Compare commits

..

5 Commits

Author SHA1 Message Date
3cb3ce6956 test(acceptance): beoordeling scenario asserts the workflow task is completed (refs #13)
All checks were successful
CI / lint (pull_request) Successful in 1m18s
CI / build (pull_request) Successful in 1m12s
CI / unit (pull_request) Successful in 1m4s
CI / frontend (pull_request) Successful in 2m11s
CI / mutation (pull_request) Successful in 5m51s
CI / verify-stack (pull_request) Successful in 8m50s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:01:46 +02:00
4ebc263bdf feat(bff): POST /behandel/registrations/{id}/decide behind the behandelaar policy (refs #13)
Forwards a behandelaar's goedkeuren/afwijzen to the domain (which applies the decision
and completes the workflow task). Validates the besluit vocabulary (400 on unknown)
without troubling the domain. openapi.json + api-client regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:00:05 +02:00
d50a60c98b test(bff): /behandel/registrations/{id}/decide auth + forwarding + besluit validation (refs #13)
Red — the decide endpoint does not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:58:45 +02:00
94d5feb6e0 feat(domain): the beoordeling decision completes the Flowable Beoordelen task (refs #13)
After applying the decision, BeoordeelRegistratie finds the open Beoordelen task for
the registration and completes it with the besluit so the workflow advances (ADR-0013).
No open task → the decision still stands (completes nothing). DI already provides
IUserTaskClient (S-12b).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:57:16 +02:00
c005b0d627 test(domain): the beoordeling decision completes the Flowable Beoordelen task (refs #13)
Red — BeoordeelRegistratie has no user-task dependency and doesn't complete the task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:56:29 +02:00
67 changed files with 43 additions and 2009 deletions

View File

@@ -157,7 +157,7 @@ jobs:
# Log dump must precede teardown (which removes the containers). # Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure - name: Dump container logs on failure
if: failure() if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
- name: Tear down - name: Tear down
if: always() if: always()
run: make down run: make down

View File

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness # Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of # into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the # bind-mounted, because bind mounts don't reach sibling containers on the

View File

@@ -1,23 +0,0 @@
# Multi-stage build for the behandel portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/behandel apps/behandel
COPY libs libs
RUN pnpm nx build behandel
FROM nginx:1.27-alpine AS runtime
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
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
# 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
EXPOSE 80

View File

@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /behandel/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,80 +0,0 @@
{
"name": "behandel",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/behandel/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/behandel",
"browser": "apps/behandel/src/main.ts",
"tsConfig": "apps/behandel/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/behandel/public"
}
],
"styles": ["apps/behandel/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "behandel:build:production"
},
"development": {
"buildTarget": "behandel:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "behandel:build",
"staticFilePath": "dist/apps/behandel/browser",
"spa": true
}
}
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,73 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
// source and token storage are faked, so the assertion turns on the actual route-matching.
describe('behandel medewerker token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'medewerker-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative werkbak call', () => {
bff.getBehandelWerkbak().subscribe();
const req = http.expectOne('/behandel/werkbak');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('attaches the bearer token to the relative decide call', () => {
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
const req = http.expectOne('/behandel/registrations/reg-1/decide');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush(null);
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});

View File

@@ -1,39 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/behandel/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}

View File

@@ -1 +0,0 @@
<router-outlet></router-outlet>

View File

@@ -1,7 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { WerkbakPage } from './werkbak/werkbak-page';
export const appRoutes: Route[] = [
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
];

View File

@@ -1,15 +0,0 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'behandel';
}

View File

@@ -1,64 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Werkbak</utrecht-heading-1>
<p utrecht-paragraph>
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Registraties in behandeling
</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">BSN</th>
<th scope="col">Status</th>
<th scope="col">Actie</th>
</tr>
</thead>
<tbody>
@for (item of items(); track item.registrationId) {
<tr>
<td>{{ item.registrationId }}</td>
<td>{{ item.bsn }}</td>
<td>{{ item.status }}</td>
<td>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'goedkeuren')"
>
Goedkeuren
</button>
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[attr.aria-label]="'Afwijzen ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'afwijzen')"
>
Afwijzen
</button>
</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>

View File

@@ -1,110 +0,0 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['behandelaar']);
login(): void {
/* not exercised here */
}
logout(): void {
/* spied in tests */
}
}
function setup(
overrides: {
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBehandelWerkbak =
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
const postBehandelRegistrationsIdDecide =
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
return {
getBehandelWerkbak,
postBehandelRegistrationsIdDecide,
providers: [
{
provide: BffApiV1Service,
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
},
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('WerkbakPage', () => {
it('lists the registrations awaiting beoordeling on open', async () => {
const { getBehandelWerkbak, providers } = setup();
await render(WerkbakPage, { providers });
expect(getBehandelWerkbak).toHaveBeenCalled();
expect(await screen.findByText('reg-1')).toBeTruthy();
expect(screen.getByText('123456782')).toBeTruthy();
expect(screen.getByText('reg-2')).toBeTruthy();
});
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'goedkeuren',
});
// Reloaded after the decision: once on open, once after deciding.
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
});
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
const { postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'afwijzen',
});
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(WerkbakPage, { providers });
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(WerkbakPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});

