Compare commits
9 Commits
feat/13-be
...
fix/portal
| Author | SHA1 | Date | |
|---|---|---|---|
| 417f6abfa8 | |||
| e7bed37cda | |||
| 5de2e9367d | |||
| 94699f3603 | |||
| 951bdd8364 | |||
| 2397d9196a | |||
| a34caba9ea | |||
| 1f1c944a8b | |||
| 3abf8f7ccf |
@@ -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 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 openbaar behandel 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -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
|
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
|
||||||
# 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
|
||||||
|
|||||||
27
apps/behandel/Dockerfile
Normal file
27
apps/behandel/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 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
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
34
apps/behandel/eslint.config.mjs
Normal file
34
apps/behandel/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
24
apps/behandel/nginx.conf
Normal file
24
apps/behandel/nginx.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/behandel/project.json
Normal file
80
apps/behandel/project.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/behandel/public/config.json
Normal file
3
apps/behandel/public/config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
BIN
apps/behandel/public/favicon.ico
Normal file
BIN
apps/behandel/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
73
apps/behandel/src/app/app.config.spec.ts
Normal file
73
apps/behandel/src/app/app.config.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/behandel/src/app/app.config.ts
Normal file
39
apps/behandel/src/app/app.config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
0
apps/behandel/src/app/app.css
Normal file
0
apps/behandel/src/app/app.css
Normal file
1
apps/behandel/src/app/app.html
Normal file
1
apps/behandel/src/app/app.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
7
apps/behandel/src/app/app.routes.ts
Normal file
7
apps/behandel/src/app/app.routes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { authenticatedGuard } from 'auth';
|
||||||
|
import { WerkbakPage } from './werkbak/werkbak-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [
|
||||||
|
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
|
||||||
|
];
|
||||||
15
apps/behandel/src/app/app.spec.ts
Normal file
15
apps/behandel/src/app/app.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
apps/behandel/src/app/app.ts
Normal file
12
apps/behandel/src/app/app.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
64
apps/behandel/src/app/werkbak/werkbak-page.html
Normal file
64
apps/behandel/src/app/werkbak/werkbak-page.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<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>
|
||||||
110
apps/behandel/src/app/werkbak/werkbak-page.spec.ts
Normal file
110
apps/behandel/src/app/werkbak/werkbak-page.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/behandel/src/app/werkbak/werkbak-page.ts
Normal file
65
apps/behandel/src/app/werkbak/werkbak-page.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/behandel/src/index.html
Normal file
13
apps/behandel/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
10
apps/behandel/src/main.ts
Normal file
10
apps/behandel/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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));
|
||||||
2
apps/behandel/src/styles.css
Normal file
2
apps/behandel/src/styles.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
9
apps/behandel/tsconfig.app.json
Normal file
9
apps/behandel/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
31
apps/behandel/tsconfig.json
Normal file
31
apps/behandel/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
apps/behandel/tsconfig.spec.json
Normal file
8
apps/behandel/tsconfig.spec.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -17,5 +17,9 @@ FROM nginx:1.27-alpine AS runtime
|
|||||||
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
||||||
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
17
apps/portal-nginx-resolver.sh
Normal file
17
apps/portal-nginx-resolver.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
|
||||||
|
#
|
||||||
|
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
|
||||||
|
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
|
||||||
|
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
|
||||||
|
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
|
||||||
|
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
|
||||||
|
#
|
||||||
|
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
|
||||||
|
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
|
||||||
|
# entrypoint executes or sources it.
|
||||||
|
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
|
||||||
|
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
|
||||||
|
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||||
|
echo "portal-nginx-resolver: set resolver to $ns"
|
||||||
|
fi
|
||||||
@@ -19,5 +19,9 @@ COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
|
|||||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
||||||
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -3,9 +3,29 @@
|
|||||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||||
|
|
||||||
@if (submitted()) {
|
@if (submitted()) {
|
||||||
<p utrecht-paragraph role="status">
|
@if (withdrawn()) {
|
||||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
<p utrecht-paragraph role="status">
|
||||||
</p>
|
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p utrecht-paragraph role="status">
|
||||||
|
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||||
|
</p>
|
||||||
|
@if (withdrawFailed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="secondary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="withdrawing()"
|
||||||
|
(click)="withdraw()"
|
||||||
|
>
|
||||||
|
Trek aanvraag in
|
||||||
|
</button>
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||||
@if (failed()) {
|
@if (failed()) {
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ class FakeAuth extends AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
function providers(
|
||||||
|
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
|
withdraw,
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
{
|
||||||
|
provide: BffApiV1Service,
|
||||||
|
useValue: {
|
||||||
|
postSelfServiceRegistrations: post,
|
||||||
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +66,36 @@ describe('RegistrationPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||||
|
const { withdraw, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||||
|
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||||
|
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui';
|
|||||||
/**
|
/**
|
||||||
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
||||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||||
|
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
@@ -22,6 +23,9 @@ export class RegistrationPage {
|
|||||||
protected readonly reference = signal<string | undefined>(undefined);
|
protected readonly reference = signal<string | undefined>(undefined);
|
||||||
protected readonly submitted = signal(false);
|
protected readonly submitted = signal(false);
|
||||||
protected readonly failed = signal(false);
|
protected readonly failed = signal(false);
|
||||||
|
protected readonly withdrawing = signal(false);
|
||||||
|
protected readonly withdrawn = signal(false);
|
||||||
|
protected readonly withdrawFailed = signal(false);
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
@@ -39,4 +43,24 @@ export class RegistrationPage {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
const reference = this.reference();
|
||||||
|
if (!reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.withdrawing.set(true);
|
||||||
|
this.withdrawFailed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.withdrawn.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.withdrawFailed.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
docs/architecture/adr-0014-withdrawal-cancels-the-process.md
Normal file
72
docs/architecture/adr-0014-withdrawal-cancels-the-process.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-16
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
|
||||||
|
(behandel-portal wiring, the Beoordelen user task)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
|
||||||
|
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
|
||||||
|
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
|
||||||
|
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
|
||||||
|
|
||||||
|
Two questions shape this sub-slice.
|
||||||
|
|
||||||
|
1. **How does the case get cancelled — in code, or in the BPMN model?**
|
||||||
|
2. **How does a withdrawal correlate to the right running process instance?**
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
|
||||||
|
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
|
||||||
|
|
||||||
|
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
|
||||||
|
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
|
||||||
|
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
|
||||||
|
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
|
||||||
|
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
|
||||||
|
(the process ended via the ingetrokken path, not a raw delete).
|
||||||
|
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
|
||||||
|
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
|
||||||
|
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
|
||||||
|
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
|
||||||
|
Targeting the subscribed execution (not the user task's execution — a message boundary event's
|
||||||
|
subscription lives on its own execution) is what makes the correlation land.
|
||||||
|
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
|
||||||
|
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
|
||||||
|
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
|
||||||
|
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
|
||||||
|
completes its task best-effort.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
|
||||||
|
that talks to Flowable and does not delete instances behind the model's back.
|
||||||
|
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
|
||||||
|
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
|
||||||
|
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
|
||||||
|
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
|
||||||
|
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
|
||||||
|
reference at these volumes; a process-level interrupting event subprocess would close the gap and
|
||||||
|
is an additive follow-up if it matters.
|
||||||
|
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
|
||||||
|
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
|
||||||
|
(consistent with ADR-0009).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
|
||||||
|
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
|
||||||
|
not the process. The message event keeps the cancellation a first-class part of the workflow.
|
||||||
|
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
|
||||||
|
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
|
||||||
|
change if the race proves to matter.
|
||||||
@@ -248,3 +248,53 @@ 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.
|
||||||
|
|||||||
@@ -117,3 +117,53 @@ 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`.
|
||||||
|
|||||||
@@ -17,7 +17,12 @@
|
|||||||
#
|
#
|
||||||
# Port map (host):
|
# Port map (host):
|
||||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||||
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
|
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||||
|
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
|
||||||
|
#
|
||||||
|
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
|
||||||
|
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
|
||||||
|
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -205,6 +210,12 @@ services:
|
|||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
KC_HTTP_ENABLED: "true"
|
KC_HTTP_ENABLED: "true"
|
||||||
|
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
|
||||||
|
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
|
||||||
|
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
|
||||||
|
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
|
||||||
|
KC_HOSTNAME: http://localhost:8180
|
||||||
|
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||||
ports:
|
ports:
|
||||||
- "8180:8080"
|
- "8180:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -295,6 +306,14 @@ services:
|
|||||||
context: ../services/bff
|
context: ../services/bff
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: register-referentie/bff:dev
|
image: register-referentie/bff:dev
|
||||||
|
environment:
|
||||||
|
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
|
||||||
|
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
|
||||||
|
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
|
||||||
|
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||||
|
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||||
|
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||||
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -303,6 +322,39 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
domain:
|
||||||
|
condition: service_healthy
|
||||||
|
projection-api:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
|
||||||
|
domain:
|
||||||
|
build:
|
||||||
|
context: ../services/domain
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: register-referentie/domain:dev
|
||||||
|
environment:
|
||||||
|
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||||
|
Flowable__Username: rest-admin
|
||||||
|
Flowable__Password: test
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
|
ports:
|
||||||
|
- "8130:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
|
flowable-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||||
@@ -328,6 +380,10 @@ services:
|
|||||||
image: register-referentie/event-subscriber:dev
|
image: register-referentie/event-subscriber:dev
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||||
|
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
|
||||||
|
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
|
||||||
|
# canonical compose).
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
ports:
|
ports:
|
||||||
- "8110:8080"
|
- "8110:8080"
|
||||||
@@ -340,6 +396,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
projection-db:
|
projection-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
@@ -362,6 +420,73 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
|
||||||
|
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
|
||||||
|
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
|
||||||
|
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
|
||||||
|
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
|
||||||
|
self-service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/self-service/Dockerfile
|
||||||
|
image: register-referentie/self-service:dev
|
||||||
|
ports:
|
||||||
|
- "8140:80"
|
||||||
|
volumes:
|
||||||
|
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
openbaar:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/openbaar/Dockerfile
|
||||||
|
image: register-referentie/openbaar:dev
|
||||||
|
ports:
|
||||||
|
- "8141:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
behandel:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/behandel/Dockerfile
|
||||||
|
image: register-referentie/behandel:dev
|
||||||
|
ports:
|
||||||
|
- "8142:80"
|
||||||
|
volumes:
|
||||||
|
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
|
|||||||
@@ -486,6 +486,29 @@ 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:
|
||||||
|
|||||||
@@ -16,7 +16,22 @@
|
|||||||
"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": [
|
||||||
|
|||||||
3
infra/local-config/behandel.config.json
Normal file
3
infra/local-config/behandel.config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
3
infra/local-config/self-service.config.json
Normal file
3
infra/local-config/self-service.config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/digid"
|
||||||
|
}
|
||||||
@@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
|
|||||||
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
|
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
|
||||||
reg_id="${loc##*/}"
|
reg_id="${loc##*/}"
|
||||||
|
|
||||||
# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
|
# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on
|
||||||
# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
|
# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
|
||||||
task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
|
task_for_reg() { REG_ID="$1" 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)"
|
task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
||||||
[ -n "$task_id" ] && break
|
[ -n "$task_id" ] && break
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
@@ -115,7 +115,46 @@ flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/j
|
|||||||
|
|
||||||
echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
|
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)"
|
still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
||||||
[ -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
|
||||||
|
|||||||
@@ -192,6 +192,40 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(
|
||||||
|
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: 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>>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { Signal } from '@angular/core';
|
import { signal, 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 implementation is DigiadAuthService).
|
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for
|
||||||
|
* citizens and MedewerkerAuthService for staff).
|
||||||
*/
|
*/
|
||||||
export abstract class AuthService {
|
export abstract class AuthService {
|
||||||
/** Whether a DigiD session is active. */
|
/** Whether a session is active. */
|
||||||
abstract readonly isAuthenticated: Signal<boolean>;
|
abstract readonly isAuthenticated: Signal<boolean>;
|
||||||
/** The citizen-service number from the DigiD token, once authenticated. */
|
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
libs/auth/src/lib/medewerker-auth.providers.ts
Normal file
49
libs/auth/src/lib/medewerker-auth.providers.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
43
libs/auth/src/lib/medewerker-auth.service.spec.ts
Normal file
43
libs/auth/src/lib/medewerker-auth.service.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
libs/auth/src/lib/medewerker-auth.service.ts
Normal file
41
libs/auth/src/lib/medewerker-auth.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ 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);
|
||||||
|
|
||||||
@@ -47,6 +52,17 @@ 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) ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ 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) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ 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)
|
||||||
|
|||||||
@@ -71,5 +71,46 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/self-service/registrations/{id}/withdraw": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/openbaar/register": {
|
"/openbaar/register": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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>();
|
||||||
@@ -74,6 +75,22 @@ 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).
|
||||||
@@ -99,6 +116,8 @@ 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;
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ 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>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -8,7 +10,8 @@ public sealed record WerkbakItem(string RegistrationId, string Bsn, string Statu
|
|||||||
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
|
/// 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 is skipped rather than invented.
|
/// whose registration the domain doesn't know, or whose registration is no longer open for beoordeling
|
||||||
|
/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||||
{
|
{
|
||||||
@@ -20,7 +23,7 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
|||||||
foreach (var task in open)
|
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)
|
if (registration is null || !IsOpenForBeoordeling(registration.Status))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
items.Add(new WerkbakItem(
|
items.Add(new WerkbakItem(
|
||||||
@@ -29,4 +32,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
55
services/domain/Big.Application/WithdrawRegistration.cs
Normal file
55
services/domain/Big.Application/WithdrawRegistration.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,7 +111,23 @@ public sealed class Registration
|
|||||||
Status = RegistrationStatus.Afgewezen;
|
Status = RegistrationStatus.Afgewezen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
|
/// <summary>
|
||||||
|
/// 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))
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ 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).
|
||||||
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
|
/// A zorgprofessional can withdraw a still-open registration into <see cref="Ingetrokken"/> (S-11).
|
||||||
|
/// 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>
|
||||||
@@ -17,4 +18,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -85,6 +86,35 @@ 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);
|
||||||
@@ -92,9 +122,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)
|
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct, HttpMethod? method = null)
|
||||||
{
|
{
|
||||||
var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||||
{
|
{
|
||||||
Content = JsonContent.Create(body),
|
Content = JsonContent.Create(body),
|
||||||
};
|
};
|
||||||
@@ -132,6 +162,10 @@ 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);
|
||||||
|
|
||||||
@@ -154,6 +188,11 @@ 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)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -39,6 +40,12 @@ 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
|
||||||
|
|||||||
@@ -242,6 +242,61 @@ 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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -217,4 +217,72 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,19 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
services/domain/Big.Tests/WithdrawRegistrationTests.cs
Normal file
123
services/domain/Big.Tests/WithdrawRegistrationTests.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
|
||||||
|
# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
|
||||||
|
# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
|
||||||
|
# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
|
||||||
|
Feature: Een registratie intrekken
|
||||||
|
Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
|
||||||
|
zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
|
||||||
|
|
||||||
|
Scenario: De zorgprofessional trekt zijn eigen registratie in
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When the zorgprofessional withdraws it
|
||||||
|
Then the withdrawal succeeds
|
||||||
|
And the registration has status "INGETROKKEN"
|
||||||
|
And the running process is cancelled
|
||||||
|
|
||||||
|
Scenario: Een andere zorgprofessional kan de registratie niet intrekken
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When a different zorgprofessional tries to withdraw it
|
||||||
|
Then the withdrawal is reported not found
|
||||||
|
And the registration has status "INGEDIEND"
|
||||||
|
And the running process is not cancelled
|
||||||
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenRegistratieIntrekken.feature</c> (S-11). Submits a registration (which
|
||||||
|
/// records its process) and then applies the WithdrawRegistration use case against in-memory ports;
|
||||||
|
/// one instance per scenario. Scoped to this feature so its "the registration has status" step does
|
||||||
|
/// not clash with the identically phrased steps in the other features.</summary>
|
||||||
|
[Binding]
|
||||||
|
[Scope(Feature = "Een registratie intrekken")]
|
||||||
|
public sealed class EenRegistratieIntrekkenSteps
|
||||||
|
{
|
||||||
|
private const string OwnerBsn = "123456782";
|
||||||
|
|
||||||
|
private readonly InMemoryRegistrationStore _store = new();
|
||||||
|
private readonly InMemoryWorkflowClient _workflow = new();
|
||||||
|
private RegistrationId _id;
|
||||||
|
private WithdrawOutcome _outcome;
|
||||||
|
|
||||||
|
[Given("a submitted registration with a running process")]
|
||||||
|
public async Task GivenASubmittedRegistrationWithARunningProcess()
|
||||||
|
=> _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
|
||||||
|
|
||||||
|
[When("the zorgprofessional withdraws it")]
|
||||||
|
public async Task WhenTheZorgprofessionalWithdrawsIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, OwnerBsn));
|
||||||
|
|
||||||
|
[When("a different zorgprofessional tries to withdraw it")]
|
||||||
|
public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, "999999990"));
|
||||||
|
|
||||||
|
[Then("the withdrawal succeeds")]
|
||||||
|
public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome);
|
||||||
|
|
||||||
|
[Then("the withdrawal is reported not found")]
|
||||||
|
public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome);
|
||||||
|
|
||||||
|
[Then("the registration has status \"(.*)\"")]
|
||||||
|
public async Task ThenTheRegistrationHasStatus(string expected)
|
||||||
|
{
|
||||||
|
var registration = await _store.GetAsync(_id);
|
||||||
|
Assert.NotNull(registration);
|
||||||
|
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Then("the running process is cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsCancelled()
|
||||||
|
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId);
|
||||||
|
|
||||||
|
[Then("the running process is not cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsNotCancelled()
|
||||||
|
=> Assert.Null(_workflow.WithdrawnProcessInstanceId);
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
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>>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ 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,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ 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: '.',
|
||||||
@@ -22,7 +25,9 @@ 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: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
|
launchOptions: {
|
||||||
|
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
|
||||||
// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
|
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
|
||||||
// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
|
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
|
||||||
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
|
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
|
||||||
test('DigiD login → submit → public INGEDIEND → approve → public INGESCHREVEN', async ({ page, request }) => {
|
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
||||||
|
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('/');
|
||||||
|
|
||||||
@@ -43,21 +44,42 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
|||||||
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
|
|
||||||
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
|
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it
|
||||||
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
|
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
|
||||||
// path by the worker, so wait for it before approving.
|
// to the medewerker realm (a different Keycloak realm than the citizen's digid session).
|
||||||
|
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 () => {
|
||||||
const res = await request.get(`http://domain:8080/registrations/${reference}`);
|
await page.reload();
|
||||||
return res.ok() ? (await res.json()).zaakUrl : null;
|
return goedkeuren.count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeTruthy();
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
|
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
||||||
expect(approve.status()).toBe(204);
|
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
||||||
|
// the decision never reaches the domain — so the registration would stay INGEDIEND.
|
||||||
|
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; the openbaar register now shows *our* row (matched by
|
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
|
||||||
// its reference) as INGESCHREVEN.
|
// its reference) now shows INGESCHREVEN.
|
||||||
|
await page.goto('http://openbaar/');
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|||||||
27
tests/e2e/withdrawal.spec.ts
Normal file
27
tests/e2e/withdrawal.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
|
||||||
|
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
|
||||||
|
// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
|
||||||
|
// then confirms the registration is ingetrokken.
|
||||||
|
test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
|
||||||
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.locator('#username').fill('jan-burger');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /indienen/i }).click();
|
||||||
|
|
||||||
|
// The BFF accepted it and the page shows the confirmation with the reference.
|
||||||
|
await expect(page.getByText(/ontvangen/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
|
||||||
|
// so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
|
||||||
|
await page.getByRole('button', { name: /trek aanvraag in/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -11,7 +11,13 @@
|
|||||||
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
|
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"/>
|
||||||
@@ -29,6 +35,16 @@
|
|||||||
<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">
|
||||||
@@ -57,6 +73,16 @@
|
|||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user