View File

@@ -1,65 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
/**
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
* decision posts to the BFF, which applies the domain transition and completes the workflow task
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
*/
@Component({
selector: 'app-werkbak-page',
imports: [UtrechtComponentsModule],
templateUrl: './werkbak-page.html',
})
export class WerkbakPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<WerkbakItem[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
protected readonly deciding = signal<string | undefined>(undefined);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBehandelWerkbak().subscribe({
next: (rows: WerkbakItem[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
decide(registrationId: string, besluit: Besluit): void {
this.deciding.set(registrationId);
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
// Refresh so the decided registration drops off the werkbak (its task is now completed).
next: () => {
this.deciding.set(undefined);
this.load();
},
error: () => {
this.deciding.set(undefined);
this.failed.set(true);
},
});
}
}

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportaal BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,10 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));

View File

@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';

View File

@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

View File

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

View File

@@ -17,22 +17,12 @@ class FakeAuth extends AuthService {
} }
} }
function providers( function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)),
) {
return { return {
post, post,
withdraw,
providers: [ providers: [
{ provide: AuthService, useClass: FakeAuth }, { provide: AuthService, useClass: FakeAuth },
{ { provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
provide: BffApiV1Service,
useValue: {
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
},
},
], ],
}; };
} }
@@ -66,36 +56,6 @@ describe('RegistrationPage', () => {
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
}); });
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
const { withdraw, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
expect(withdraw).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
});
it('has no WCAG 2.1 AA violations on the submit page', async () => { it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level // The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document. // html-has-lang rule reflects the app, not the bare jsdom document.

View File

@@ -6,8 +6,7 @@ import { UtrechtComponentsModule } from 'ui';
/** /**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG * The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and- * registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
*/ */
@Component({ @Component({
selector: 'app-registration-page', selector: 'app-registration-page',
@@ -23,9 +22,6 @@ export class RegistrationPage {
protected readonly reference = signal<string | undefined>(undefined); protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false); protected readonly submitted = signal(false);
protected readonly failed = signal(false); protected readonly failed = signal(false);
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
submit(): void { submit(): void {
this.submitting.set(true); this.submitting.set(true);
@@ -43,24 +39,4 @@ export class RegistrationPage {
}, },
}); });
} }
withdraw(): void {
const reference = this.reference();
if (!reference) {
return;
}
this.withdrawing.set(true);
this.withdrawFailed.set(false);
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
next: () => {
this.withdrawn.set(true);
this.withdrawing.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.withdrawFailed.set(true);
this.withdrawing.set(false);
},
});
}
} }

View File

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

View File

@@ -248,53 +248,3 @@ curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
> The openbaar register's "Referentie" column and its search now use this reference — the exact value > The openbaar register's "Referentie" column and its search now use this reference — the exact value
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path. > the citizen saw on submit. Asserted end-to-end by the Playwright happy path.
## S-12 — Behandel portal: werkbak + beoordeling (#13, ADR-0013)
A behandelaar now works submitted registrations in a real portal instead of the temporary admin
endpoint. After a citizen submits (as above), the workflow parks the registration at the Flowable
`Beoordelen` user task, and it shows up in the **werkbak**. The behandelaar logs in against the
Keycloak `medewerker` realm and decides — **goedkeuren** (→ INGESCHREVEN via the ACL, per ADR-0011)
or **afwijzen** — which also completes the Beoordelen task so the process advances.
```text
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
# http://localhost:8142/ → merel-behandelaar / test123
#
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
#
# 3. The row drops off the werkbak (its Beoordelen task is completed) and the openbaar register
# (http://localhost:8141/) now shows that reference as INGESCHREVEN.
```
**The path:** behandel portal → BFF `POST /behandel/registrations/{id}/decide` (behandelaar policy,
`medewerker` realm) → domain applies the decision + completes the Flowable `Beoordelen` task →
ACL → NRC → event-subscriber → projection → openbaar register shows INGESCHREVEN.
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
> drives the behandel portal in place of the old admin endpoint.
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
behandelaar's werkbak.
```text
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
# note the "Referentie" on the confirmation.
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
# appears — its Beoordelen task was cancelled.
```
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
interrupting boundary event ends it → the werkbak drops the case.
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
> `Een registratie intrekken` acceptance scenarios and the domain live check.

View File

@@ -117,53 +117,3 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders. (id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
- **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and - **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query. re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.
## Behandel portal (S-12, #13)
The staff portal where a behandelaar works the **werkbak** (registrations awaiting beoordeling) and
decides each — goedkeuren or afwijzen. `apps/behandel` mirrors `apps/self-service`; the net-new
frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in
**ADR-0013**; this entry records the frontend-specific choices.
- **Medewerker realm auth, reusing `libs/auth`.** Staff authenticate against the Keycloak
`medewerker` realm (public client `big-portal`), not `digid`. Rather than fork the auth lib, the
abstract `AuthService` grew a **`roles`/`hasRole` surface** (empty for realms without roles, e.g.
`digid`), and a parallel **`MedewerkerAuthService` + `provideMedewerkerAuth`** were added — same
auth-code + PKCE config, bound to the medewerker realm, reading the nested `realm_access.roles`
claim. The library's own `authInterceptor` attaches the token to the relative `/behandel/` calls
(secure route), exactly as self-service does for `/self-service/`.
- **Roles reach the frontend via a realm mapper.** Keycloak emits realm roles in the access token by
default but not the ID token/userinfo the SPA reads, so the medewerker `big-portal` client gets a
**realm-roles protocol mapper** (`realm_access.roles`, added to id + userinfo tokens). The
**BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013);
the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a
403 for a non-behandelaar) rather than swallowing it.
- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built
app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`,
health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm.
- **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a
row per registration (referentie/bsn/status). Goedkeuren/afwijzen `POST /behandel/registrations/
{id}/decide` and then reload the werkbak, so the handled item drops off (its Flowable `Beoordelen`
task is completed). Per-row decide buttons carry an `aria-label` including the reference, so the
e2e (and screen readers) can target a specific registration in a shared werkbak.
- **Testing.** Component tests use `@testing-library/angular` with `BffApiV1Service`/`AuthService`
mocked and the axe WCAG 2.1 AA check; an `app.config.spec` drives the real interceptor + api-client
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
path.
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
browser round-trip is `tests/e2e/withdrawal.spec.ts`.

View File

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

View File

@@ -486,29 +486,6 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:

View File

@@ -16,22 +16,7 @@
"standardFlowEnabled": true, "standardFlowEnabled": true,
"directAccessGrantsEnabled": true, "directAccessGrantsEnabled": true,
"redirectUris": ["*"], "redirectUris": ["*"],
"webOrigins": ["*"], "webOrigins": ["*"]
"protocolMappers": [
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"multivalued": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
} }
], ],
"users": [ "users": [

View File

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

View File

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

View File

@@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
fl_base="http://$(ip "$fl"):8080/flowable-rest/service" fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}" reg_id="${loc##*/}"
# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on # Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. # Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
try: try:
d=json.load(sys.stdin) d=json.load(sys.stdin)
except Exception: except Exception:
@@ -98,7 +98,7 @@ echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id="" task_id=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")" task_id="$(printf '%s' "$resp" | task_for_reg)"
[ -n "$task_id" ] && break [ -n "$task_id" ] && break
sleep 2 sleep 2
done done
@@ -115,46 +115,7 @@ flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/j
echo ">> asserting the process finished (no Beoordelen task remains for the registration)" echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
still="$(printf '%s' "$resp" | task_for_reg "$reg_id")" still="$(printf '%s' "$resp" | task_for_reg)"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } [ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
echo ">> submitting a second registration to withdraw"
loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2"
echo ">> polling Flowable for its Beoordelen task"
task_id2=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -n "$task_id2" ] && break
sleep 2
done
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
# Owner-scoped: the withdraw carries the same bsn the registration was submitted with (S-11c).
docker run --rm --network "$net" curlimages/curl:latest \
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
gone=""
for _ in $(seq 1 15); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
still2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -z "$still2" ] && { gone=1; break; }
sleep 2
done
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
exit 0 exit 0

View File

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

View File

@@ -1,6 +1,4 @@
export * from './lib/auth.service'; export * from './lib/auth.service';
export * from './lib/digid-auth.service'; export * from './lib/digid-auth.service';
export * from './lib/digid-auth.providers'; export * from './lib/digid-auth.providers';
export * from './lib/medewerker-auth.service';
export * from './lib/medewerker-auth.providers';
export * from './lib/authenticated.guard'; export * from './lib/authenticated.guard';

View File

@@ -1,26 +1,16 @@
import { signal, Signal } from '@angular/core'; import { Signal } from '@angular/core';
/** /**
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and * The portal's view of the signed-in user. An abstraction over the OIDC library so components and
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for * guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
* citizens and MedewerkerAuthService for staff).
*/ */
export abstract class AuthService { export abstract class AuthService {
/** Whether a session is active. */ /** Whether a DigiD session is active. */
abstract readonly isAuthenticated: Signal<boolean>; abstract readonly isAuthenticated: Signal<boolean>;
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */ /** The citizen-service number from the DigiD token, once authenticated. */
abstract readonly bsn: Signal<string | undefined>; abstract readonly bsn: Signal<string | undefined>;
/** /** Start the DigiD login (redirects to Keycloak). */
* The realm roles carried in the token. Empty for realms that don't grant roles (e.g. `digid`);
* the `medewerker` realm carries `behandelaar`/`teamlead`.
*/
readonly roles: Signal<readonly string[]> = signal<readonly string[]>([]);
/** Start login (redirects to Keycloak). */
abstract login(): void; abstract login(): void;
/** End the session. */ /** End the session. */
abstract logout(): void; abstract logout(): void;
/** Whether the signed-in user holds the given realm role. */
hasRole(role: string): boolean {
return this.roles().includes(role);
}
} }

View File

@@ -1,49 +0,0 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client';
import { AuthService } from './auth.service';
import { MedewerkerAuthService } from './medewerker-auth.service';
export interface MedewerkerAuthOptions {
/** The Keycloak `medewerker` realm issuer, as reachable from the browser. */
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
secureRoutes: string[];
}
/**
* Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code +
* PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register
* {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF
* calls carry the token.
*/
export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideAuth(
{
config: {
authority: options.authority,
redirectUrl: options.redirectUrl,
postLogoutRedirectUri: options.redirectUrl,
clientId: 'big-portal',
scope: 'openid profile',
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},
// Run checkAuth() at startup so the login callback (?code=…) is processed before the router
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
withAppInitializerAuthCheck(),
),
{ provide: AuthService, useClass: MedewerkerAuthService },
]);
}

View File

@@ -1,43 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { of } from 'rxjs';
import { MedewerkerAuthService } from './medewerker-auth.service';
function makeService(userData: unknown, authenticated = true) {
const oidc = {
isAuthenticated$: of({ isAuthenticated: authenticated }),
userData$: of({ userData }),
authorize: vi.fn(),
logoff: vi.fn(() => of(null)),
};
TestBed.configureTestingModule({
providers: [MedewerkerAuthService, { provide: OidcSecurityService, useValue: oidc }],
});
return { svc: TestBed.inject(MedewerkerAuthService), oidc };
}
describe('MedewerkerAuthService', () => {
it('exposes the realm roles carried in the token', () => {
const { svc } = makeService({ realm_access: { roles: ['behandelaar', 'teamlead'] } });
expect(svc.roles()).toEqual(['behandelaar', 'teamlead']);
expect(svc.hasRole('behandelaar')).toBe(true);
expect(svc.hasRole('beheerder')).toBe(false);
});
it('has no roles when the token omits realm_access', () => {
const { svc } = makeService({ preferred_username: 'merel-behandelaar' });
expect(svc.roles()).toEqual([]);
expect(svc.hasRole('behandelaar')).toBe(false);
});
it('reflects the OIDC authenticated state', () => {
const { svc } = makeService({}, true);
expect(svc.isAuthenticated()).toBe(true);
});
it('starts login by delegating to the OIDC library', () => {
const { svc, oidc } = makeService({});
svc.login();
expect(oidc.authorize).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,41 +0,0 @@
import { inject, Injectable, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';
import { AuthService } from './auth.service';
/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */
interface MedewerkerClaims {
realm_access?: { roles?: string[] };
}
/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */
@Injectable()
export class MedewerkerAuthService extends AuthService {
private readonly oidc = inject(OidcSecurityService);
readonly isAuthenticated: Signal<boolean> = toSignal(
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
{ initialValue: false },
);
// Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor.
readonly bsn: Signal<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
initialValue: undefined,
});
override readonly roles: Signal<readonly string[]> = toSignal(
this.oidc.userData$.pipe(
map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []),
),
{ initialValue: [] },
);
override login(): void {
this.oidc.authorize();
}
override logout(): void {
this.oidc.logoff().subscribe();
}
}

View File

@@ -22,11 +22,6 @@ public interface IDomainClient
{ {
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default); Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary> /// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default); Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
@@ -52,17 +47,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status); return new SubmitAccepted(dto.RegistrationId, dto.Status);
} }
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? []; => await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];

View File

@@ -86,24 +86,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized); .Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
return withdrawn ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09). // Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) => app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{ {

View File

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

View File

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

View File

@@ -30,37 +30,6 @@
} }
} }
}, },
"/self-service/registrations/{id}/withdraw": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/openbaar/register": { "/openbaar/register": {
"get": { "get": {
"tags": [ "tags": [

View File

@@ -26,7 +26,6 @@ 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<Werkbak>(); builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -75,22 +74,6 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
return Results.NoContent(); return Results.NoContent();
}); });
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is
// 404 (indistinguishable, so ownership isn't leaked). Idempotent.
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn))
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." });
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open // 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).
@@ -116,8 +99,6 @@ public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit); public sealed record DecideRequest(string Besluit);
public sealed record WithdrawRequest(string Bsn);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program; public partial class Program;

View File

@@ -15,14 +15,6 @@ public interface IWorkflowClient
/// aggregate. Returns the process instance id. /// aggregate. Returns the process instance id.
/// </summary> /// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
/// <c>RegistratieIngetrokken</c> message to the instance, tripping the interrupting message event
/// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
/// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
} }
/// <summary> /// <summary>

View File

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

View File

@@ -1,55 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
/// only the registration's own bsn may withdraw it.</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a withdrawal request.</summary>
public enum WithdrawOutcome
{
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
Withdrawn,
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone
/// else (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
}
/// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
{
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand 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 WithdrawOutcome.NotFound;
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken)
return WithdrawOutcome.Withdrawn;
registration.Withdraw();
await store.SaveAsync(registration, ct);
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
if (registration.ProcessInstanceId is not null)
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
return WithdrawOutcome.Withdrawn;
}
}

View File

@@ -111,23 +111,7 @@ public sealed class Registration
Status = RegistrationStatus.Afgewezen; Status = RegistrationStatus.Afgewezen;
} }
/// <summary> // A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
/// Withdraw the registration — the zorgprofessional pulls their own submission back (S-11). Allowed
/// while it is still open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a registration that has
/// already been decided (INGESCHREVEN/AFGEWEZEN) can no longer be withdrawn. Re-withdrawing one
/// already <see cref="RegistrationStatus.Ingetrokken"/> is a no-op.
/// </summary>
public void Withdraw()
{
if (Status == RegistrationStatus.Ingetrokken)
return;
RequireOpenForDecision(nameof(Withdraw));
Status = RegistrationStatus.Ingetrokken;
}
// A decision (or withdrawal) 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))

View File

@@ -3,8 +3,7 @@ namespace Big.Domain;
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in /// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one /// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected). /// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
/// A zorgprofessional can withdraw a still-open registration into <see cref="Ingetrokken"/> (S-11). /// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
/// The herregistratie state arrives in its own slice.</summary>
public enum RegistrationStatus public enum RegistrationStatus
{ {
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary> /// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
@@ -18,7 +17,4 @@ public enum RegistrationStatus
/// <summary>Rejected by the behandelaar. Terminal.</summary> /// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen, Afgewezen,
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
Ingetrokken,
} }

View File

@@ -23,7 +23,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private const string RegistrationIdVariable = "registrationId"; private const string RegistrationIdVariable = "registrationId";
private const string ZaakUrlVariable = "zaakUrl"; private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit"; private const string BesluitVariable = "besluit";
private const string IngetrokkenMessage = "RegistratieIngetrokken";
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
@@ -86,35 +85,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
// Correlate the withdrawal message to the instance: find the execution subscribed to it (the
// interrupting message event's own execution — NOT the user task's), then deliver
// messageEventReceived to that execution so the process ends (ADR-0014). If nothing is
// subscribed (the process is not parked at Beoordelen) this is a best-effort no-op.
var subscribed = await GetAsync<ExecutionQueryResult>(
$"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}",
ct);
var execution = subscribed?.Data?.FirstOrDefault();
if (execution is null)
return;
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
using var response = await SendAsync(
$"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct) 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);
@@ -122,9 +92,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
return await response.Content.ReadFromJsonAsync<TResponse>(ct); return await response.Content.ReadFromJsonAsync<TResponse>(ct);
} }
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct, HttpMethod? method = null) private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
{ {
var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path)) var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{ {
Content = JsonContent.Create(body), Content = JsonContent.Create(body),
}; };
@@ -162,10 +132,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables); [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record MessageEventRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("messageName")] string MessageName);
private sealed record TaskQueryResult( private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data); [property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
@@ -188,11 +154,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
private sealed record ExecutionQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredJob( private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables) [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)

View File

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

View File

@@ -242,61 +242,6 @@ public class FlowableWorkflowClientTests
Assert.Contains($"\"value\":\"{expected}\"", capture.Body); Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
} }
[Fact]
public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution()
{
HttpRequestMessage? getReq = null;
HttpRequestMessage? putReq = null;
string? putBody = null;
var client = Client(new StubHandler(async req =>
{
if (req.Method == HttpMethod.Get)
{
getReq = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
};
}
putReq = req;
putBody = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.WithdrawProcessAsync("pi-1");
// 1. Find the execution subscribed to the withdrawal message for this instance.
Assert.Equal(HttpMethod.Get, getReq!.Method);
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
Assert.Equal(HttpMethod.Put, putReq!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
putReq.RequestUri!.ToString());
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
}
[Fact]
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
{
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
});
}));
await client.WithdrawProcessAsync("pi-1");
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
}
[Fact] [Fact]
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request() public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
{ {

View File

@@ -217,72 +217,4 @@ public class RegistrationTests
Assert.Contains("Afgewezen", rejectEx.Message); Assert.Contains("Afgewezen", rejectEx.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
} }
[Fact]
public void Withdrawing_an_ingediend_registration_sets_it_ingetrokken()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_an_in_behandeling_registration_sets_it_ingetrokken()
{
// A citizen can still pull a registration back while a behandelaar has it in behandeling.
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_needs_no_zaak()
{
// Withdrawal is the citizen's own action and does not depend on the zaak having been opened.
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_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.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Withdrawing_a_rejected_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Reject();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
} }

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
# language: en
# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
Feature: Een registratie intrekken
Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
Scenario: De zorgprofessional trekt zijn eigen registratie in
Given a submitted registration with a running process
When the zorgprofessional withdraws it
Then the withdrawal succeeds
And the registration has status "INGETROKKEN"
And the running process is cancelled
Scenario: Een andere zorgprofessional kan de registratie niet intrekken
Given a submitted registration with a running process
When a different zorgprofessional tries to withdraw it
Then the withdrawal is reported not found
And the registration has status "INGEDIEND"
And the running process is not cancelled

View File

@@ -1,59 +0,0 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenRegistratieIntrekken.feature</c> (S-11). Submits a registration (which
/// records its process) and then applies the WithdrawRegistration use case against in-memory ports;
/// one instance per scenario. Scoped to this feature so its "the registration has status" step does
/// not clash with the identically phrased steps in the other features.</summary>
[Binding]
[Scope(Feature = "Een registratie intrekken")]
public sealed class EenRegistratieIntrekkenSteps
{
private const string OwnerBsn = "123456782";
private readonly InMemoryRegistrationStore _store = new();
private readonly InMemoryWorkflowClient _workflow = new();
private RegistrationId _id;
private WithdrawOutcome _outcome;
[Given("a submitted registration with a running process")]
public async Task GivenASubmittedRegistrationWithARunningProcess()
=> _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
[When("the zorgprofessional withdraws it")]
public async Task WhenTheZorgprofessionalWithdrawsIt()
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
new WithdrawRegistrationCommand(_id, OwnerBsn));
[When("a different zorgprofessional tries to withdraw it")]
public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt()
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
new WithdrawRegistrationCommand(_id, "999999990"));
[Then("the withdrawal succeeds")]
public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome);
[Then("the withdrawal is reported not found")]
public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome);
[Then("the registration has status \"(.*)\"")]
public async Task ThenTheRegistrationHasStatus(string expected)
{
var registration = await _store.GetAsync(_id);
Assert.NotNull(registration);
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
}
[Then("the running process is cancelled")]
public void ThenTheRunningProcessIsCancelled()
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId);
[Then("the running process is not cancelled")]
public void ThenTheRunningProcessIsNotCancelled()
=> Assert.Null(_workflow.WithdrawnProcessInstanceId);
}

View File

@@ -69,9 +69,6 @@ public sealed class CapturingDomainClient : IDomainClient
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
} }
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true);
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]); => Task.FromResult<IReadOnlyList<WerkbakItem>>([]);

View File

@@ -11,19 +11,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
public const string StartedProcessInstanceId = "proc-acc-1"; public const string StartedProcessInstanceId = "proc-acc-1";
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
StartedFor = registrationId; StartedFor = registrationId;
return Task.FromResult(StartedProcessInstanceId); return Task.FromResult(StartedProcessInstanceId);
} }
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
} }
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, /// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,

View File

@@ -3,9 +3,6 @@ import { defineConfig, devices } from '@playwright/test';
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the // The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow. // self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service'; const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service';
// The behandel portal is a second origin the happy path visits (staff approve from the werkbak);
// it needs the same insecure-origin-as-secure treatment as self-service for the PKCE login (below).
const behandelURL = process.env.BEHANDEL_URL ?? 'http://behandel';
export default defineConfig({ export default defineConfig({
testDir: '.', testDir: '.',
@@ -25,9 +22,7 @@ export default defineConfig({
// the production HTTPS context. This flag is only honoured by the full Chromium build (new // the production HTTPS context. This flag is only honoured by the full Chromium build (new
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`. // headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
channel: 'chromium', channel: 'chromium',
launchOptions: { launchOptions: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
},
}, },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
}); });

View File

@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock // Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar // submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in // as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and // ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN. test('DigiD login → submit → public INGEDIENDapprove → public INGESCHREVEN', async ({ page, request }) => {
test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public INGESCHREVEN', async ({ page }) => {
// 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('/');
@@ -44,42 +43,21 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' })) await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible(); .toBeVisible();
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it // Approve via the temporary admin endpoint (reached directly on the compose network, as a
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches // behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
// to the medewerker realm (a different Keycloak realm than the citizen's digid session). // path by the worker, so wait for it before approving.
await page.goto('http://behandel/');
await page.locator('#username').fill('merel-behandelaar');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// The registration parks at the Beoordelen user task only after the worker has opened its zaak, so
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
// tasks, so a positional match could act on someone else's registration.
const goedkeuren = page.getByRole('button', { name: `Goedkeuren ${reference}` });
await expect await expect
.poll(async () => { .poll(async () => {
await page.reload(); const res = await request.get(`http://domain:8080/registrations/${reference}`);
return goedkeuren.count(); return res.ok() ? (await res.json()).zaakUrl : null;
}, { 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); .toBeTruthy();
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and expect(approve.status()).toBe(204);
// the decision never reaches the domain — so the registration would stay INGEDIEND.
const decided = page.waitForResponse(
(r) =>
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
r.request().method() === 'POST',
);
await goedkeuren.click();
expect((await decided).status()).toBe(204);
// The approval flows back to the projection; back on the openbaar register *our* row (matched by // The approval flows back to the projection; the openbaar register now shows *our* row (matched by
// its reference) now shows INGESCHREVEN. // its reference) as INGESCHREVEN.
await page.goto('http://openbaar/');
await expect await expect
.poll(async () => { .poll(async () => {
await page.reload(); await page.reload();

View File

@@ -1,27 +0,0 @@
import { expect, test } from '@playwright/test';
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
// then confirms the registration is ingetrokken.
test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
await page.locator('#username').fill('jan-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
await page.getByRole('button', { name: /indienen/i }).click();
// The BFF accepted it and the page shows the confirmation with the reference.
await expect(page.getByText(/ontvangen/i)).toBeVisible();
// Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
// so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
await page.getByRole('button', { name: /trek aanvraag in/i }).click();
await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
});

View File

@@ -11,13 +11,7 @@
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
as a user task: the process parks here until a behandelaar claims and completes it with a as a user task: the process parks here until a behandelaar claims and completes it with a
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process `besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
variable so the werkbak can correlate each task back to its aggregate. variable so the werkbak can correlate each task back to its aggregate. -->
S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an
interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the
process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message
to the task's execution; the BPMN owns the cancellation path. -->
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
<process id="registratie" name="Registratie ontvangen" isExecutable="true"> <process id="registratie" name="Registratie ontvangen" isExecutable="true">
<startEvent id="start" name="Registratie ontvangen"/> <startEvent id="start" name="Registratie ontvangen"/>
@@ -35,16 +29,6 @@
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/> <sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
<endEvent id="end" name="Registratie beoordeeld"/> <endEvent id="end" name="Registratie beoordeeld"/>
<!-- Withdrawal (S-11): interrupting message boundary event on Beoordelen. On RegistratieIngetrokken
the task is cancelled and the process ends as "ingetrokken". -->
<boundaryEvent id="Ingetrokken" attachedToRef="Beoordelen" cancelActivity="true">
<messageEventDefinition messageRef="Message_Ingetrokken"/>
</boundaryEvent>
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
</process> </process>
<bpmndi:BPMNDiagram id="diagram"> <bpmndi:BPMNDiagram id="diagram">
@@ -73,16 +57,6 @@
<omgdi:waypoint x="510" y="115"/> <omgdi:waypoint x="510" y="115"/>
<omgdi:waypoint x="580" y="115"/> <omgdi:waypoint x="580" y="115"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
<omgdc:Bounds x="435" y="135" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
<omgdc:Bounds x="435" y="220" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
<omgdi:waypoint x="450" y="165"/>
<omgdi:waypoint x="450" y="220"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane> </bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>
</definitions> </definitions>