Compare commits

..
Author SHA1 Message Date
notandClaude Opus 4.8 b00d8bd60e docs(portal-openbaar): record openbaar decisions + walking-skeleton public-visibility demo (refs #10)
CI / lint (pull_request) Successful in 1m14s
CI / build (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 1m55s
CI / mutation (pull_request) Successful in 4m1s
CI / unit (pull_request) Successful in 1m4s
CI / verify-stack (pull_request) Successful in 6m57s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:16:30 +02:00
notandClaude Opus 4.8 7ec9d514ec test(e2e): assert the submitted registration becomes publicly visible (refs #10)
Extend the walking-skeleton happy path: after the self-service submit + confirmation,
open the anonymous openbaar register portal and poll (reload) until the submitted
INGEDIEND entry appears — completing submit → BFF → domain → projection → public
visibility. Verified the openbaar portal renders the public-safe register (id+status,
no bsn) against a real BFF + stub projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:15:43 +02:00
notandClaude Opus 4.8 b8a88cb531 ci(portal-openbaar): serve the openbaar app in compose (nginx + BFF proxy) (refs #10)
Add the openbaar nginx image (serves the Angular app, reverse-proxies /openbaar to
the BFF, same-origin) and wire it into the compose stack on :8141, anonymous (no
Keycloak dependency). Add it to the health-wait list so the e2e can rely on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:13:03 +02:00
notandClaude Opus 4.8 28fd4ca964 feat(portal-openbaar): render the public register with search + empty state (refs #10)
Implement RegisterPage: load the full public register from the BFF on open, filter
by the search term via /openbaar/register?q=, and show a results table (referentie +
status), a loading indicator, and an empty-state message. Anonymous, same-origin
relative calls. Adds the app-shell spec. All openbaar lint/test/build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:11:19 +02:00
notandClaude Opus 4.8 2c1734c251 test(portal-openbaar): drive the openbaar register page — list, search, empty state (refs #10)
Scaffolds the anonymous openbaar Angular app (mirrors self-service: standalone +
signals, NL DS via the ui lib, no auth) and adds a failing register-page spec:
loads the public register from the BFF on open, searches by term, and shows an
empty-state message. The stub page renders only the heading, so the list/search/
empty-state assertions fail (red).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:09:37 +02:00
notandClaude Opus 4.8 1b89b45d21 docs(backlog): split S-09 into portal (#10) and approval flow (#75) (refs #10)
The walking skeleton has only INGEDIEND and no approval/status-transition path, so
the Openbaar portal (demoable against the existing public-safe BFF endpoint) and the
approve→visible flow are separate slices. See #75 for the approval flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:05:16 +02:00
161 changed files with 211 additions and 9320 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ jobs:
# Log dump must precede teardown (which removes the containers). # Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure - name: Dump container logs on failure
if: failure() if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
- name: Tear down - name: Tear down
if: always() if: always()
run: make down run: make down
+2 -24
View File
@@ -199,25 +199,9 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
### S-10 · Document upload + boundary timer for document timeout (Flow 2) ### S-10 · Document upload + boundary timer for document timeout (Flow 2)
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary: **Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102 **Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
### S-11 · Withdrawal (Flow 3) ### S-11 · Withdrawal (Flow 3)
@@ -239,12 +223,6 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role. **Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
### S-26 · Self-service — resume an existing registration after refresh — #111
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
--- ---
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)* ## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
+1 -112
View File
@@ -2,130 +2,19 @@
All notable changes to this project. Generated from Conventional Commits by git-cliff. All notable changes to this project. Generated from Conventional Commits by git-cliff.
## v2026.07.0 — 2026-07-14 ## Unreleased
### Architecture
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
- ADR-0009 external-task job-worker pattern (refs #6, #60)
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
### Bug Fixes
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
- Bake config into images so compose-smoke passes on CI (refs #30)
- Nrc-init runs migrations only, not setup_configuration (refs #30)
- Smoke waits on durable services, not the whole project (refs #30)
- Portable health poll instead of compose --wait (refs #30)
- Pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
- Keep dotnet format green under the shared .editorconfig (refs #65)
- Re-export the full Utrecht package from libs/ui (refs #67)
- Run checkAuth() at startup to end the login redirect loop (refs #67)
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
- Attach the DigiD token to relative BFF calls (refs #68)
### Build
- Pin Stryker.NET as a local dotnet tool (refs #47)
### CI ### CI
- Gitea Actions pipeline + runner runbook (refs #30) (#37) - Gitea Actions pipeline + runner runbook (refs #30) (#37)
- ACL Dockerfile + full compose stack for smoke test (refs #30)
- Switch runner label to ubuntu-latest (refs #30)
- Run the mutation ratchet as a parallel CI job (refs #47)
- Publish the Stryker HTML report as a CI artifact (refs #47)
- Run the ACL integration test as a Gitea Actions job (refs #46)
- Keep the integration lane local-only; document the runner gap (refs #46)
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
- Containerize, wire into compose, and verify end-to-end (refs #6)
- Make Stryker report upload best-effort (refs #62)
- Retrigger after runner cleanup (refs #6)
- Retrigger CI (refs #6)
- Retrigger CI after gitea restart (refs #6)
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
- Nx frontend lane (lint/test/build) (refs #65)
- Serve the self-service app in compose (refs #68)
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
- Cache the NuGet package store across the .NET jobs (refs #73)
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
### Chores ### Chores
- Add idempotent Gitea backlog seeder - Add idempotent Gitea backlog seeder
- Remove bootstrap scripts from main (#35) - Remove bootstrap scripts from main (#35)
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
### Documentation ### Documentation
- Split S-00 into sub-slices (refs #1) (#33) - Split S-00 into sub-slices (refs #1) (#33)
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
- Tighten gitea-actions-gotchas, add local compose (refs #30)
- ADR-0008 read projection store + demo note for the event path (refs #7)
- Demo note for submitting a registration (S-05) (refs #6)
- Demo note for the BFF front door (S-07) (refs #8)
- Split S-08 into S-08a-d (refs #65)
- Frontend-decisions + demo note for S-08a (refs #65)
- Record the orval generator choice (refs #66)
- Record NL DS + DigiD decisions and demo note (refs #67)
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
### Features ### Features
- Placeholder BFF + /health endpoint (closes #28) (#34) - Placeholder BFF + /health endpoint (closes #28) (#34)
- Containerize BFF + compose-up smoke (closes #29) (#36) - Containerize BFF + compose-up smoke (closes #29) (#36)
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
- Open Notificaties up + shared network (closes #2) (#42)
- Keycloak with four mock realms (closes #3) (#43)
- Flowable + registratie.bpmn external task (closes #4) (#44)
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
- Add bind-mount local compose for no-make/Windows dev (refs #30)
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
- Wire OpenZaak → Open Notificaties notifications (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Persist the read projection and expose webhook + read APIs (refs #7)
- Enforce the callback bearer before reading the body (refs #7)
- Implement the Registration aggregate invariants (refs #6)
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
- Implement the Flowable Workflow Client and ACL client (refs #6)
- Expose POST /registrations and the read endpoint (refs #6)
- Implement self-service submit and openbaar lookup (refs #8)
- Committed OpenAPI contract + drift guard (refs #8)
- Self-service portal placeholder page (refs #65)
- Expose the generated BFF client + repeatable generate target (refs #66)
- Implement the DigiD registration submit page (refs #67)
- Runtime config + nginx serve/proxy image (refs #68)
- Surface submit failures with a retryable alert (refs #68)
- One citizen reference across self-service and the openbaar register (#79)
### Other
- Openbaar Register portal — public lookup (#76)
- Approval flow — temp admin endpoint + status transition to projection (#77)
### Refactor
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
- Use upstream images verbatim, seed config via docker cp (refs #30)
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
### Tests
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
- Integration test opens a real zaak against OpenZaak (refs #46)
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Ratchet projector mutation baseline to 100% (refs #7)
- Registration aggregate invariants (refs #6)
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
- Workflow Client, ACL client, store and job processor (refs #6)
- Acceptance scenario for submitting a registration (refs #6)
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
- Endpoints, JWT auth and public-safe projection (refs #8)
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
- Self-service portal placeholder renders (refs #65)
- Generated BFF client is exposed and calls the endpoints (refs #66)
- DigiD-guarded registration submit page (refs #67)
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
- Submit surfaces BFF failures instead of swallowing them (refs #68)
- Guard that the DigiD token attaches to relative BFF calls (refs #68)
+1 -1
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness # Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of # into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the # bind-mounted, because bind mounts don't reach sibling containers on the
-27
View File
@@ -1,27 +0,0 @@
# Multi-stage build for the behandel portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/behandel apps/behandel
COPY libs libs
RUN pnpm nx build behandel
FROM nginx:1.27-alpine AS runtime
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# 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
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-24
View File
@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /behandel/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
-80
View File
@@ -1,80 +0,0 @@
{
"name": "behandel",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/behandel/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/behandel",
"browser": "apps/behandel/src/main.ts",
"tsConfig": "apps/behandel/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/behandel/public"
}
],
"styles": ["apps/behandel/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "behandel:build:production"
},
"development": {
"buildTarget": "behandel:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "behandel:build",
"staticFilePath": "dist/apps/behandel/browser",
"spa": true
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-73
View File
@@ -1,73 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
// source and token storage are faked, so the assertion turns on the actual route-matching.
describe('behandel medewerker token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'medewerker-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative werkbak call', () => {
bff.getBehandelWerkbak().subscribe();
const req = http.expectOne('/behandel/werkbak');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('attaches the bearer token to the relative decide call', () => {
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
const req = http.expectOne('/behandel/registrations/reg-1/decide');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush(null);
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});
-39
View File
@@ -1,39 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/behandel/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
-1
View File
@@ -1 +0,0 @@
<router-outlet></router-outlet>
-7
View File
@@ -1,7 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { WerkbakPage } from './werkbak/werkbak-page';
export const appRoutes: Route[] = [
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
];
-15
View File
@@ -1,15 +0,0 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'behandel';
}
@@ -1,64 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Werkbak</utrecht-heading-1>
<p utrecht-paragraph>
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Registraties in behandeling
</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">BSN</th>
<th scope="col">Status</th>
<th scope="col">Actie</th>
</tr>
</thead>
<tbody>
@for (item of items(); track item.registrationId) {
<tr>
<td>{{ item.registrationId }}</td>
<td>{{ item.bsn }}</td>
<td>{{ item.status }}</td>
<td>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'goedkeuren')"
>
Goedkeuren
</button>
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[attr.aria-label]="'Afwijzen ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'afwijzen')"
>
Afwijzen
</button>
</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -1,110 +0,0 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['behandelaar']);
login(): void {
/* not exercised here */
}
logout(): void {
/* spied in tests */
}
}
function setup(
overrides: {
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBehandelWerkbak =
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
const postBehandelRegistrationsIdDecide =
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
return {
getBehandelWerkbak,
postBehandelRegistrationsIdDecide,
providers: [
{
provide: BffApiV1Service,
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
},
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('WerkbakPage', () => {
it('lists the registrations awaiting beoordeling on open', async () => {
const { getBehandelWerkbak, providers } = setup();
await render(WerkbakPage, { providers });
expect(getBehandelWerkbak).toHaveBeenCalled();
expect(await screen.findByText('reg-1')).toBeTruthy();
expect(screen.getByText('123456782')).toBeTruthy();
expect(screen.getByText('reg-2')).toBeTruthy();
});
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'goedkeuren',
});
// Reloaded after the decision: once on open, once after deciding.
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
});
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
const { postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'afwijzen',
});
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(WerkbakPage, { providers });
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(WerkbakPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -1,65 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
/**
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
* decision posts to the BFF, which applies the domain transition and completes the workflow task
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
*/
@Component({
selector: 'app-werkbak-page',
imports: [UtrechtComponentsModule],
templateUrl: './werkbak-page.html',
})
export class WerkbakPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<WerkbakItem[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
protected readonly deciding = signal<string | undefined>(undefined);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBehandelWerkbak().subscribe({
next: (rows: WerkbakItem[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
decide(registrationId: string, besluit: Besluit): void {
this.deciding.set(registrationId);
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
// Refresh so the decided registration drops off the werkbak (its task is now completed).
next: () => {
this.deciding.set(undefined);
this.load();
},
error: () => {
this.deciding.set(undefined);
this.failed.set(true);
},
});
}
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportaal BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
-2
View File
@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
-4
View File
@@ -17,9 +17,5 @@ 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
@@ -43,7 +43,7 @@
<tbody> <tbody>
@for (entry of entries(); track entry.id) { @for (entry of entries(); track entry.id) {
<tr> <tr>
<td>{{ entry.reference }}</td> <td>{{ entry.id }}</td>
<td>{{ entry.status }}</td> <td>{{ entry.status }}</td>
</tr> </tr>
} }
@@ -5,8 +5,8 @@ import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page'; import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [ const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' }, { id: 'zaak-abc', status: 'INGEDIEND' },
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' }, { id: 'zaak-def', status: 'INGESCHREVEN' },
]; ];
function providers(get = vi.fn().mockReturnValue(of(sample))) { function providers(get = vi.fn().mockReturnValue(of(sample))) {
@@ -22,11 +22,9 @@ describe('RegisterPage', () => {
await render(RegisterPage, { providers: providers(get).providers }); await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled(); expect(get).toHaveBeenCalled();
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78), expect(await screen.findByText(/zaak-abc/)).toBeTruthy();
// not the internal zaak id.
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy(); expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/REG-def/)).toBeTruthy(); expect(screen.getByText(/zaak-def/)).toBeTruthy();
}); });
it('searches by the entered term', async () => { it('searches by the entered term', async () => {
-17
View File
@@ -1,17 +0,0 @@
#!/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
-4
View File
@@ -19,9 +19,5 @@ 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,56 +3,9 @@
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1> <utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
@if (submitted()) { @if (submitted()) {
@if (withdrawn()) { <p utrecht-paragraph role="status">
<p utrecht-paragraph role="status"> Uw registratie is ontvangen. Referentie: {{ reference() }}.
Uw registratie met referentie {{ reference() }} is ingetrokken. </p>
</p>
} @else {
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
}
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
<label utrecht-form-label for="diploma">Diploma</label>
<input
id="diploma"
type="file"
accept="application/pdf"
[disabled]="providingDocuments()"
(change)="onFileSelected($event)"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="providingDocuments() || !selectedFile()"
(click)="provideDocuments()"
>
Documenten aanleveren
</button>
}
@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,25 +17,12 @@ class FakeAuth extends AuthService {
} }
} }
function providers( function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
) {
return { return {
post, post,
withdraw,
provideDocuments,
providers: [ providers: [
{ provide: AuthService, useClass: FakeAuth }, { provide: AuthService, useClass: FakeAuth },
{ { provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
provide: BffApiV1Service,
useValue: {
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments,
},
},
], ],
}; };
} }
@@ -69,76 +56,6 @@ describe('RegistrationPage', () => {
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
}); });
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
const { withdraw, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
expect(withdraw).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
});
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
const { provideDocuments, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
// Choose the file, then upload it.
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
expect(provideDocuments).toHaveBeenCalledWith(
'reg-9',
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
);
});
it('surfaces a diploma-upload failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(of(undefined)),
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => {
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,8 +6,7 @@ import { UtrechtComponentsModule } from 'ui';
/** /**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG * The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and- * registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
*/ */
@Component({ @Component({
selector: 'app-registration-page', selector: 'app-registration-page',
@@ -23,13 +22,6 @@ export class RegistrationPage {
protected readonly reference = signal<string | undefined>(undefined); protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false); protected readonly submitted = signal(false);
protected readonly failed = signal(false); protected readonly failed = signal(false);
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined);
submit(): void { submit(): void {
this.submitting.set(true); this.submitting.set(true);
@@ -47,74 +39,4 @@ export class RegistrationPage {
}, },
}); });
} }
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.selectedFile.set(input.files?.[0] ?? undefined);
}
async provideDocuments(): Promise<void> {
const reference = this.reference();
const file = this.selectedFile();
if (!reference || !file) {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false);
let contentBase64: string;
try {
contentBase64 = await readAsBase64(file);
} catch {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
return;
}
this.bff
.postSelfServiceRegistrationsIdDocuments(reference, {
contentBase64,
fileName: file.name,
contentType: file.type || 'application/pdf',
})
.subscribe({
next: () => {
this.documentsProvided.set(true);
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
},
});
}
withdraw(): void {
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);
},
});
}
}
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
function readAsBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
reader.readAsDataURL(file);
});
} }
@@ -1,64 +0,0 @@
# ADR-0011: Approval sets the zaak eindstatus via the ACL and projects INGESCHREVEN from the notification alone
- **Status:** Accepted
- **Date:** 2026-07-13
- **Deciders:** Respellion engineering
- **Relates to:** S-09b (#75); split from S-09 (#10); builds on ADR-0001 (§8 loose coupling), ADR-0003 (ACL default-fill), ADR-0007 (OZ→NRC wiring), ADR-0008 (read projection), ADR-0009 (external-task worker)
## Context
The walking skeleton could submit a registration (INGEDIEND) and show it in the openbaar register,
but nothing could **approve** it. S-09b adds a behandelaar approval that must make the entry publicly
visible as a terminal status. There is no behandel-portal yet (S-12), so approval is triggered by a
**temporary admin endpoint** on the Domain Service.
Two decisions are non-obvious (§14) and cross service boundaries:
1. **Who resolves the ZGW statustype?** Approval means "set the zaak to its final status", but the
domain must stay ZGW-ignorant (§8.1 — only the ACL talks to ZGW) and does not know statustype URLs.
2. **How does the projection learn the new status?** The status is set in OpenZaak, which notifies over
NRC; the Event Subscriber projects it. But the subscriber **may not read OpenZaak** (§8.1), and an
NRC `status`/`create` notification's `resourceUrl` is the *status* resource, not the zaak, and does
not carry the statustype.
## Decision
**Approval flows Domain → ACL → OpenZaak → NRC → Event Subscriber → projection, using only the
notification's own fields on the read side.**
- **Domain.** `Registration.Approve()` advances INGEDIEND → INGESCHREVEN (requires an opened zaak; a
repeat is a no-op). The `ApproveRegistration` use case calls the ACL to set the zaak status, then
advances the aggregate. A temporary `POST /registrations/{id}/approve` endpoint drives it.
- **ACL.** A new `POST /statussen` operation takes only the zaak URL. The ACL resolves the zaaktype's
**eindstatus** from the catalogus (`isEindstatus`, falling back to the highest `volgnummer`) and
POSTs a ZGW status against the zaak. The domain never names statustypen — the ACL owns the ZGW
translation (§8.1, ADR-0003).
- **Event Subscriber.** It binds the NRC `hoofdObject` (always the zaak URL) and keys the projection on
it, so a `zaken`/`status`/`create` notification updates the **same** row the zaak-create created,
flipping it to INGESCHREVEN. It takes **any** status-create as the approval — in the walking skeleton
the only status ever set after creation is the approval — so it never has to read OpenZaak to learn
the statustype. The ZGW `resource` is retained in the notification log (new column) so a rebuild
reproduces the right status.
## Consequences
- The domain↔ACL boundary stays clean: the domain hands over a zaak URL and says "approve"; ZGW
statustype knowledge lives only in the ACL.
- The projection remains rebuildable without OpenZaak (§8.1, ADR-0008): the log now records the ZGW
resource, which is all a rebuild needs to reproject the status.
- The openbaar register shows real lifecycle: INGEDIEND on submit, INGESCHREVEN on approval.
- **Walking-skeleton assumption:** "any status-create ⇒ INGESCHREVEN" holds only while approval is the
sole post-creation status transition. When more transitions arrive (beoordeling, afwijzing — S-12+),
the subscriber must distinguish statustypen. The honest options then are to carry the statustype
omschrijving in the notification `kenmerken`, or to have the ACL resolve it and re-notify — recorded
here so future-me revisits this rather than assuming it generalises.
## Alternatives considered
- **Inject the approved statustype URL into the ACL as config** (like the zaaktype URL). Rejected:
couples ACL config to seed output and adds compose/run-domain-check plumbing; runtime eindstatus
discovery keeps the ACL self-contained for one extra ZGW GET per approval.
- **Have the Event Subscriber GET the status/statustype from OpenZaak** to map precisely. Rejected:
violates §8.1 (only the ACL talks to ZGW) and makes the projection depend on OpenZaak being up.
- **Record the derived status in the notification log** instead of the ZGW resource. Rejected: the log
should retain notification *facts*, not projection semantics; the mapping stays in the projector.
@@ -1,104 +0,0 @@
# ADR-0012: One citizen-facing reference across self-service and the openbaar register
- **Status:** Accepted
- **Date:** 2026-07-14
- **Deciders:** Respellion engineering
- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation)
## Context
A citizen submits through the self-service portal and is shown a confirmation with a
**reference** so they can find their registration back in the public register. But the two
sides showed **different identifiers**:
- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the
domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak
exists.
- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL,
assigned by OpenZaak when the ACL opens the zaak.
These never match, so the reference on the confirmation was useless for looking the entry up.
The two identifiers live on opposite sides of the ACL boundary and are generated by different
systems at different times, so there is no way to reconcile them after the fact without a
correlating value carried across the boundary.
The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed
`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the
`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any
such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up.
This is that follow-up, scoped to the one field the citizen actually needs.
## Decision
**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value
as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection
with the reference by reading the zaak through the ACL, and stores it in the replay log so
rebuild stays log-only.**
Concretely, following the request path:
1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes
`registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets
it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per
`bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that
constructs ZGW payloads (§8.1); the domain never sees a ZGW URL.
2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the
zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`),
which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber
still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP.
3. **Projection + replay log.** The reference is written both to the `register_projection` row
**and** to the `processed_notifications` replay log (a new nullable `reference` column on
each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not
OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log
without re-reading the ACL.
4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes
`id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either
`id` or `reference`. The openbaar register's "Referentie" column now renders `reference`.
The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured
from the submit confirmation must appear as a cell in the public register.
### Why HTTP to the ACL, not the ACL as a library
ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead
call the ACL **over HTTP**, consistent with every other cross-service hop in this system
(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the
ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption
boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config.
## Consequences
**Positive**
- One reference, end to end: the citizen's confirmation value is exactly what the public
register shows and searches by.
- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not
OpenZaak.
- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`,
so `/admin/rebuild` needs no ACL/ZGW access.
- The column additions are nullable and additive; older rows without a reference are tolerated.
**Negative / costs**
- A new coupling: the Event Subscriber now depends on the ACL being reachable
(`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will
need the notification redelivered (NRC already redelivers; the projection upsert is
idempotent).
- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded:
one small GET per zaak, off the citizen's request path.
- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak
were ever configured to auto-generate `identificatie`, the correlation would break; the ACL
setting it explicitly is now load-bearing.
## Alternatives considered
- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and
the notification content is not ours to extend; it would also couple the projection to a
bespoke notification shape.
- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when
the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has
no zaak id to show at submit time.
- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make
rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant.
- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second
source of truth for a value that can simply be the same on both sides.
@@ -1,76 +0,0 @@
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
- **Status:** Accepted
- **Date:** 2026-07-15
- **Deciders:** Respellion engineering
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
## Context
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
the whole slice.
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
`digid`-realm JWT validation rejects a medewerker token outright.
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
task; the decision must advance it, and also apply the domain transition (ADR-0011).
## Decision
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
completes the Flowable task.**
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
decision acts on a real task with no separate correlation store. The read projection stays the
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
data (both deferred in ADR-0008) just to render a staff view.
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
here so the boundary is decided up front.
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
endpoint + task completion.
## Consequences
**Positive**
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
(§8.3).
**Negative / costs**
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
## Alternatives considered
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
enough at this size and keeps §8.3 simple.
@@ -1,72 +0,0 @@
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
- **Status:** Accepted
- **Date:** 2026-07-16
- **Deciders:** Respellion engineering
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
(behandel-portal wiring, the Beoordelen user task)
## Context
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
Two questions shape this sub-slice.
1. **How does the case get cancelled — in code, or in the BPMN model?**
2. **How does a withdrawal correlate to the right running process instance?**
## Decision
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete).
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
Targeting the subscribed execution (not the user task's execution — a message boundary event's
subscription lives on its own execution) is what makes the correlation land.
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
completes its task best-effort.
## Consequences
**Positive**
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
that talks to Flowable and does not delete instances behind the model's back.
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
**Negative / costs**
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
reference at these volumes; a process-level interrupting event subprocess would close the gap and
is an additive follow-up if it matters.
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
(consistent with ADR-0009).
## Alternatives considered
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
not the process. The message event keeps the cancellation a first-class part of the workflow.
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
change if the race proves to matter.
@@ -1,77 +0,0 @@
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
pattern on `Beoordelen`).
## Context
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
Two forces shape this.
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
escalation happens alongside it.
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
(§8.2)" posture since ADR-0009.
## Decision
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
behandelaar group to teamlead.**
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
belongs to the teamlead; its history and variables are untouched.
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
`OpenZaakAanmaken` worker.
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
## Consequences
**Positive**
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
timer.
**Negative / costs**
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
on redelivery.
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
## Alternatives considered
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
surface to the engine for no capability the external-worker route lacks.
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
@@ -1,77 +0,0 @@
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
## Context
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
governing the registration workflow" (Flowable as a peer orchestration module).
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
back. That is one option; it is not the only one, and it is not the cleanest.
## Decision
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
as a DMN service task (`flowable:type="dmn"`); an exclusive gateway routes on its output. The domain's
only new job is to carry the diploma origin and pass it into the process as a start variable.**
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
→ `route` (`Buitenlands` ⇒ `CBGV_ADVIES`, otherwise `DIRECT`). A DMN service task
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. (A `businessRuleTask` would
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
never evaluates the DMN and never learns the route — that is the process's concern.
- **Deployed as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
— the resource is stored but no decision is created, so the service task fails at runtime with
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
With the decision present in the DMN repository, the process's DMN service task resolves it across
deployments by key (verified live), so no shared parent deployment id is needed.
## Consequences
**Positive**
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
framing); business users can read/adjust the decision table without touching domain code.
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
- The domain change is minimal and additive: one value on the aggregate, one start variable.
**Negative / costs**
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
- The DMN and its service-task wiring are validated live (verify-domain drives a foreign
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
cover only that the origin is carried into the process.
## Alternatives considered
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
the domain to Flowable for a decision and splits the routing across two places (domain computes,
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.
@@ -1,90 +0,0 @@
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
- **Status:** Accepted
- **Date:** 2026-07-20
- **Deciders:** Respellion engineering
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
## Context
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
aggregate stays in sync.
## Decision
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
between the gateway and Beoordelen.
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
without persisting again; an unknown registration throws so the job is redelivered.
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
behind the same action.
## Consequences
**Positive**
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
worker trio (worker + processor + pump), one Workflow Client method.
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
introduced in S-10a.
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
**Negative / costs**
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
through the process.
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
## Alternatives considered
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
status — the case would still look open.
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
@@ -1,74 +0,0 @@
# ADR-0018: Diploma upload is stored in the ZGW Documenten API, fronted by the ACL
- **Status:** Accepted
- **Date:** 2026-07-20
- **Deciders:** Respellion engineering
- **Relates to:** S-10b (#103); proposal #107. Builds on ADR-0001 (ACL is the only ZGW caller),
ADR-0003 (ACL default-fill), ADR-0017 (document-wait + provision trigger). Carves the zaak-close on
expiry to #106 (S-10c).
## Context
S-10a wired the "documenten aanleveren" trigger (portal → BFF → domain → complete the WachtOpDocumenten
wait) with the file itself stubbed. S-10b makes the upload real: the diploma must be **stored in the
ZGW Documenten (DRC) API** and related to the zaak. §8.1 makes the ACL the only code that talks to ZGW.
The DRC API is served by the same OpenZaak container as the Zaken/Catalogi APIs.
## Decision
**The ACL fronts the Documenten API: it creates an `enkelvoudiginformatieobject` and relates it to the
zaak. The file travels base64-encoded in JSON across every hop (the portal encodes it client-side); a
"Diploma" `informatieobjecttype` is seeded in the catalogus and injected into the ACL like the
zaaktype.**
- **ACL gateway.** `OpenZaakGateway.StoreDocumentAsync` POSTs the `enkelvoudiginformatieobject`
(`/documenten/api/v1/enkelvoudiginformatieobjecten`, base64 `inhoud`, `bestandsomvang`,
`status=definitief`) then relates it to the zaak (`/zaken/api/v1/zaakinformatieobjecten`), reusing the
established gateway patterns (ZGW Bearer JWT, buffered non-chunked body for uwsgi, **no CRS headers** —
the Documenten API is not geo, unlike zaak-create). `AclService.StoreDiplomaAsync` default-fills the
ZGW-mandatory fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, `taal=nld`,
creatiedatum); the domain hands over only the zaak, the bytes, and the file's name/type. No new ZGW
scopes were needed — the seed applicatie holds `heeft_alle_autorisaties`.
- **The file travels as base64 JSON end-to-end.** The portal reads the chosen file client-side
(`FileReader`) and posts `{ contentBase64, fileName, contentType }` as JSON to the BFF; the BFF
forwards it to the domain, and the domain to the ACL, all as JSON. This deviates from proposal #107's
"multipart on the portal→BFF hop": base64 JSON keeps **one** contract shape across all four services
(no `IFormFile`/antiforgery plumbing, no multipart in the generated client), and a diploma is a small
placeholder PDF, so the ~33% base64 overhead is immaterial. The ACL turns the base64 back into the
ZGW `inhoud`.
- **Storing precedes completing the wait.** `ProvideDocuments` (from S-10a) now stores the diploma via
the ACL — once the zaak is opened — and then completes the `WachtOpDocumenten` task, so a registration
reaches beoordeling only after its diploma is stored. Both steps stay best-effort about missing
preconditions (no zaak yet → skip storage; no process yet → skip completion), mirroring withdrawal.
- **Catalogus.** `seed_catalogus.py` (OZ_PUBLISH) creates a "Diploma" `informatieobjecttype`, relates it
to the zaaktype (`zaaktype-informatieobjecttypen`, while both concept), publishes both, and prints
`INFORMATIEOBJECTTYPE_URL`; verify-domain injects it as `Acl__Defaults__InformatieobjecttypeUrl`
(a zeros-uuid placeholder otherwise, so the ACL still boots).
## Consequences
**Positive**
- §8.1 stays intact: the ACL is still the only ZGW caller; the portal only talks to the BFF; the domain
only crosses the ACL boundary. Adding a document was almost entirely additive (one gateway method, one
default, one seed block).
- One JSON contract shape across portal/BFF/domain/ACL keeps the generated client and the service
contracts uniform; the upload is exercised live (ACL integration test against real OpenZaak; the
Playwright journey uploads a real PDF).
**Negative / costs**
- Base64 inflates the payload ~33% and holds the whole file in memory at each hop — fine for a small
diploma, but not a pattern to reuse for large documents without streaming/multipart.
- The zaak is **not** set to a cancellation status when the 30-day term lapses — carved to #106 (S-10c),
which adds the cancellation statustype/resultaattype + ACL method + expiry-worker wiring.
- Providing documents before the zaak is opened silently skips storage (best-effort); the e2e/live flow
avoids this by uploading only after the openbaar register shows the zaak (INGEDIEND).
## Alternatives considered
- **Multipart on the portal→BFF hop** (proposal #107). Rejected: it splits the transport into two shapes
(multipart then JSON), needs `IFormFile` + antiforgery handling and a multipart method in the generated
client, for no benefit at diploma size.
- **The domain talks to the Documenten API directly.** Rejected outright: violates §8.1 (only the ACL
talks to ZGW).
@@ -1,81 +0,0 @@
# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
- **Status:** Accepted
- **Date:** 2026-07-21
- **Deciders:** Respellion engineering
- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
## Context
ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
(§8.1, the only code that talks to ZGW).
The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
## Decision
**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
(name) rather than by position or the eindstatus flag alone.**
- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
zaaktype's single `selectielijstProcestype` so they validate on publish.
- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
+ `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
`AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
registration that lapsed before its zaak was opened has nothing to cancel.
## Consequences
**Positive**
- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
the change is additive and §8 stays clean (only the ACL talks to ZGW).
- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
cancelled end-to-end.
**Negative / costs**
- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
`Vervallen` resultaat) is a possible follow-up.
- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
is documented in the gateway.
- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
## Alternatives considered
- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
the approval eindstatus resolution.
- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
+2 -287
View File
@@ -195,290 +195,5 @@ curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ] # → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
``` ```
> The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below. > The register shows `INGEDIEND` entries today; the approval transition to a terminal status
> (e.g. `INGESCHREVEN`) lands with the approval flow (S-09b, #75).
---
## S-09b — Approval flow (public visibility flips to INGESCHREVEN)
**Outcome:** a behandelaar approves a submitted registration via a temporary admin endpoint (no
behandel-portal yet — S-12). The approval sets the zaak's final status through the ACL, which flows
back to the projection over NRC, and the openbaar register then shows the entry as `INGESCHREVEN`.
**The path:** `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW
`/statussen`) → OpenZaak → NRC → Event Subscriber projects `INGESCHREVEN` → openbaar register.
```bash
# 1. Full stack up, then drive submit → public INGEDIEND → approve → public INGESCHREVEN:
make up
make verify-e2e
# 2. Or by hand: submit (as in S-09), note the reference, then approve it.
# The zaak is opened off the request path, so approve once GET shows a zaakUrl.
ref="<registration-reference-from-the-confirmation>"
curl -fsS http://localhost:8130/registrations/$ref | jq # domain (host port 8130): wait for .zaakUrl
curl -fsS -X POST http://localhost:8130/registrations/$ref/approve -i # → 204 No Content
# 3. The public register now shows the entry as approved.
curl -fsS http://localhost:8140/openbaar/register | jq
# → [ { "id": "<zaak-uuid>", "status": "INGESCHREVEN" } ]
```
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).
## #78 — One reference across both portals (ADR-0012)
Before this change the self-service confirmation and the openbaar register showed **different**
identifiers, so a citizen could not look their registration back up. Now both show the same
**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the
Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing
it in the replay log so rebuild stays log-only (ADR-0008).
**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC →
Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register.
```bash
# Submit as in S-09 and note the reference on the confirmation, then find it in the public register:
ref="<registration-reference-from-the-confirmation>"
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
```
> 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.
## 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.
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
its identity throughout — only who may claim it changes.
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
verify-domain check automates):
```bash
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
# but NOT claimed.
#
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
FL=http://localhost:8090/flowable-rest/service
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
-H 'Content-Type: application/json' -d '{"action":"move"}'
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
-H 'Content-Type: application/json' -d '{"action":"execute"}'
#
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
```
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren`
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
candidate group behandelaar → teamlead (§8.2).
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the
> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
> and reassignment are asserted live by the verify-domain check.
## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016)
A registration's diploma origin decides its route. A DMN service task in the registratie
process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a
**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before
beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the
DMN, not in code — a beheerder can read and adjust the decision table directly.
The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to
the domain directly, so the demo drives it through the domain endpoint:
```bash
# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference):
DOM=http://localhost:8080 # domain service
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
#
# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task
# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
FL=http://localhost:8090/flowable-rest/service
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
#
# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task:
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
-H 'Content-Type: application/json' -d '{"action":"complete"}'
# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen.
```
**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN
DMN service task sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`;
domestic: `Beoordelen` directly (§8.2, ADR-0016).
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
> scenarios and unit tests (the origin is carried into the process); the DMN decision and the
> foreign→CBGV routing are asserted live by the verify-domain check.
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
for the citizen's documents (their diploma). Two things can happen:
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
routing (S-13) → beoordeling.
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
**VERLOPEN** (the case is cancelled).
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
demonstrated by firing the 30-day timer early via the management API.
```bash
DOM=http://localhost:8080 # domain service
FL=http://localhost:8090/flowable-rest/service # flowable-rest
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
-H 'Content-Type: application/json' -d '{"action":"complete"}'
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \
-H 'Content-Type: application/json' -d '{"action":"move"}'
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
```
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017).
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
> verify-domain check.
## S-10b — Diploma upload stored in the ZGW Documenten API (#103, ADR-0018)
The self-service "Documenten aanleveren" action (S-10a) is now a **real file upload**: after submitting,
the citizen picks a PDF and uploads it. The portal base64-encodes the file client-side and posts it to
the BFF; the BFF forwards it to the domain, which stores it via the **ACL** as a ZGW
`enkelvoudiginformatieobject` in the **Documenten (DRC) API** and relates it to the zaak — then completes
the `WachtOpDocumenten` wait so beoordeling can proceed. Per §8.1 only the ACL talks to ZGW.
```bash
make up
# 1. Log in as jan-burger / test123, submit, then — once the openbaar register shows the row —
# choose a PDF under "Documenten aanleveren" and upload it. The page confirms "aangeleverd".
open http://localhost:8140
#
# 2. Automated: the walking-skeleton e2e now uploads a real PDF before the behandelaar approves.
make verify-e2e
#
# 3. The ACL integration test proves the document is really created in the Documenten API and
# related to the zaak (against a live OpenZaak):
make verify-acl # → "Storing a diploma creates a real informatieobject related to the zaak"
```
**The path:** portal (base64) → BFF `POST /self-service/registrations/{id}/documents` → domain
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
## S-10c — the ZGW zaak is cancelled when the document term lapses (#106)
When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen` —
it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set
to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the
approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019).
```bash
# 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat
# against a live OpenZaak:
make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat"
#
# 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts
# the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd):
make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
```
**The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
job for redelivery rather than diverging the two (ADR-0019).
-50
View File
@@ -117,53 +117,3 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders. (id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
- **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and - **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query. re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.
## Behandel portal (S-12, #13)
The staff portal where a behandelaar works the **werkbak** (registrations awaiting beoordeling) and
decides each — goedkeuren or afwijzen. `apps/behandel` mirrors `apps/self-service`; the net-new
frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in
**ADR-0013**; this entry records the frontend-specific choices.
- **Medewerker realm auth, reusing `libs/auth`.** Staff authenticate against the Keycloak
`medewerker` realm (public client `big-portal`), not `digid`. Rather than fork the auth lib, the
abstract `AuthService` grew a **`roles`/`hasRole` surface** (empty for realms without roles, e.g.
`digid`), and a parallel **`MedewerkerAuthService` + `provideMedewerkerAuth`** were added — same
auth-code + PKCE config, bound to the medewerker realm, reading the nested `realm_access.roles`
claim. The library's own `authInterceptor` attaches the token to the relative `/behandel/` calls
(secure route), exactly as self-service does for `/self-service/`.
- **Roles reach the frontend via a realm mapper.** Keycloak emits realm roles in the access token by
default but not the ID token/userinfo the SPA reads, so the medewerker `big-portal` client gets a
**realm-roles protocol mapper** (`realm_access.roles`, added to id + userinfo tokens). The
**BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013);
the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a
403 for a non-behandelaar) rather than swallowing it.
- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built
app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`,
health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm.
- **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a
row per registration (referentie/bsn/status). Goedkeuren/afwijzen `POST /behandel/registrations/
{id}/decide` and then reload the werkbak, so the handled item drops off (its Flowable `Beoordelen`
task is completed). Per-row decide buttons carry an `aria-label` including the reference, so the
e2e (and screen readers) can target a specific registration in a shared werkbak.
- **Testing.** Component tests use `@testing-library/angular` with `BffApiV1Service`/`AuthService`
mocked and the axe WCAG 2.1 AA check; an `app.config.spec` drives the real interceptor + api-client
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
path.
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
+1 -126
View File
@@ -17,12 +17,7 @@
# #
# Port map (host): # Port map (host):
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST # 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only) # 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
#
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
services: services:
@@ -210,12 +205,6 @@ services:
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true" KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true" KC_HTTP_ENABLED: "true"
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
KC_HOSTNAME: http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports: ports:
- "8180:8080" - "8180:8080"
volumes: volumes:
@@ -306,14 +295,6 @@ services:
context: ../services/bff context: ../services/bff
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/bff:dev image: register-referentie/bff:dev
environment:
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
Keycloak__Authority: http://keycloak:8080/realms/digid
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports: ports:
- "8080:8080" - "8080:8080"
healthcheck: healthcheck:
@@ -322,39 +303,6 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
start_period: 10s start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports:
- "8130:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
acl:
condition: service_healthy
flowable-init:
condition: service_completed_successfully
networks: [cg] networks: [cg]
# ── Read projection (S-06) ──────────────────────────────────────────────── # ── Read projection (S-06) ────────────────────────────────────────────────
@@ -380,10 +328,6 @@ 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"
@@ -396,8 +340,6 @@ 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:
@@ -420,73 +362,6 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:
+6 -50
View File
@@ -259,30 +259,19 @@ services:
flowable-init: flowable-init:
image: docker.io/curlimages/curl:latest image: docker.io/curlimages/curl:latest
restart: "no" restart: "no"
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by # registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
# infra/seed-config.sh.
volumes: volumes:
- fl-bpmn:/work:ro - fl-bpmn:/work:ro
command: command:
- sh - sh
- -c - -c
- | - |
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments: echo "registratie already deployed; skip"
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
else else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN" curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
fi fi
depends_on: depends_on:
flowable-rest: flowable-rest:
@@ -306,9 +295,6 @@ services:
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# Override with the real zaaktype URL after running seed_catalogus.py. # Override with the real zaaktype URL after running seed_catalogus.py.
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000} Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
ports: ports:
- "8100:8080" - "8100:8080"
healthcheck: healthcheck:
@@ -363,8 +349,6 @@ services:
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the # Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent. # verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid Keycloak__Authority: http://keycloak:8080/realms/digid
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/ Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/ Downstream__Projection__BaseUrl: http://projection-api:8080/
ports: ports:
@@ -412,9 +396,6 @@ 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 (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
Acl__BaseUrl: http://acl:8080/
# The bearer Open Notificaties must present on the abonnement callback. NRC's # The bearer Open Notificaties must present on the abonnement callback. NRC's
# registration probe expects a 401 without it (ADR-0007). Dev-only token. # registration probe expects a 401 without it (ADR-0007). Dev-only token.
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
@@ -429,8 +410,6 @@ services:
depends_on: depends_on:
projection-db: projection-db:
condition: service_healthy condition: service_healthy
acl:
condition: service_healthy
networks: [cg] networks: [cg]
# The read side of the projection. Shares Projection.ReadModel, so build context is root. # The read side of the projection. Shares Projection.ReadModel, so build context is root.
@@ -500,29 +479,6 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:
+8 -19
View File
@@ -35,35 +35,24 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# Deploys registratie.bpmn (process engine) and diploma-eligibility.dmn (DMN engine) via the REST # Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
# API once flowable-rest is up. Idempotent: skips each if already deployed. # Idempotent: skips if a deployment named "registratie" already exists.
flowable-init: flowable-init:
image: docker.io/curlimages/curl:latest image: docker.io/curlimages/curl:latest
restart: "no" restart: "no"
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by # registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
# infra/seed-config.sh.
volumes: volumes:
- fl-bpmn:/work:ro - fl-bpmn:/work:ro
command: command:
- sh - sh
- -c - -c
- | - |
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments: echo "registratie already deployed; skip"
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
else else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN" curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
fi fi
depends_on: depends_on:
flowable-rest: flowable-rest:
+1 -16
View File
@@ -16,22 +16,7 @@
"standardFlowEnabled": true, "standardFlowEnabled": true,
"directAccessGrantsEnabled": true, "directAccessGrantsEnabled": true,
"redirectUris": ["*"], "redirectUris": ["*"],
"webOrigins": ["*"], "webOrigins": ["*"]
"protocolMappers": [
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"multivalued": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
} }
], ],
"users": [ "users": [
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/digid"
}
+17 -100
View File
@@ -10,7 +10,7 @@ Creates (if absent):
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002). Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier). Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
""" """
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.parse, urllib.request import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request
BASE = os.environ.get("OZ_BASE", "http://localhost:8000") BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed") CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
@@ -77,12 +77,8 @@ def publish_zaaktype(zt):
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
`selectielijstProcestype`, plus a `resultaattypeomschrijving`. `selectielijstProcestype`, plus a `resultaattypeomschrijving`.
""" """
# Ontvangen (begin) → Afgehandeld (eind, highest volgnummer). "Geannuleerd" (S-10c) sits between
# them: a non-terminal status the document-timeout branch sets, so it never displaces the Afgehandeld
# eindstatus the approval path resolves. Keyed by volgnummer on a fresh catalogus (CI reseeds); a
# stale local stack must reset its OpenZaak volumes for the renumbering to take effect.
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")} have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Geannuleerd"), (3, "Afgehandeld")]: for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
if volgnummer not in have_st: if volgnummer not in have_st:
st, body = api("POST", "/statustypen", { st, body = api("POST", "/statustypen", {
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer}) "omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
@@ -99,42 +95,25 @@ def publish_zaaktype(zt):
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}") sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
print("create roltype Aanvrager") print("create roltype Aanvrager")
# Two resultaattypen, keyed by omschrijving so each is created independently (idempotent): if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
# "Geregistreerd" — the approval outcome (S-09b) print("skip resultaattype Geregistreerd")
# "Vervallen" — the document-timeout cancellation outcome (S-10c)
# Both selectielijstklassen must share the zaaktype's selectielijstProcestype, so pick two
# Selectielijst resultaten from a single procestype and set that procestype on the zaaktype.
have_rt = {r.get("omschrijving") for r in find(f"/resultaattypen?zaaktype={zt['url']}&status=alles")}
wanted = [("Geregistreerd", "blijvend_bewaren"), ("Vervallen", "vernietigen")]
if all(naam in have_rt for naam, _ in wanted):
print("skip resultaattypen Geregistreerd + Vervallen")
else: else:
# Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
# both klassen validate against the zaaktype's selectielijstProcestype.
procestype = selectielijst("/resultaten?pageSize=1")["results"][0]["procesType"]
resultaten = selectielijst(f"/resultaten?procesType={urllib.parse.quote(procestype, safe='')}")["results"]
if len(resultaten) < len(wanted):
sys.exit(f"selectielijst procestype has too few resultaten ({len(resultaten)}) for {len(wanted)} resultaattypen")
omschrijvingen = selectielijst("/resultaattypeomschrijvingen") omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
oms_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"] oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
# The selectielijstklasse and the zaaktype must share a procestype.
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype}) st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
if st != 200: if st != 200:
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}") sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
st, body = api("POST", "/resultaattypen", {
for i, (naam, archiefnominatie) in enumerate(wanted): "zaaktype": zt["url"], "omschrijving": "Geregistreerd",
if naam in have_rt: "resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
print(f"skip resultaattype {naam}") "archiefnominatie": "blijvend_bewaren",
continue "brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
st, body = api("POST", "/resultaattypen", { })
"zaaktype": zt["url"], "omschrijving": naam, if st != 201:
"resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"], sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
"archiefnominatie": archiefnominatie, print("create resultaattype Geregistreerd")
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
})
if st != 201:
sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}")
print(f"create resultaattype {naam}")
if zt.get("concept", True): if zt.get("concept", True):
st, body = api("POST", f"{zt['url']}/publish") st, body = api("POST", f"{zt['url']}/publish")
@@ -145,58 +124,6 @@ def publish_zaaktype(zt):
print("skip publish (already published)") print("skip publish (already published)")
def seed_informatieobjecttype(cat, zt):
"""Create the "Diploma" informatieobjecttype and relate it to the zaaktype (both idempotent).
A diploma uploaded in S-10b is filed under this informatieobjecttype; OpenZaak only accepts a
document (and its zaak relation) once the informatieobjecttype is published AND allowed for the
zaak's zaaktype (a zaaktype-informatieobjecttype relation). Both the relation and this call must run
while the zaaktype is still a concept, so seed this *before* publishing the zaaktype. Returns the
informatieobjecttype dict.
"""
iots = [i for i in find(f"/informatieobjecttypen?catalogus={cat['url']}&status=alles")
if i.get("omschrijving") == "Diploma"]
if iots:
iot = iots[0]
print(f"skip informatieobjecttype Diploma ({iot['url']}) concept={iot.get('concept')}")
else:
st, iot = api("POST", "/informatieobjecttypen", {
"catalogus": cat["url"],
"omschrijving": "Diploma",
"vertrouwelijkheidaanduiding": "openbaar",
"informatieobjectcategorie": "diploma",
"beginGeldigheid": "2026-01-01",
})
if st != 201:
sys.exit(f"create informatieobjecttype -> {st}: {json.dumps(iot, indent=2)}")
print(f"create informatieobjecttype Diploma ({iot['url']})")
# Relate it to the zaaktype (must be done while both are concept).
relations = find(f"/zaaktype-informatieobjecttypen?zaaktype={zt['url']}&status=alles")
if any(r.get("informatieobjecttype") == iot["url"] for r in relations):
print("skip zaaktype-informatieobjecttype Diploma")
else:
st, body = api("POST", "/zaaktype-informatieobjecttypen", {
"zaaktype": zt["url"], "informatieobjecttype": iot["url"],
"volgnummer": 1, "richting": "inkomend"})
if st != 201:
sys.exit(f"relate zaaktype-informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
print("create zaaktype-informatieobjecttype Diploma")
return iot
def publish_informatieobjecttype(iot):
"""Publish the informatieobjecttype (idempotent) so documents may reference it."""
if iot.get("concept", True):
st, body = api("POST", f"{iot['url']}/publish")
if st != 200:
sys.exit(f"publish informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
print(f"publish informatieobjecttype Diploma ({iot['url']})")
else:
print("skip publish informatieobjecttype (already published)")
def main(): def main():
# 1. Catalogus # 1. Catalogus
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"] existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
@@ -271,16 +198,10 @@ def main():
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add # schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
# those relations and publish — needed so a real zaak POST is accepted, which # those relations and publish — needed so a real zaak POST is accepted, which
# the ACL integration test (S-04a, #46) exercises. See ADR-0006. # the ACL integration test (S-04a, #46) exercises. See ADR-0006.
iot = None
if PUBLISH: if PUBLISH:
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag. # Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles") zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
if z.get("identificatie") == "BIG-REGISTRATIE") if z.get("identificatie") == "BIG-REGISTRATIE")
# Seed + relate the Diploma informatieobjecttype (S-10b) while the zaaktype is still concept,
# then publish both. Publish the informatieobjecttype before the zaaktype so the zaaktype's
# relations reference a published type.
iot = seed_informatieobjecttype(cat, zt)
publish_informatieobjecttype(iot)
publish_zaaktype(zt) publish_zaaktype(zt)
# 5. Verify the JWT client can list the zaaktype (concepts included). # 5. Verify the JWT client can list the zaaktype (concepts included).
@@ -293,10 +214,6 @@ def main():
# zaaktype URL to configure the ACL's default-fill (ADR-0003/0009). # zaaktype URL to configure the ACL's default-fill (ADR-0003/0009).
zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE") zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE")
print(f"ZAAKTYPE_URL {zt_url}") print(f"ZAAKTYPE_URL {zt_url}")
# Machine-readable informatieobjecttype URL (S-10b) so callers can configure the ACL's document
# default-fill. Only emitted when publishing — a concept informatieobjecttype can't back a document.
if iot is not None:
print(f"INFORMATIEOBJECTTYPE_URL {iot['url']}")
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)") print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
+9 -342
View File
@@ -33,18 +33,13 @@ echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL" echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)" sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
seed_out="$(docker start -a "$sid")" zt_url="$(docker start -a "$sid" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
zt_url="$(printf '%s\n' "$seed_out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
iot_url="$(printf '%s\n' "$seed_out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
docker rm -f "$sid" >/dev/null docker rm -f "$sid" >/dev/null
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; } [ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
[ -n "$iot_url" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
echo ">> zaaktype: $zt_url" echo ">> zaaktype: $zt_url"
echo ">> informatieobjecttype: $iot_url"
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)" echo ">> recreating the acl service pointed at the seeded zaaktype (host-consistent)"
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \ ACL_ZAAKTYPE_URL="$zt_url" ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
docker compose -f "$compose" up -d acl
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
echo ">> submitting a registration to the domain" echo ">> submitting a registration to the domain"
@@ -56,346 +51,18 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \
echo ">> registration accepted at $loc" echo ">> registration accepted at $loc"
echo ">> polling the domain until the worker records the opened zaak" echo ">> polling the domain until the worker records the opened zaak"
zaak_ok=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest \ body="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)" -fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
echo "OK — the domain opened a zaak and recorded it on the registration:" echo "OK — the domain opened a zaak and recorded it on the registration:"
echo "$body" | cut -c1-300 echo "$body" | cut -c1-300
zaak_ok=1 exit 0
break
fi fi
sleep 2 sleep 2
done done
if [ -z "$zaak_ok" ]; then echo "FAIL — the registration never received a zaak URL" >&2
echo "FAIL — the registration never received a zaak URL" >&2 echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2 acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" [ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; } exit 1
exit 1
fi
# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST
# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching
# flowable-rest by container IP (same in-network constraint as above). ──────────────────────────
fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; }
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}"
# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on
# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
rid=os.environ['REG_ID']
# Flowable's task-query returns the included process variables under 'variables'.
print(next((t['id'] for t in (d.get('data') or [])
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), ''))"; }
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
wacht_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten","includeProcessVariables":true}'
# S-10a: every registration now parks at WachtOpDocumenten first (interrupting P30D timer). Completing
# that task stands in for the citizen's document upload (wired for real in S-10b), letting the process
# advance to the diploma routing / Beoordelen so the checks below still hold. The 30-day timeout branch
# is exercised separately at the end.
complete_wacht() { # reg_id
local rid="$1" wid="" r
for _ in $(seq 1 30); do
r="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
wid="$(printf '%s' "$r" | task_for_reg "$rid")"
[ -n "$wid" ] && break
sleep 2
done
[ -n "$wid" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $rid" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
flcurl -X POST "$fl_base/runtime/tasks/$wid" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
echo ">> completed WachtOpDocumenten for $rid (documents received)"
}
echo ">> completing WachtOpDocumenten so the process advances (documents received)"
complete_wacht "$reg_id"
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
[ -n "$task_id" ] && break
sleep 2
done
[ -n "$task_id" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id is waiting"
echo ">> claiming the task as merel-behandelaar"
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
-d '{"action":"claim","assignee":"merel-behandelaar"}' >/dev/null
echo ">> completing the beoordeling (goedkeuren)"
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
-d '{"action":"complete","variables":[{"name":"besluit","type":"string","value":"goedkeuren"}]}' >/dev/null
echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
echo ">> submitting a second registration to withdraw"
loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2"
complete_wacht "$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)"
# ── S-13: diploma-eligibility routing. A registration with a FOREIGN diploma must route through the
# extra CBGVAdvies user task before Beoordelen (the DMN service task sets route=CBGV_ADVIES and the
# gateway branches, ADR-0016). The domestic DIRECT path is already proven by the first registration
# above, which parked straight at Beoordelen. ──────────────────────────────────────────────────────
cbgv_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies","includeProcessVariables":true}'
echo ">> submitting a registration with a foreign diploma"
locf="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
reg_idf="${locf##*/}"
echo ">> foreign registration $reg_idf"
complete_wacht "$reg_idf"
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
cbgv_task=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$cbgv_query" 2>/dev/null || true)"
cbgv_task="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -n "$cbgv_task" ] && break
sleep 2
done
[ -n "$cbgv_task" ] || { echo "FAIL — no CBGVAdvies task appeared for the foreign registration $reg_idf" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> CBGVAdvies task $cbgv_task is waiting"
echo ">> asserting it has NOT reached Beoordelen yet (still awaiting CBGV-advies)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
early="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -z "$early" ] || { echo "FAIL — foreign registration reached Beoordelen ($early) before CBGV-advies" >&2; exit 1; }
echo ">> completing the CBGV-advies task"
flcurl -X POST "$fl_base/runtime/tasks/$cbgv_task" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
echo ">> asserting it now advances to Beoordelen"
onward=""
for _ in $(seq 1 15); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
[ -n "$(printf '%s' "$resp" | task_for_reg "$reg_idf")" ] && { onward=1; break; }
sleep 2
done
[ -n "$onward" ] || { echo "FAIL — foreign registration did not reach Beoordelen after CBGV-advies" >&2; exit 1; }
echo "OK — foreign diploma routed through CBGV-advies, then on to Beoordelen (DMN + gateway)"
# ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer
# early via Flowable's management API (the timer job is moved to executable and run), which routes a
# parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
echo ">> submitting a third registration to escalate"
loc3="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
reg_id3="${loc3##*/}"
echo ">> third registration $reg_id3"
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
rid=os.environ['REG_ID']
t=next((t for t in (d.get('data') or [])
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
candidate_groups() { python3 -c "import sys,json
try:
links=json.load(sys.stdin)
except Exception:
links=[]
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
# The first job id in a management jobs/timer-jobs response on stdin.
first_job_id() { python3 -c "import sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
print(((d.get('data') or [{}])[0]).get('id',''))"; }
complete_wacht "$reg_id3"
echo ">> polling Flowable for its Beoordelen task"
task_id3=""; pid3=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
[ -n "$task_id3" ] && break
sleep 2
done
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
echo ">> asserting the task starts out claimable by the behandelaar group"
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
echo ">> firing the 14-day boundary timer early via the management API"
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
if [ -n "$async_id" ]; then
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
fi
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
escalated=""
for _ in $(seq 1 30); do
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
[ "$groups" = "teamlead" ] && { escalated=1; break; }
sleep 2
done
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
# ── S-10a/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block
# above — its documents never arrive. We fire its 30-day boundary timer early via the management API;
# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task.
# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the
# registration to VERLOPEN (ADR-0017). ─────────────────────────────────────────────────────────────
echo ">> submitting a registration to let its document term lapse"
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$locv" ] || { echo "FAIL — timeout POST /registrations returned no Location" >&2; exit 1; }
reg_idv="${locv##*/}"
echo ">> timeout registration $reg_idv"
echo ">> polling Flowable for its WachtOpDocumenten task"
wacht_id=""; pidv=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
read -r wacht_id pidv <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_idv")"
[ -n "$wacht_id" ] && break
sleep 2
done
[ -n "$wacht_id" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $reg_idv" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> WachtOpDocumenten task $wacht_id (instance $pidv) is waiting for documents"
echo ">> firing the 30-day document timer early via the management API"
timer_idv="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pidv" | first_job_id)"
[ -n "$timer_idv" ] || { echo "FAIL — no timer job found for instance $pidv" >&2; exit 1; }
# Move the timer job to an executable async job; the async executor fires the interrupting boundary
# event. It may run before we look, so executing it explicitly is a best-effort nudge (as for S-14).
flcurl -X POST "$fl_base/management/timer-jobs/$timer_idv" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
async_idv="$(flcurl "$fl_base/management/jobs?processInstanceId=$pidv" 2>/dev/null | first_job_id || true)"
if [ -n "$async_idv" ]; then
flcurl -X POST "$fl_base/management/jobs/$async_idv" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
fi
echo ">> timer fired; the RegistratieVerlopen token is parked for the domain worker"
echo ">> polling the domain until the timeout worker expires the registration to VERLOPEN"
verlopen=""
for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest -fsS "http://$dom_ip:8080$locv" 2>/dev/null || true)"
printf '%s' "$body" | grep -qi 'verlopen' && { verlopen=1; break; }
sleep 2
done
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
# S-10c: the worker cancels the ZGW zaak (ACL-first, before it expires the aggregate), so a VERLOPEN
# registration must carry a zaak whose current status is "Geannuleerd". Read it back from OpenZaak with
# a ZGW token minted like the seed's client (the same client OpenZaak trusts for this stack).
zaak_url_v="$(printf '%s' "$body" | grep -oiE 'http://[^"]*/zaken/api/v1/zaken/[a-f0-9-]+' | head -1)"
[ -n "$zaak_url_v" ] || { echo "FAIL — VERLOPEN registration $reg_idv exposes no zaak URL (body: $body)" >&2; exit 1; }
echo ">> confirming the zaak $zaak_url_v reached the Geannuleerd status in OpenZaak"
read_zaak_status() {
# -i so the heredoc reaches `python -` on the container's stdin (without it the script is empty).
docker run --rm -i --network "$net" \
-e OZ_CLIENT_ID="${OZ_CLIENT_ID:-big-reference-seed}" \
-e OZ_SECRET="${OZ_SECRET:-insecure-dev-secret-change-me}" \
python:3-slim python - "$1" <<'PY'
import base64, hashlib, hmac, json, os, sys, time, urllib.request
cid, sec = os.environ["OZ_CLIENT_ID"], os.environ["OZ_SECRET"]
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
def token():
hdr = {"alg": "HS256", "typ": "JWT"}
pl = {"iss": cid, "iat": int(time.time()), "client_id": cid, "user_id": "verify", "user_representation": "verify"}
seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + b64(json.dumps(pl, separators=(",", ":")).encode())
return (seg + b"." + b64(hmac.new(sec.encode(), seg, hashlib.sha256).digest())).decode()
def get(url):
req = urllib.request.Request(url, headers={
"Authorization": "Bearer " + token(), "Accept": "application/json", "Accept-Crs": "EPSG:4326"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
zaak = get(sys.argv[1])
status_url = zaak.get("status")
if not status_url:
print(""); sys.exit(0)
print(get(get(status_url)["statustype"]).get("omschrijving", ""))
PY
}
geannuleerd=""
for _ in $(seq 1 15); do
oms="$(read_zaak_status "$zaak_url_v" 2>/dev/null | tr -d '\r' || true)"
[ "$oms" = "Geannuleerd" ] && { geannuleerd=1; break; }
sleep 2
done
[ -n "$geannuleerd" ] || { echo "FAIL — zaak $zaak_url_v not Geannuleerd after timeout (current status omschrijving: '$oms')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
exit 0
+1 -10
View File
@@ -35,21 +35,12 @@ populate() { # volume source(file or dir/.)
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; } [ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
# does not cascade a .dmn bundled in a process .bar into the DMN engine, so we seed both raw files and
# let flowable-init deploy each via its own REST app. We stage them in a temp dir and copy its contents.
stage_flowable_workflows() {
local dir="$1"
cp "$here/../workflows/registratie.bpmn" "$here/../workflows/diploma-eligibility.dmn" "$dir/"
}
for key in "$@"; do for key in "$@"; do
case "$key" in case "$key" in
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;; oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;; nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;; kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;; fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;; *) echo "unknown seed key: $key" >&2; exit 2 ;;
esac esac
done done
@@ -24,23 +24,9 @@ import {
Observable Observable
} from 'rxjs'; } from 'rxjs';
export interface DecideRequest {
besluit: string;
}
export interface OpenbaarEntry { export interface OpenbaarEntry {
id: string; id: string;
status: string; status: string;
/** @nullable */
reference: string | null;
}
export interface ProvideDocumentsRequest {
contentBase64: string;
/** @nullable */
fileName?: string | null;
/** @nullable */
contentType?: string | null;
} }
export interface SubmitAccepted { export interface SubmitAccepted {
@@ -48,12 +34,6 @@ export interface SubmitAccepted {
status: string; status: string;
} }
export interface WerkbakItem {
registrationId: string;
bsn: string;
status: string;
}
export type GetOpenbaarRegisterParams = { export type GetOpenbaarRegisterParams = {
q?: string; q?: string;
}; };
@@ -200,78 +180,6 @@ export class BffApiV1Service {
); );
} }
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(
id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
provideDocumentsRequest,{
...(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>>;
@@ -305,73 +213,4 @@ export class BffApiV1Service {
); );
} }
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
postBehandelRegistrationsIdDecide<TData = void>(id: string,
decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable<TData>;
postBehandelRegistrationsIdDecide<TData = void>(id: string,
decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postBehandelRegistrationsIdDecide<TData = void>(id: string,
decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postBehandelRegistrationsIdDecide<TData = void>(
id: string,
decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/behandel/registrations/${id}/decide`,
decideRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/behandel/registrations/${id}/decide`,
decideRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/behandel/registrations/${id}/decide`,
decideRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
}; };
-2
View File
@@ -1,6 +1,4 @@
export * from './lib/auth.service'; export * from './lib/auth.service';
export * from './lib/digid-auth.service'; export * from './lib/digid-auth.service';
export * from './lib/digid-auth.providers'; export * from './lib/digid-auth.providers';
export * from './lib/medewerker-auth.service';
export * from './lib/medewerker-auth.providers';
export * from './lib/authenticated.guard'; export * from './lib/authenticated.guard';
+5 -15
View File
@@ -1,26 +1,16 @@
import { signal, Signal } from '@angular/core'; import { Signal } from '@angular/core';
/** /**
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and * The portal's view of the signed-in user. An abstraction over the OIDC library so components and
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for * guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
* citizens and MedewerkerAuthService for staff).
*/ */
export abstract class AuthService { export abstract class AuthService {
/** Whether a session is active. */ /** Whether a DigiD session is active. */
abstract readonly isAuthenticated: Signal<boolean>; abstract readonly isAuthenticated: Signal<boolean>;
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */ /** The citizen-service number from the DigiD token, once authenticated. */
abstract readonly bsn: Signal<string | undefined>; abstract readonly bsn: Signal<string | undefined>;
/** /** Start the DigiD login (redirects to Keycloak). */
* The realm roles carried in the token. Empty for realms that don't grant roles (e.g. `digid`);
* the `medewerker` realm carries `behandelaar`/`teamlead`.
*/
readonly roles: Signal<readonly string[]> = signal<readonly string[]>([]);
/** Start login (redirects to Keycloak). */
abstract login(): void; abstract login(): void;
/** End the session. */ /** End the session. */
abstract logout(): void; abstract logout(): void;
/** Whether the signed-in user holds the given realm role. */
hasRole(role: string): boolean {
return this.roles().includes(role);
}
} }
@@ -1,49 +0,0 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client';
import { AuthService } from './auth.service';
import { MedewerkerAuthService } from './medewerker-auth.service';
export interface MedewerkerAuthOptions {
/** The Keycloak `medewerker` realm issuer, as reachable from the browser. */
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
secureRoutes: string[];
}
/**
* Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code +
* PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register
* {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF
* calls carry the token.
*/
export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideAuth(
{
config: {
authority: options.authority,
redirectUrl: options.redirectUrl,
postLogoutRedirectUri: options.redirectUrl,
clientId: 'big-portal',
scope: 'openid profile',
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},
// Run checkAuth() at startup so the login callback (?code=…) is processed before the router
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
withAppInitializerAuthCheck(),
),
{ provide: AuthService, useClass: MedewerkerAuthService },
]);
}
@@ -1,43 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { of } from 'rxjs';
import { MedewerkerAuthService } from './medewerker-auth.service';
function makeService(userData: unknown, authenticated = true) {
const oidc = {
isAuthenticated$: of({ isAuthenticated: authenticated }),
userData$: of({ userData }),
authorize: vi.fn(),
logoff: vi.fn(() => of(null)),
};
TestBed.configureTestingModule({
providers: [MedewerkerAuthService, { provide: OidcSecurityService, useValue: oidc }],
});
return { svc: TestBed.inject(MedewerkerAuthService), oidc };
}
describe('MedewerkerAuthService', () => {
it('exposes the realm roles carried in the token', () => {
const { svc } = makeService({ realm_access: { roles: ['behandelaar', 'teamlead'] } });
expect(svc.roles()).toEqual(['behandelaar', 'teamlead']);
expect(svc.hasRole('behandelaar')).toBe(true);
expect(svc.hasRole('beheerder')).toBe(false);
});
it('has no roles when the token omits realm_access', () => {
const { svc } = makeService({ preferred_username: 'merel-behandelaar' });
expect(svc.roles()).toEqual([]);
expect(svc.hasRole('behandelaar')).toBe(false);
});
it('reflects the OIDC authenticated state', () => {
const { svc } = makeService({}, true);
expect(svc.isAuthenticated()).toBe(true);
});
it('starts login by delegating to the OIDC library', () => {
const { svc, oidc } = makeService({});
svc.login();
expect(oidc.authorize).toHaveBeenCalledTimes(1);
});
});
@@ -1,41 +0,0 @@
import { inject, Injectable, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';
import { AuthService } from './auth.service';
/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */
interface MedewerkerClaims {
realm_access?: { roles?: string[] };
}
/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */
@Injectable()
export class MedewerkerAuthService extends AuthService {
private readonly oidc = inject(OidcSecurityService);
readonly isAuthenticated: Signal<boolean> = toSignal(
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
{ initialValue: false },
);
// Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor.
readonly bsn: Signal<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
initialValue: undefined,
});
override readonly roles: Signal<readonly string[]> = toSignal(
this.oidc.userData$.pipe(
map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []),
),
{ initialValue: [] },
);
override login(): void {
this.oidc.authorize();
}
override logout(): void {
this.oidc.logoff().subscribe();
}
}
+2 -43
View File
@@ -20,53 +20,12 @@ app.MapGet("/health", () => "Healthy");
// The ACL's single operation, exposed as a service endpoint. // The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{ {
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct); var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct);
return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
}); });
// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak
// URL; the ACL owns the ZGW statustype resolution (§8.1).
app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) =>
{
await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct);
return Results.NoContent();
});
// Cancel a zaak on document-timeout expiry (S-10c): set it to its zaaktype's cancellation statustype
// + resultaat. The domain hands over only the zaak URL; the ACL owns the ZGW resolution (§8.1).
app.MapPost("/annuleringen", async (CancelZaakRequest body, AclService acl, CancellationToken ct) =>
{
await acl.CancelZaakAsync(new Uri(body.ZaakUrl), ct);
return Results.NoContent();
});
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
// the read projection without reading ZGW itself (§8.1, #78).
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
{
var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct);
return Results.Ok(new { reference });
});
// Store an uploaded diploma against a zaak (S-10b): the domain sends the file as base64; the ACL
// creates the ZGW enkelvoudiginformatieobject and relates it to the zaak (§8.1). Returns its URL.
app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, CancellationToken ct) =>
{
var url = await acl.StoreDiplomaAsync(
new Uri(body.ZaakUrl), Convert.FromBase64String(body.ContentBase64), body.FileName, body.ContentType, ct);
return Results.Ok(new { informatieobjectUrl = url.ToString() });
});
app.Run(); app.Run();
public sealed record OpenZaakRequest(string Bsn, string Reference); public sealed record OpenZaakRequest(string Bsn);
public sealed record SetStatusRequest(string ZaakUrl);
public sealed record CancelZaakRequest(string ZaakUrl);
public sealed record ZaakReferenceRequest(string ZaakUrl);
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
public partial class Program; public partial class Program;
@@ -7,8 +7,4 @@ public sealed class AclDefaults
public required string VerantwoordelijkeOrganisatie { get; init; } public required string VerantwoordelijkeOrganisatie { get; init; }
public required string Vertrouwelijkheidaanduiding { get; init; } public required string Vertrouwelijkheidaanduiding { get; init; }
public required Uri ZaaktypeUrl { get; init; } public required Uri ZaaktypeUrl { get; init; }
/// <summary>The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
public required Uri InformatieobjecttypeUrl { get; init; }
} }
+1 -62
View File
@@ -13,69 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
defaults.VerantwoordelijkeOrganisatie, defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl, defaults.ZaaktypeUrl,
clock.Today, clock.Today);
registration.Reference);
return gateway.OpenZaakAsync(request, ct); return gateway.OpenZaakAsync(request, ct);
} }
/// <summary>
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// </summary>
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
/// <summary>
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
/// statustype/resultaat means "cancelled" (§8.1).
/// </summary>
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
}
/// <summary>
/// Store an uploaded diploma against the zaak (S-10b): default-fill the ZGW-mandatory document
/// fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, taal, creatiedatum)
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
/// </summary>
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(content);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
var request = new DocumentRequest(
defaults.Bronorganisatie,
defaults.InformatieobjecttypeUrl,
defaults.Vertrouwelijkheidaanduiding,
zaakUrl,
clock.Today,
Titel: "Diploma",
Auteur: "zorgprofessional",
Taal: "nld",
Bestandsnaam: fileName,
Formaat: contentType,
Inhoud: content);
return gateway.StoreDocumentAsync(request, ct);
}
} }
@@ -1,17 +0,0 @@
namespace Acl.Application;
/// <summary>The fully default-filled diploma document the gateway will create in the ZGW Documenten
/// API and relate to the zaak (S-10b). <see cref="Inhoud"/> is the raw file content; the gateway
/// base64-encodes it into the ZGW <c>inhoud</c> field.</summary>
public sealed record DocumentRequest(
string Bronorganisatie,
Uri Informatieobjecttype,
string Vertrouwelijkheidaanduiding,
Uri Zaak,
DateOnly Creatiedatum,
string Titel,
string Auteur,
string Taal,
string Bestandsnaam,
string Formaat,
byte[] Inhoud);
@@ -1,5 +1,4 @@
namespace Acl.Application; namespace Acl.Application;
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/> /// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary>
/// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78).</summary> public sealed record DomainRegistration(string Bsn);
public sealed record DomainRegistration(string Bsn, string Reference);
@@ -5,31 +5,4 @@ namespace Acl.Application;
public interface IZaakGateway public interface IZaakGateway
{ {
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default); Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
/// <summary>
/// Set the given zaak to the <em>eindstatus</em> (final statustype) of the supplied zaaktype —
/// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
/// </summary>
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
/// <summary>
/// Set the given zaak to the <em>cancellation</em> statustype ("Geannuleerd") and record the
/// matching cancellation resultaat ("Vervallen") — the ZGW translation of "the 30-day document term
/// lapsed" (S-10c). Distinct from <see cref="SetZaakToEindstatusAsync"/> (approval): the gateway
/// resolves both the cancellation statustype and resultaattype from the catalogus by their
/// omschrijving, POSTs the resultaat then the status, dated <paramref name="datumStatusGezet"/>.
/// </summary>
Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
/// <summary>
/// Store a diploma document (S-10b): create an <c>enkelvoudiginformatieobject</c> in the ZGW
/// Documenten API and relate it to the zaak via a <c>zaakinformatieobject</c>. Returns the URL of
/// the created informatieobject.
/// </summary>
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
} }
+1 -2
View File
@@ -6,5 +6,4 @@ public sealed record ZaakRequest(
string VerantwoordelijkeOrganisatie, string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding, string Vertrouwelijkheidaanduiding,
Uri Zaaktype, Uri Zaaktype,
DateOnly Startdatum, DateOnly Startdatum);
string Identificatie);
@@ -8,12 +8,6 @@ namespace Acl.Infrastructure;
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary> /// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
{ {
// The ACL owns which ZGW statustype/resultaat carries each domain outcome (§8.1). These
// omschrijvingen match the seeded BIG catalogus (infra/openzaak/seed_catalogus.py).
private const string GeregistreerdResultaat = "Geregistreerd"; // approval outcome
private const string GeannuleerdStatus = "Geannuleerd"; // document-timeout cancellation status (S-10c)
private const string VervallenResultaat = "Vervallen"; // document-timeout cancellation outcome (S-10c)
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -26,8 +20,7 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
request.Zaaktype.ToString(), request.Zaaktype.ToString(),
request.VerantwoordelijkeOrganisatie, request.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"), request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding, request.Vertrouwelijkheidaanduiding)),
request.Identificatie)),
}; };
message.Headers.Authorization = message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
@@ -48,254 +41,13 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(created.Url); return new Uri(created.Url);
} }
public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
// Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking
// the first would be ambiguous (the Zaken API does not guarantee order).
var resultaattype = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, GeregistreerdResultaat, ct);
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
await PostAsync("/zaken/api/v1/resultaten",
new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct);
await PostAsync("/zaken/api/v1/statussen",
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
new StatusDto(zaakUrl.ToString(), eindstatus.ToString(),
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
"Setting the zaak status", ct);
}
public async Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
// Distinct from approval: resolve the cancellation statustype + resultaat by name (Geannuleerd
// is a non-terminal statustype, so it is never the eindstatus the approval path resolves).
var cancellationStatus = await ResolveStatustypeByOmschrijvingAsync(zaaktypeUrl, GeannuleerdStatus, ct);
var cancellationResultaat = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, VervallenResultaat, ct);
// As with approval, OpenZaak wants the resultaat recorded before the status.
await PostAsync("/zaken/api/v1/resultaten",
new ResultaatDto(zaakUrl.ToString(), cancellationResultaat.ToString()),
"Setting the zaak cancellation resultaat", ct);
await PostAsync("/zaken/api/v1/statussen",
new StatusDto(zaakUrl.ToString(), cancellationStatus.ToString(),
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
"Setting the zaak cancellation status", ct);
}
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// The zaak is a geo resource; the Zaken API requires the CRS header even on GET.
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, "Reading the zaak", ct);
var zaak = await response.Content.ReadFromJsonAsync<ZaakReadDto>(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
return zaak.Identificatie;
}
public async Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
// 1. Create the enkelvoudiginformatieobject in the Documenten API (not a geo API — no CRS).
var created = await PostForUrlAsync(
"/documenten/api/v1/enkelvoudiginformatieobjecten",
new EnkelvoudigInformatieobjectDto(
request.Bronorganisatie,
request.Creatiedatum.ToString("yyyy-MM-dd"),
request.Titel,
request.Auteur,
request.Taal,
request.Informatieobjecttype.ToString(),
Convert.ToBase64String(request.Inhoud),
request.Bestandsnaam,
request.Inhoud.Length,
request.Vertrouwelijkheidaanduiding,
request.Formaat,
"definitief",
// No usage-rights restrictions apply. Left null, OpenZaak rejects closing the related
// zaak with "indicatiegebruiksrecht-unset"; false records the deliberate "none" answer.
false),
"Creating the informatieobject", ct);
// 2. Relate it to the zaak (Zaken API — no CRS).
await PostAsync("/zaken/api/v1/zaakinformatieobjecten",
new ZaakInformatieobjectDto(request.Zaak.ToString(), created.ToString()),
"Relating the informatieobject to the zaak", ct);
return created;
}
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
// a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{
Content = JsonContent.Create(dto),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, action, ct);
}
// POSTs a non-geo ZGW resource and returns the created resource's URL (as PostAsync, but reads back
// the `url` of the created object). Buffers the body so uwsgi gets a Content-Length.
private async Task<Uri> PostForUrlAsync(string path, object dto, string action, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{
Content = JsonContent.Create(dto),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, action, ct);
var created = await response.Content.ReadFromJsonAsync<CreatedDto>(ct)
?? throw new InvalidOperationException($"OpenZaak returned an empty response for {action}");
return new Uri(created.Url);
}
// EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
// is essential for diagnosing a rejected request, so surface it in the exception.
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
}
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
{
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
var results = page.Results ?? [];
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
// highest volgnummer if the flag is absent.
var eindstatus = results.FirstOrDefault(s => s.IsEindstatus)
?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}");
return new Uri(eindstatus.Url);
}
/// <summary>Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd").</summary>
private async Task<Uri> ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
{
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
var match = (page.Results ?? []).FirstOrDefault(s => s.Omschrijving == omschrijving)
?? throw new InvalidOperationException($"No '{omschrijving}' statustype found for zaaktype {zaaktypeUrl}");
return new Uri(match.Url);
}
/// <summary>Resolve a specific resultaattype from the catalogus by its omschrijving (the seed defines
/// "Geregistreerd" for approval and "Vervallen" for a document-timeout cancellation).</summary>
private async Task<Uri> ResolveResultaattypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
{
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
var match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving)
?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}");
return new Uri(match.Url);
}
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
private async Task<T> GetCatalogusAsync<T>(string resource, Uri zaaktypeUrl, string label, CancellationToken ct)
{
var query = new Uri(options.BaseUrl,
$"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, $"Querying {label}", ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
}
private sealed record ZaakDto( private sealed record ZaakDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum, [property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding, [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record ZaakCreatedDto( private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url); [property: JsonPropertyName("url")] string Url);
private sealed record ZaakReadDto(
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record StatusDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype,
[property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet);
private sealed record StatustypePage(
[property: JsonPropertyName("results")] IReadOnlyList<StatustypeDto>? Results);
private sealed record StatustypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer,
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus,
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
private sealed record ResultaatDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("resultaattype")] string Resultaattype);
private sealed record ResultaattypePage(
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
private sealed record ResultaattypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
private sealed record CreatedDto(
[property: JsonPropertyName("url")] string Url);
private sealed record EnkelvoudigInformatieobjectDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("creatiedatum")] string Creatiedatum,
[property: JsonPropertyName("titel")] string Titel,
[property: JsonPropertyName("auteur")] string Auteur,
[property: JsonPropertyName("taal")] string Taal,
[property: JsonPropertyName("informatieobjecttype")] string Informatieobjecttype,
[property: JsonPropertyName("inhoud")] string Inhoud,
[property: JsonPropertyName("bestandsnaam")] string Bestandsnaam,
[property: JsonPropertyName("bestandsomvang")] int Bestandsomvang,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
[property: JsonPropertyName("formaat")] string Formaat,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("indicatieGebruiksrecht")] bool IndicatieGebruiksrecht);
private sealed record ZaakInformatieobjectDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("informatieobject")] string Informatieobject);
} }
@@ -65,68 +65,6 @@ public sealed class OpenZaakFixture : IDisposable
return JsonDocument.Parse(json).RootElement.Clone(); return JsonDocument.Parse(json).RootElement.Clone();
} }
/// <summary>GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers.</summary>
public async Task<JsonElement> GetJsonAsync(Uri url, CancellationToken ct = default)
{
using var message = new HttpRequestMessage(HttpMethod.Get, url);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
using var response = await Http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
return JsonDocument.Parse(json).RootElement.Clone();
}
/// <summary>The URL of the published "Diploma" informatieobjecttype (S-10b), or null when the
/// stack has not been seeded with OZ_PUBLISH=1. `status=definitief` returns published types only.</summary>
public async Task<Uri?> FindPublishedDiplomaInformatieobjecttypeAsync(CancellationToken ct = default)
{
var query = new Uri(BaseUrl, "/catalogi/api/v1/informatieobjecttypen?status=definitief");
var page = await GetJsonAsync(query, ct);
foreach (var iot in page.GetProperty("results").EnumerateArray())
if (iot.TryGetProperty("omschrijving", out var o) && o.GetString() == "Diploma")
return new Uri(iot.GetProperty("url").GetString()!);
return null;
}
/// <summary>The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets.</summary>
public async Task<Uri> FindEindstatustypeAsync(Uri zaaktypeUrl, CancellationToken ct = default)
{
var query = new Uri(BaseUrl,
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
var page = await GetJsonAsync(query, ct);
var results = page.GetProperty("results");
Uri? fallback = null;
var highest = int.MinValue;
foreach (var st in results.EnumerateArray())
{
if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean())
return new Uri(st.GetProperty("url").GetString()!);
var volgnummer = st.GetProperty("volgnummer").GetInt32();
if (volgnummer > highest)
{
highest = volgnummer;
fallback = new Uri(st.GetProperty("url").GetString()!);
}
}
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
}
/// <summary>Resolve a statustype by its omschrijving (e.g. the S-10c "Geannuleerd" cancellation status).</summary>
public async Task<Uri> FindStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct = default)
{
var query = new Uri(BaseUrl,
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
var page = await GetJsonAsync(query, ct);
foreach (var st in page.GetProperty("results").EnumerateArray())
if (st.TryGetProperty("omschrijving", out var o) && o.GetString() == omschrijving)
return new Uri(st.GetProperty("url").GetString()!);
throw new InvalidOperationException($"No '{omschrijving}' statustype for zaaktype {zaaktypeUrl}");
}
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here // A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
// rather than reusing Acl.Infrastructure's internal minter to keep that internal. // rather than reusing Acl.Infrastructure's internal minter to keep that internal.
private string MintToken() private string MintToken()
@@ -22,14 +22,12 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
"seed it with OZ_PUBLISH=1 (`make integration` does this)."); "seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options); var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
var request = new ZaakRequest( var request = new ZaakRequest(
Bronorganisatie: "517439943", Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar", Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!, Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow), Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
Identificatie: reference);
var zaakUrl = await gateway.OpenZaakAsync(request); var zaakUrl = await gateway.OpenZaakAsync(request);
@@ -38,127 +36,10 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(), new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
zaakUrl.ToString()); zaakUrl.ToString());
// ...and that zaak is really persisted with the default-filled fields + the reference identificatie. // ...and that zaak is really persisted with the default-filled fields.
var zaak = await stack.GetZaakAsync(zaakUrl); var zaak = await stack.GetZaakAsync(zaakUrl);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString()); Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
}
[Fact]
public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype()
{
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
Assert.True(zaaktype is not null,
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: Guid.NewGuid().ToString()));
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
// The zaak now carries a current status, and it is the zaaktype's eindstatus.
var zaak = await stack.GetZaakAsync(zaakUrl);
var statusUrl = zaak.GetProperty("status").GetString();
Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status");
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!);
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
}
[Fact]
public async Task Cancelling_a_zaak_records_the_geannuleerd_status_and_a_resultaat()
{
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
Assert.True(zaaktype is not null,
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: Guid.NewGuid().ToString()));
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
// The zaak's current status is the Geannuleerd statustype — distinct from the approval eindstatus.
var zaak = await stack.GetZaakAsync(zaakUrl);
var statusUrl = zaak.GetProperty("status").GetString();
Assert.False(string.IsNullOrEmpty(statusUrl), "the cancelled zaak has no current status");
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
var geannuleerd = await stack.FindStatustypeByOmschrijvingAsync(zaaktype!, "Geannuleerd");
Assert.Equal(geannuleerd.ToString(), status.GetProperty("statustype").GetString());
// ...and a resultaat is recorded (OpenZaak requires it before a closing/terminal status).
Assert.False(string.IsNullOrEmpty(zaak.GetProperty("resultaat").GetString()),
"the cancelled zaak has no resultaat");
}
[Fact]
public async Task Storing_a_diploma_creates_a_real_informatieobject_related_to_the_zaak()
{
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
Assert.True(zaaktype is not null,
"No published BIG-REGISTRATIE zaaktype found — seed the stack with OZ_PUBLISH=1.");
var informatieobjecttype = await stack.FindPublishedDiplomaInformatieobjecttypeAsync();
Assert.True(informatieobjecttype is not null,
"No published Diploma informatieobjecttype found — seed the stack with OZ_PUBLISH=1.");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: Guid.NewGuid().ToString()));
var content = System.Text.Encoding.UTF8.GetBytes("%PDF-1.4 synthetic diploma\n");
var documentUrl = await gateway.StoreDocumentAsync(new DocumentRequest(
Bronorganisatie: "517439943",
Informatieobjecttype: informatieobjecttype!,
Vertrouwelijkheidaanduiding: "openbaar",
Zaak: zaakUrl,
Creatiedatum: DateOnly.FromDateTime(DateTime.UtcNow),
Titel: "Diploma",
Auteur: "zorgprofessional",
Taal: "nld",
Bestandsnaam: "diploma.pdf",
Formaat: "application/pdf",
Inhoud: content));
// The gateway returns the canonical informatieobject URL...
Assert.StartsWith(
new Uri(stack.BaseUrl, "/documenten/api/v1/enkelvoudiginformatieobjecten/").ToString(),
documentUrl.ToString());
// ...the document is really persisted with the default-filled fields...
var doc = await stack.GetJsonAsync(documentUrl);
Assert.Equal("diploma.pdf", doc.GetProperty("bestandsnaam").GetString());
Assert.Equal(informatieobjecttype.ToString(), doc.GetProperty("informatieobjecttype").GetString());
Assert.Equal(content.Length, doc.GetProperty("bestandsomvang").GetInt32());
// indicatieGebruiksrecht is recorded as "no restrictions"; left null, OpenZaak would refuse to
// close the zaak this document is related to (the S-10b regression that broke the e2e flow).
Assert.False(doc.GetProperty("indicatieGebruiksrecht").GetBoolean());
// ...and it is related to the zaak (a zaakinformatieobject links the two).
var relations = await stack.GetJsonAsync(new Uri(stack.BaseUrl,
"/zaken/api/v1/zaakinformatieobjecten?informatieobject=" + Uri.EscapeDataString(documentUrl.ToString())));
Assert.Contains(relations.EnumerateArray(),
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString());
} }
} }
+1 -159
View File
@@ -9,55 +9,13 @@ public class AclServiceTests
public ZaakRequest? Captured; public ZaakRequest? Captured;
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved;
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{ {
Captured = request; Captured = request;
return Task.FromResult(Result); return Task.FromResult(Result);
} }
public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask;
}
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled;
public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask;
}
public Uri? ReadReferenceFor;
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ReadReferenceFor = zaakUrl;
return Task.FromResult("REG-FROM-ZAAK");
}
public DocumentRequest? StoredDocument;
public Uri DocumentResult { get; } = new("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1");
public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
{
StoredDocument = request;
return Task.FromResult(DocumentResult);
}
} }
private static AclDefaults Defaults() => new()
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
};
private sealed class FixedClock(DateOnly today) : IClock private sealed class FixedClock(DateOnly today) : IClock
{ {
public DateOnly Today { get; } = today; public DateOnly Today { get; } = today;
@@ -73,11 +31,10 @@ public class AclServiceTests
VerantwoordelijkeOrganisatie = "517439943", VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar", Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
}; };
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77")); var url = await service.OpenZaakAsync(new DomainRegistration("123456782"));
Assert.Equal(gateway.Result, url); Assert.Equal(gateway.Result, url);
var req = Assert.IsType<ZaakRequest>(gateway.Captured); var req = Assert.IsType<ZaakRequest>(gateway.Captured);
@@ -86,8 +43,6 @@ public class AclServiceTests
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie);
} }
[Fact] [Fact]
@@ -100,123 +55,10 @@ public class AclServiceTests
VerantwoordelijkeOrganisatie = "517439943", VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar", Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
}; };
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured); Assert.Null(gateway.Captured);
} }
[Fact]
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
{
var gateway = new FakeGateway();
var defaults = Defaults();
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.ApproveZaakAsync(zaak);
Assert.NotNull(gateway.Approved);
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
}
[Fact]
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
}
[Fact]
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
{
var gateway = new FakeGateway();
var defaults = Defaults();
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.CancelZaakAsync(zaak);
Assert.NotNull(gateway.Cancelled);
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
// Cancellation must not touch the approval path.
Assert.Null(gateway.Approved);
}
[Fact]
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
Assert.Null(gateway.Cancelled);
}
[Fact]
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
{
var gateway = new FakeGateway();
var defaults = Defaults();
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf");
Assert.Equal(gateway.DocumentResult, url);
var req = gateway.StoredDocument!;
Assert.Equal(zaak, req.Zaak);
Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
Assert.Equal("517439943", req.Bronorganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
Assert.Equal("nld", req.Taal);
Assert.Equal("diploma.pdf", req.Bestandsnaam);
Assert.Equal("application/pdf", req.Formaat);
Assert.Equal(new byte[] { 1, 2, 3 }, req.Inhoud);
}
[Fact]
public async Task Storing_a_diploma_rejects_null_or_blank_arguments()
{
var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf"));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(zaak, null!, "d.pdf", "application/pdf"));
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.StoreDiplomaAsync(zaak, [1], " ", "application/pdf"));
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.StoreDiplomaAsync(zaak, [1], "d.pdf", " "));
}
[Fact]
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak);
Assert.Equal("REG-FROM-ZAAK", reference);
Assert.Equal(zaak, gateway.ReadReferenceFor);
}
[Fact]
public async Task Reading_a_null_zaak_reference_is_rejected()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor);
}
} }
+2 -526
View File
@@ -22,7 +22,7 @@ public class OpenZaakGatewayTests
private static ZaakRequest SampleRequest() => new( private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar", "517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1"); new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
private static StubHandler Created(out RequestCapture capture) private static StubHandler Created(out RequestCapture capture)
{ {
@@ -67,8 +67,6 @@ public class OpenZaakGatewayTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body); Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
// The registration reference is set as the zaak identificatie (#78).
Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body);
} }
[Fact] [Fact]
@@ -133,9 +131,8 @@ public class OpenZaakGatewayTests
Content = new StringContent("null", Encoding.UTF8, "application/json"), Content = new StringContent("null", Encoding.UTF8, "application/json"),
})); }));
var ex = await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest())); () => Gateway(handler).OpenZaakAsync(SampleRequest()));
Assert.Contains("empty zaak response", ex.Message);
} }
[Fact] [Fact]
@@ -147,417 +144,6 @@ public class OpenZaakGatewayTests
() => Gateway(handler).OpenZaakAsync(null!)); () => Gateway(handler).OpenZaakAsync(null!));
} }
// --- SetZaakToEindstatusAsync (approval / S-09b) ---
private const string ZaakUrl = "http://openzaak/zaken/api/v1/zaken/xyz";
private static readonly Uri Zaaktype = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
private sealed class Recorder
{
public List<HttpRequestMessage> Requests { get; } = [];
public List<string?> Bodies { get; } = [];
public List<long?> ContentLengths { get; } = [];
public int IndexOf(string pathContains) =>
Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains));
// The (body, content-length, request) of the single request whose URL contains the segment.
public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains)
{
var i = IndexOf(pathContains);
return (Bodies[i], ContentLengths[i], Requests[i]);
}
}
// Per-route response config for the four calls the approval makes.
private sealed class OzRoutes
{
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
public string ResultaattypenJson { get; init; } =
"""{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1","omschrijving":"Geregistreerd"}]}""";
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created;
}
// Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus),
// then POST /resultaten and POST /statussen (zaken).
private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req =>
{
rec.Requests.Add(req);
// Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect).
rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
var url = req.RequestUri!.ToString();
if (req.Method == HttpMethod.Get && url.Contains("/statustypen"))
return Json(routes.StatustypenStatus, routes.StatustypenJson);
if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen"))
return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson);
if (url.Contains("/resultaten"))
return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}""");
return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}""");
});
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
// Two statustypen; the eindstatus is flagged on the *lower* volgnummer so the tests prove the
// isEindstatus flag is preferred over "highest volgnummer", not coincidentally equal to it.
private static string StatustypenPage(bool withEindstatusFlag) => JsonSerializer.Serialize(new
{
results = new object[]
{
new { url = "http://openzaak/catalogi/api/v1/statustypen/1", volgnummer = 1, isEindstatus = withEindstatusFlag },
new { url = "http://openzaak/catalogi/api/v1/statustypen/2", volgnummer = 2, isEindstatus = false },
},
});
[Fact]
public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak()
{
var rec = new Recorder();
await Gateway(ApprovalStub(rec, new OzRoutes()))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
Assert.Equal(4, rec.Requests.Count);
// Both catalogus queries filter by the zaaktype and carry the bearer.
var statustypenGet = rec.Sent("/statustypen").Request;
Assert.Equal(HttpMethod.Get, statustypenGet.Method);
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString());
Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme);
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString());
// OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen.
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
var resultaat = rec.Sent("/resultaten");
Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString());
Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme);
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body);
Assert.True(resultaat.Length > 0);
var status = rec.Sent("/statussen");
Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString());
Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme);
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
// The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer.
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body);
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
// Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body.
Assert.True(status.Length > 0);
}
[Fact]
public async Task Approving_selects_the_geregistreerd_resultaat_by_name_when_several_exist()
{
// Once S-10c adds a second resultaattype (Vervallen), picking the first is ambiguous — the
// Zaken API does not guarantee order. Approval must resolve its resultaat by omschrijving.
var rec = new Recorder();
var twoResultaattypen = """
{"results":[
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"},
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"}
]}
""";
await Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = twoResultaattypen }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd\"",
rec.Sent("/resultaten").Body);
}
// --- SetZaakToCancellationStatusAsync (document-timeout cancellation / S-10c) ---
// A catalogus with the three statustypen S-10c seeds (Geannuleerd is non-terminal, below the
// Afgehandeld eindstatus) and both resultaattypen. Cancellation must resolve "Geannuleerd" and
// "Vervallen" by omschrijving, never the approval pair.
private const string CancellationStatustypenJson = """
{"results":[
{"url":"http://openzaak/catalogi/api/v1/statustypen/ontvangen","volgnummer":1,"omschrijving":"Ontvangen","isEindstatus":false},
{"url":"http://openzaak/catalogi/api/v1/statustypen/geannuleerd","volgnummer":2,"omschrijving":"Geannuleerd","isEindstatus":false},
{"url":"http://openzaak/catalogi/api/v1/statustypen/afgehandeld","volgnummer":3,"omschrijving":"Afgehandeld","isEindstatus":true}
]}
""";
private const string CancellationResultaattypenJson = """
{"results":[
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"},
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"}
]}
""";
[Fact]
public async Task Cancelling_records_the_vervallen_resultaat_then_the_geannuleerd_status_against_the_zaak()
{
var rec = new Recorder();
await Gateway(ApprovalStub(rec, new OzRoutes
{
StatustypenJson = CancellationStatustypenJson,
ResultaattypenJson = CancellationResultaattypenJson,
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
// Resultaat precedes status (OpenZaak requires a resultaat before a closing/terminal status).
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
var resultaat = rec.Sent("/resultaten");
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
// The cancellation resultaat (Vervallen) is chosen by name — not the approval one (Geregistreerd).
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/vervallen\"", resultaat.Body);
var status = rec.Sent("/statussen");
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
// The Geannuleerd statustype is chosen by name — not the Afgehandeld eindstatus (approval).
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/geannuleerd\"", status.Body);
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
}
[Fact]
public async Task Cancelling_throws_when_the_zaaktype_has_no_geannuleerd_statustype()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes
{
// Only the approval statustypen — no "Geannuleerd".
StatustypenJson = StatustypenPage(withEindstatusFlag: true),
ResultaattypenJson = CancellationResultaattypenJson,
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Geannuleerd", ex.Message);
}
[Fact]
public async Task Cancelling_rejects_a_null_zaak_without_calling_openzaak()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToCancellationStatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
}
[Fact]
public async Task Cancelling_rejects_a_null_zaaktype_without_calling_openzaak()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
}
[Fact]
public async Task Cancelling_surfaces_the_failure_when_recording_the_resultaat_is_rejected()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes
{
StatustypenJson = CancellationStatustypenJson,
ResultaattypenJson = CancellationResultaattypenJson,
ResultaatPostStatus = HttpStatusCode.BadRequest,
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("cancellation resultaat", ex.Message);
// It fails on the resultaat, before it ever posts the status.
Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
public async Task Cancelling_surfaces_the_failure_when_recording_the_status_is_rejected()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes
{
StatustypenJson = CancellationStatustypenJson,
ResultaattypenJson = CancellationResultaattypenJson,
StatusPostStatus = HttpStatusCode.BadRequest,
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("cancellation status", ex.Message);
}
[Fact]
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
{
var rec = new Recorder();
await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = StatustypenPage(withEindstatusFlag: false) }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
// No isEindstatus flag → the highest volgnummer (/2) is chosen.
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body);
}
[Fact]
public async Task Approving_throws_when_the_zaaktype_has_no_statustypen()
{
var rec = new Recorder();
// A page with no `results` property (Results is null) — the eindstatus cannot be resolved.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("No statustypen found", ex.Message);
// It never posts anything when it cannot resolve the eindstatus.
Assert.Single(rec.Requests);
}
[Fact]
public async Task Approving_throws_when_the_statustypen_response_is_empty()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("empty statustypen", ex.Message);
Assert.Single(rec.Requests);
}
[Fact]
public async Task Approving_throws_when_the_statustypen_query_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Querying statustypen", ex.Message);
}
[Fact]
public async Task Approving_throws_when_the_resultaattypen_query_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Querying resultaattypen", ex.Message);
Assert.Equal(-1, rec.IndexOf("/resultaten"));
}
[Fact]
public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("'Geregistreerd' resultaattype", ex.Message);
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
Assert.Equal(-1, rec.IndexOf("/resultaten"));
Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
public async Task Approving_throws_when_posting_the_resultaat_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Setting the zaak resultaat", ex.Message);
// The status is never posted if the resultaat could not be recorded.
Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
public async Task Approving_throws_when_posting_the_status_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Setting the zaak status", ex.Message);
// It got as far as the resultaat + the status POST (4 calls) before failing.
Assert.Equal(4, rec.Requests.Count);
}
[Fact]
public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs()
{
RequestCapture? capture = null;
var handler = new StubHandler(req =>
{
capture = new RequestCapture { Seen = req };
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }),
});
});
var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl));
Assert.Equal("REG-XYZ", reference);
Assert.Equal(HttpMethod.Get, capture!.Seen!.Method);
Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs")));
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_rejects_it()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") }));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("Reading the zaak", ex.Message);
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("empty zaak response", ex.Message);
}
[Fact]
public async Task Reading_a_null_zaak_is_rejected()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).GetZaakIdentificatieAsync(null!));
}
[Fact]
public async Task Approving_rejects_a_null_zaak_or_zaaktype()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToEindstatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToEindstatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
}
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode. // ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
private static string DecodeSegment(string segment) private static string DecodeSegment(string segment)
{ {
@@ -565,114 +151,4 @@ public class OpenZaakGatewayTests
b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 }; b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 };
return Encoding.UTF8.GetString(Convert.FromBase64String(b64)); return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
} }
// --- StoreDocumentAsync (diploma upload / S-10b) ---
private static readonly Uri Informatieobjecttype =
new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
private static DocumentRequest SampleDocument(byte[]? inhoud = null) => new(
Bronorganisatie: "517439943",
Informatieobjecttype: Informatieobjecttype,
Vertrouwelijkheidaanduiding: "openbaar",
Zaak: new Uri(ZaakUrl),
Creatiedatum: new DateOnly(2026, 6, 4),
Titel: "Diploma",
Auteur: "zorgprofessional",
Taal: "nld",
Bestandsnaam: "diploma.pdf",
Formaat: "application/pdf",
Inhoud: inhoud ?? [1, 2, 3, 4]);
// Routes the two document calls: POST /enkelvoudiginformatieobjecten (documenten) then
// POST /zaakinformatieobjecten (zaken).
private static StubHandler DocumentStub(Recorder rec) => new(async req =>
{
rec.Requests.Add(req);
rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
return req.RequestUri!.ToString().Contains("/enkelvoudiginformatieobjecten")
? Json(HttpStatusCode.Created, """{"url":"http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1"}""")
: Json(HttpStatusCode.Created, """{"url":"http://openzaak/zaken/api/v1/zaakinformatieobjecten/rel-1"}""");
});
[Fact]
public async Task Storing_a_document_creates_the_informatieobject_then_relates_it_to_the_zaak()
{
var rec = new Recorder();
var url = await Gateway(DocumentStub(rec)).StoreDocumentAsync(SampleDocument([10, 20, 30]));
Assert.Equal("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1", url.ToString());
// 1. Create the enkelvoudiginformatieobject in the Documenten API.
var create = rec.Sent("/enkelvoudiginformatieobjecten");
Assert.Equal(HttpMethod.Post, create.Request.Method);
Assert.Equal("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten",
create.Request.RequestUri!.ToString());
Assert.Equal("Bearer", create.Request.Headers.Authorization!.Scheme);
Assert.Contains("\"bronorganisatie\":\"517439943\"", create.Body);
Assert.Contains("\"informatieobjecttype\":\"http://openzaak/catalogi/api/v1/informatieobjecttypen/dip\"", create.Body);
Assert.Contains("\"creatiedatum\":\"2026-06-04\"", create.Body);
Assert.Contains("\"titel\":\"Diploma\"", create.Body);
Assert.Contains("\"auteur\":\"zorgprofessional\"", create.Body);
Assert.Contains("\"taal\":\"nld\"", create.Body);
Assert.Contains("\"bestandsnaam\":\"diploma.pdf\"", create.Body);
Assert.Contains("\"formaat\":\"application/pdf\"", create.Body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", create.Body);
Assert.Contains("\"status\":\"definitief\"", create.Body);
// indicatieGebruiksrecht must be set explicitly (false = no usage restrictions); left null,
// OpenZaak refuses to close the zaak this document is related to ("indicatiegebruiksrecht-unset").
Assert.Contains("\"indicatieGebruiksrecht\":false", create.Body);
// The file content is base64-encoded into `inhoud`, with its byte length in `bestandsomvang`.
Assert.Contains($"\"inhoud\":\"{Convert.ToBase64String([10, 20, 30])}\"", create.Body);
Assert.Contains("\"bestandsomvang\":3", create.Body);
// 2. Relate that informatieobject to the zaak (Zaken API — no CRS).
var relate = rec.Sent("/zaakinformatieobjecten");
Assert.Equal(HttpMethod.Post, relate.Request.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaakinformatieobjecten",
relate.Request.RequestUri!.ToString());
Assert.Contains($"\"zaak\":\"{ZaakUrl}\"", relate.Body);
Assert.Contains("\"informatieobject\":\"http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1\"", relate.Body);
}
[Fact]
public async Task Storing_a_document_buffers_the_body_and_sends_no_crs_headers()
{
// uwsgi rejects a chunked body (Content-Length must be present); the Documenten API is not a
// geo API, so no CRS headers (unlike the Zaken zaak-create).
var rec = new Recorder();
await Gateway(DocumentStub(rec)).StoreDocumentAsync(SampleDocument());
var create = rec.Sent("/enkelvoudiginformatieobjecten");
Assert.NotNull(create.Length);
Assert.True(create.Length > 0);
Assert.False(create.Request.Headers.Contains("Accept-Crs"));
Assert.False(create.Request.Content!.Headers.Contains("Content-Crs"));
}
[Fact]
public async Task Storing_a_document_surfaces_an_openzaak_rejection()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("""{"detail":"bad"}""", Encoding.UTF8, "application/json"),
}));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).StoreDocumentAsync(SampleDocument()));
Assert.Contains("bad", ex.Message);
}
[Fact]
public async Task Storing_a_document_rejects_a_null_request()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!));
}
} }
+3 -59
View File
@@ -6,38 +6,16 @@ namespace Bff.Api;
public sealed record SubmitAccepted(string RegistrationId, string Status); public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are /// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). /// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).</summary>
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary> public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder);
public sealed record ProjectionEntry(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary> /// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
public sealed record OpenbaarEntry(string Id, string Status, string? Reference); public sealed record OpenbaarEntry(string Id, string Status);
/// <summary>A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a
/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c).</summary>
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary> /// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
public interface IDomainClient 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>Provide (upload) the diploma the caller's own registration is waiting for ("documenten
/// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. Returns
/// <c>false</c> when the domain reports the registration is unknown or not the caller's (404).</summary>
Task<bool> ProvideDocumentsAsync(
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
/// <summary>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
} }
/// <summary>Port to the read projection.</summary> /// <summary>Port to the read projection.</summary>
@@ -58,40 +36,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status); return new SubmitAccepted(dto.RegistrationId, dto.Status);
} }
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
public async Task<bool> ProvideDocumentsAsync(
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/documents",
new { bsn, contentBase64, fileName, contentType }, ct);
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/decide", new { besluit }, ct);
response.EnsureSuccessStatusCode();
}
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl); private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
} }
+2 -4
View File
@@ -11,10 +11,8 @@ public static class OpenbaarProjection
{ {
var filtered = string.IsNullOrWhiteSpace(q) var filtered = string.IsNullOrWhiteSpace(q)
? entries ? entries
: entries.Where(e => : entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase));
e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) ||
(e.Reference?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status, e.Reference))]; return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))];
} }
} }
+1 -143
View File
@@ -1,6 +1,4 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api; using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -8,10 +6,6 @@ var builder = WebApplication.CreateBuilder(args);
var keycloakAuthority = builder.Configuration["Keycloak:Authority"] var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'"); ?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
// so the BFF validates a second issuer for the behandel endpoints (ADR-0013).
var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'");
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"] var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'"); ?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"] var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
@@ -25,28 +19,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
options.Authority = keycloakAuthority; options.Authority = keycloakAuthority;
options.RequireHttpsMetadata = false; options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false; options.TokenValidationParameters.ValidateAudience = false;
})
// The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles
// (the nested realm_access.roles claim) into role claims so authorization policies can require them.
.AddJwtBearer(BehandelAuth.Scheme, options =>
{
options.Authority = medewerkerAuthority;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
BehandelAuth.AddRealmRoles(context.Principal);
return Task.CompletedTask;
},
};
}); });
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization();
options.AddPolicy(BehandelAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BehandelAuth.BehandelaarRole)));
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3). // The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl)); builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
@@ -86,46 +60,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized); .Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
return withdrawn ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
// is S-10b — this is the trigger that unblocks the process.
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
if (string.IsNullOrWhiteSpace(body?.ContentBase64))
return Results.BadRequest("A document is required.");
var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct);
return provided ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09). // Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) => app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{ {
@@ -134,83 +68,7 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
}) })
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK); .Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm
// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013).
app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) =>
Results.Ok(await domain.GetWerkbakAsync(ct)))
.RequireAuthorization(BehandelAuth.Policy)
.Produces<IReadOnlyList<WerkbakItem>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
app.MapPost("/behandel/registrations/{id}/decide",
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
{
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await domain.DecideAsync(id, body.Besluit, ct);
return Results.NoContent();
})
.RequireAuthorization(BehandelAuth.Policy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run(); app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
public sealed record DecideRequest(string Besluit);
/// <summary>A diploma upload from the self-service portal — the file base64-encoded client-side, with
/// its name and MIME type. The bsn is taken from the DigiD token, not this body.</summary>
public sealed record ProvideDocumentsRequest(string ContentBase64, string? FileName = null, string? ContentType = null);
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
internal static class BehandelAuth
{
public const string Scheme = "medewerker";
public const string Policy = "behandelaar";
public const string BehandelaarRole = "behandelaar";
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
public static bool IsKnownBesluit(string? besluit) =>
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
public static void AddRealmRoles(ClaimsPrincipal? principal)
{
if (principal?.Identity is not ClaimsIdentity identity)
return;
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (string.IsNullOrWhiteSpace(realmAccess))
return;
// A malformed realm_access claim must not fail authentication (a throw here becomes a 401);
// it simply yields no roles, so the authorization policy answers 403.
string[] roles;
try
{
roles = JsonSerializer.Deserialize<RealmAccess>(realmAccess)?.Roles ?? [];
}
catch (JsonException)
{
return;
}
foreach (var role in roles)
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
}
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app. // Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program; public partial class Program;
+1 -2
View File
@@ -7,8 +7,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Keycloak": { "Keycloak": {
"Authority": "http://localhost:8180/realms/digid", "Authority": "http://localhost:8180/realms/digid"
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
}, },
"Downstream": { "Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" }, "Domain": { "BaseUrl": "http://localhost:8130/" },
@@ -1,114 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
/// <summary>
/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the
/// <c>behandelaar</c> role. A missing token is 401; an authenticated medewerker without the role is
/// 403; a behandelaar gets the werkbak (staff view, incl. bsn).
/// </summary>
public class BehandelEndpointTests
{
private static HttpRequestMessage Werkbak(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/behandel/werkbak");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_werkbak_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Werkbak(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_medewerker_without_the_behandelaar_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("teamlead")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Serves_the_werkbak_to_a_behandelaar()
{
using var factory = new BffFactory();
factory.Domain.Werkbak.Add(new WerkbakItem("reg-1", "123456782", "InBehandeling"));
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("behandelaar")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var items = await response.Content.ReadFromJsonAsync<List<WerkbakItem>>();
var item = Assert.Single(items!);
Assert.Equal("reg-1", item.RegistrationId);
Assert.Equal("123456782", item.Bsn);
}
private static HttpRequestMessage Decide(string? bearer, string id = "reg-1", string besluit = "goedkeuren")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/behandel/registrations/{id}/decide")
{
Content = JsonContent.Create(new { besluit }),
};
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_decision_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Decide(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Decided);
}
[Fact]
public async Task Rejects_a_decision_from_a_medewerker_without_the_behandelaar_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Decide(TestTokens.Medewerker("teamlead")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(factory.Domain.Decided);
}
[Fact]
public async Task Forwards_a_behandelaar_decision_to_the_domain()
{
using var factory = new BffFactory();
var response = await factory.CreateClient()
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), id: "reg-42", besluit: "afwijzen"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-42", "afwijzen"), factory.Domain.Decided);
}
[Fact]
public async Task Rejects_an_unknown_besluit_without_calling_the_domain()
{
using var factory = new BffFactory();
var response = await factory.CreateClient()
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), besluit: "misschien"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(factory.Domain.Decided);
}
}
+17 -67
View File
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -24,34 +23,9 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
public FakeDomainClient Domain { get; } = new(); public FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new(); public FakeProjectionClient Projection { get; } = new();
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options =>
{
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
options.Authority = null;
options.MetadataAddress = null!;
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration();
options.ConfigurationManager =
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestSigningKey,
ClockSkew = TimeSpan.Zero,
};
});
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid"); builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/"); builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/"); builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
@@ -60,11 +34,23 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
services.AddSingleton<IDomainClient>(Domain); services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection); services.AddSingleton<IProjectionClient>(Projection);
// Both realms validate locally against the test key (no live Keycloak). The medewerker services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation {
// parameters are swapped here. // Validate locally against the test key; never reach out for OIDC metadata.
ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme); options.Authority = null;
ValidateWithTestKey(services, "medewerker"); options.MetadataAddress = null!;
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration();
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestSigningKey,
ClockSkew = TimeSpan.Zero,
};
});
}); });
} }
} }
@@ -74,48 +60,12 @@ internal sealed class FakeDomainClient : IDomainClient
{ {
public string? SubmittedBsn { get; private set; } public string? SubmittedBsn { get; private set; }
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend"); public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
public List<WerkbakItem> Werkbak { get; } = [];
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default) public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
{ {
SubmittedBsn = bsn; SubmittedBsn = bsn;
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 Bsn, string ContentBase64, string? FileName, string? ContentType)? DocumentsProvidedFor { get; private set; }
/// <summary>Whether the fake domain reports the provide-documents as done (true → 204) or
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary>
public bool ProvideDocumentsSucceeds { get; set; } = true;
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
{
DocumentsProvidedFor = (registrationId, bsn, contentBase64, fileName, contentType);
return Task.FromResult(ProvideDocumentsSucceeds);
}
public (string RegistrationId, string Besluit)? Decided { get; private set; }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
{
Decided = (registrationId, besluit);
return Task.CompletedTask;
}
} }
/// <summary>Serves a configurable set of projection rows.</summary> /// <summary>Serves a configurable set of projection rows.</summary>
@@ -10,7 +10,7 @@ public class OpenbaarEndpointTests
public async Task Serves_public_safe_rows_anonymously() public async Task Serves_public_safe_rows_anonymously()
{ {
using var factory = new BffFactory(); using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", "123456782", "Jan")); factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan"));
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09). // No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
var response = await factory.CreateClient().GetAsync("/openbaar/register"); var response = await factory.CreateClient().GetAsync("/openbaar/register");
@@ -19,9 +19,7 @@ public class OpenbaarEndpointTests
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
Assert.Contains("abc-111", body); Assert.Contains("abc-111", body);
Assert.Contains("INGEDIEND", body); Assert.Contains("INGEDIEND", body);
// The public reference is surfaced (matches the submit confirmation, #78)... // The bsn must never appear in a public response.
Assert.Contains("REG-abc", body);
// ...but the bsn must never appear in a public response.
Assert.DoesNotContain("123456782", body); Assert.DoesNotContain("123456782", body);
} }
@@ -29,8 +27,8 @@ public class OpenbaarEndpointTests
public async Task Filters_by_the_query_parameter() public async Task Filters_by_the_query_parameter()
{ {
using var factory = new BffFactory(); using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", null, null)); factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null));
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", "REG-def", null, null)); factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null));
var rows = await factory.CreateClient() var rows = await factory.CreateClient()
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc"); .GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");
@@ -6,8 +6,8 @@ public class OpenbaarProjectionTests
{ {
private static readonly ProjectionEntry[] Sample = private static readonly ProjectionEntry[] Sample =
[ [
new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"), new("abc-111", "INGEDIEND", "123456782", "Jan"),
new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"), new("def-222", "INGEDIEND", "987654321", "Piet"),
]; ];
[Fact] [Fact]
@@ -18,47 +18,31 @@ public class OpenbaarProjectionTests
Assert.Equal(2, view.Count); Assert.Equal(2, view.Count);
Assert.Equal("abc-111", view[0].Id); Assert.Equal("abc-111", view[0].Id);
Assert.Equal("INGEDIEND", view[0].Status); Assert.Equal("INGEDIEND", view[0].Status);
// The public reference (zaak identificatie) is surfaced so it matches the submit confirmation (#78).
Assert.Equal("REG-A", view[0].Reference);
} }
[Fact] [Fact]
public void Public_view_exposes_only_id_status_and_reference() public void Public_view_exposes_only_id_and_status()
{ {
// OpenbaarEntry structurally carries only public-safe fields — bsn/naam can never leak. // OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak.
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray(); var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
Assert.Equal(["Id", "Status", "Reference"], props); Assert.Equal(["Id", "Status"], props);
} }
[Theory] [Theory]
[InlineData("abc", 1)] // by id [InlineData("abc", 1)]
[InlineData("ABC", 1)] // by id, case-insensitive [InlineData("ABC", 1)]
[InlineData("def", 1)] // by id [InlineData("2", 1)]
[InlineData("zzz", 0)] // no match [InlineData("zzz", 0)]
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected) public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
=> Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count); {
var view = OpenbaarProjection.PublicView(Sample, q);
[Theory] Assert.Equal(expected, view.Count);
[InlineData("REG-A", 1)] // by reference — the citizen's confirmation reference }
[InlineData("reg-a", 1)] // by reference, case-insensitive
[InlineData("REG", 2)] // both share the prefix
public void Filters_by_reference_containing_the_query_case_insensitively(string q, int expected)
=> Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
[Fact] [Fact]
public void Blank_query_is_treated_as_no_filter() public void Blank_query_is_treated_as_no_filter()
{ {
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count); Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
} }
[Fact]
public void A_row_without_a_reference_never_matches_a_query()
{
// Older rows can have a null reference (the column is additive, #78/ADR-0012). A search must
// simply not match them — it must not throw and must not treat "no reference" as a match.
var entries = new[] { new ProjectionEntry("xyz-999", "INGEDIEND", Reference: null, Bsn: null, NaamPlaceholder: null) };
Assert.Empty(OpenbaarProjection.PublicView(entries, "REG"));
Assert.Equal("xyz-999", Assert.Single(OpenbaarProjection.PublicView(entries, "xyz")).Id);
}
} }
@@ -71,102 +71,5 @@ public class SelfServiceEndpointTests
Assert.Equal("reg-123", body!.RegistrationId); Assert.Equal("reg-123", body!.RegistrationId);
} }
private static HttpRequestMessage Withdraw(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/withdraw");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_withdrawal_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Withdrawn);
}
[Fact]
public async Task Withdraws_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.Withdrawn);
}
[Fact]
public async Task Relays_not_found_when_the_registration_is_unknown_or_not_the_callers()
{
using var factory = new BffFactory();
factory.Domain.WithdrawSucceeds = false;
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private static HttpRequestMessage ProvideDocuments(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/documents")
{
// The portal base64-encodes the file client-side and posts it as JSON (S-10b); the bsn is
// never in the body — it comes from the DigiD token.
Content = JsonContent.Create(new
{
contentBase64 = Convert.ToBase64String([1, 2, 3]),
fileName = "diploma.pdf",
contentType = "application/pdf",
}),
};
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_providing_documents_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(ProvideDocuments(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.DocumentsProvidedFor);
}
[Fact]
public async Task Provides_documents_for_the_callers_registration_forwarding_id_bsn_and_file()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
var provided = factory.Domain.DocumentsProvidedFor;
Assert.NotNull(provided);
Assert.Equal("reg-9", provided!.Value.RegistrationId);
Assert.Equal("123456782", provided.Value.Bsn);
Assert.Equal(Convert.ToBase64String([1, 2, 3]), provided.Value.ContentBase64);
Assert.Equal("diploma.pdf", provided.Value.FileName);
}
[Fact]
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
{
using var factory = new BffFactory();
factory.Domain.ProvideDocumentsSucceeds = false;
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status); private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
} }
-16
View File
@@ -17,22 +17,6 @@ internal static class TestTokens
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")), new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
expired: false); expired: false);
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
public static string Medewerker(params string[] roles)
{
var handler = new JsonWebTokenHandler();
return handler.CreateToken(new SecurityTokenDescriptor
{
Claims = new Dictionary<string, object>
{
["realm_access"] = new Dictionary<string, object> { ["roles"] = roles },
},
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256),
});
}
private static string Create(string bsn, SymmetricSecurityKey key, bool expired) private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
{ {
var handler = new JsonWebTokenHandler(); var handler = new JsonWebTokenHandler();
+1 -202
View File
@@ -30,78 +30,6 @@
} }
} }
}, },
"/self-service/registrations/{id}/withdraw": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/self-service/registrations/{id}/documents": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProvideDocumentsRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/openbaar/register": { "/openbaar/register": {
"get": { "get": {
"tags": [ "tags": [
@@ -132,95 +60,14 @@
} }
} }
} }
},
"/behandel/werkbak": {
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WerkbakItem"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/behandel/registrations/{id}/decide": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DecideRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
} }
}, },
"components": { "components": {
"schemas": { "schemas": {
"DecideRequest": {
"required": [
"besluit"
],
"type": "object",
"properties": {
"besluit": {
"type": "string"
}
}
},
"OpenbaarEntry": { "OpenbaarEntry": {
"required": [ "required": [
"id", "id",
"status", "status"
"reference"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -229,35 +76,6 @@
}, },
"status": { "status": {
"type": "string" "type": "string"
},
"reference": {
"type": [
"null",
"string"
]
}
}
},
"ProvideDocumentsRequest": {
"required": [
"contentBase64"
],
"type": "object",
"properties": {
"contentBase64": {
"type": "string"
},
"fileName": {
"type": [
"null",
"string"
]
},
"contentType": {
"type": [
"null",
"string"
]
} }
} }
}, },
@@ -275,25 +93,6 @@
"type": "string" "type": "string"
} }
} }
},
"WerkbakItem": {
"required": [
"registrationId",
"bsn",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"bsn": {
"type": "string"
},
"status": {
"type": "string"
}
}
} }
} }
}, },
+2 -106
View File
@@ -20,31 +20,14 @@ builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
builder.Services.AddHttpClient<FlowableWorkflowClient>(); builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IRegistratieVerlopenClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>(); builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>(); builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<ProvideDocuments>();
builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
builder.Services.AddScoped<ExpireRegistrationWorker>();
builder.Services.AddScoped<RegistratieVerlopenProcessor>();
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion. // The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
builder.Services.AddHostedService<OpenZaakJobPump>(); builder.Services.AddHostedService<OpenZaakJobPump>();
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
// each overdue beoordeling to the teamlead (S-14).
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
// The document-timeout worker polls the RegistratieVerlopen jobs the 30-day timer on WachtOpDocumenten
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
builder.Services.AddHostedService<RegistratieVerlopenPump>();
var app = builder.Build(); var app = builder.Build();
@@ -55,91 +38,10 @@ app.MapGet("/health", () => "Healthy");
// a location to read the registration's progress (ADR-0009, eventual consistency). // a location to read the registration's progress (ADR-0009, eventual consistency).
app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) => app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) =>
{ {
// Diploma origin defaults to domestic; a foreign (eIDAS) submission passes "Buitenlands" so the var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn), ct);
// workflow's DMN routes it through CBGV-advies (S-13). An unknown value is a bad request.
if (!Enum.TryParse<DiplomaOrigin>(body.DiplomaOrigin, ignoreCase: true, out var origin) && body.DiplomaOrigin is not null)
return Results.BadRequest(new { error = $"Unknown diplomaOrigin '{body.DiplomaOrigin}'. Expected 'Binnenlands' or 'Buitenlands'." });
var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn, origin), ct);
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null)); return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
}); });
// Temporary admin endpoint (S-09b): approve a registration — the behandelaar's decision, until the
// behandel-portal exists (S-12). The zaak's final status is set via the ACL, which flows back over
// NRC to the projection, making the entry publicly visible as INGESCHREVEN. Idempotent.
app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration approve, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
await approve.HandleAsync(new ApproveRegistrationCommand(new RegistrationId(guid)), ct);
return Results.NoContent();
});
// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the
// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract
// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above,
// which is retired once the portal lands.
app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (!Enum.TryParse<BeoordelingsBesluit>(body.Besluit, ignoreCase: true, out var besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct);
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();
});
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process.
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn))
return Results.BadRequest(new { error = "A bsn is required to provide documents." });
if (string.IsNullOrWhiteSpace(body.ContentBase64))
return Results.BadRequest(new { error = "A document is required." });
byte[] content;
try { content = Convert.FromBase64String(body.ContentBase64); }
catch (FormatException) { return Results.BadRequest(new { error = "The document content is not valid base64." }); }
var command = new ProvideDocumentsCommand(
new RegistrationId(guid), body.Bsn, content,
body.FileName ?? "diploma.pdf", body.ContentType ?? "application/pdf");
var outcome = await provide.HandleAsync(command, ct);
return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound();
});
// 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
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
Results.Ok(await werkbak.GetAsync(ct)));
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{ {
@@ -155,13 +57,7 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca
await app.RunAsync(); await app.RunAsync();
public sealed record SubmitRegistrationRequest(string Bsn, string? DiplomaOrigin = null); public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit);
public sealed record WithdrawRequest(string Bsn);
public sealed record ProvideDocumentsRequest(string Bsn, string ContentBase64, string? FileName = null, string? ContentType = null);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
@@ -1,36 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A behandelaar's decision to approve a registration, in domain language.</summary>
public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId);
/// <summary>
/// The approve use case (S-09b): set the registration's zaak to its final status via the ACL (§8.1),
/// then advance the aggregate to INGESCHREVEN. Idempotent — a redelivered or repeated approval of an
/// already-approved registration is a no-op, so the ACL is not asked to set the status twice. The
/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
/// keeps the domain's own view consistent.
/// </summary>
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl)
{
public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to approve.");
// A repeated approval is a no-op: don't set the zaak status a second time.
if (registration.Status == RegistrationStatus.Ingeschreven)
return;
if (registration.ZaakUrl is null)
throw new InvalidOperationException(
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve();
await store.SaveAsync(registration, ct);
}
}
@@ -1,71 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A behandelaar's beoordeling outcome, in domain language.</summary>
public enum BeoordelingsBesluit
{
/// <summary>Approve — enter the registration in the register.</summary>
Goedkeuren,
/// <summary>Reject — turn the registration down.</summary>
Afwijzen,
}
/// <summary>A behandelaar's decision on a registration.</summary>
public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, BeoordelingsBesluit Besluit);
/// <summary>
/// The beoordeling use case (S-12): apply a behandelaar's decision to a registration.
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
/// it, is a later sub-slice of S-12). After applying the decision it completes the Flowable
/// <c>Beoordelen</c> task (found by registrationId) so the workflow advances (ADR-0013). Both
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
/// state is a no-op, so the ACL is not called and the task not completed twice.
/// </summary>
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
{
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to decide.");
switch (command.Besluit)
{
case BeoordelingsBesluit.Goedkeuren:
// A repeated approval is a no-op: don't set the zaak status a second time.
if (registration.Status == RegistrationStatus.Ingeschreven)
return;
if (registration.ZaakUrl is null)
throw new InvalidOperationException(
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve();
break;
case BeoordelingsBesluit.Afwijzen:
if (registration.Status == RegistrationStatus.Afgewezen)
return;
registration.Reject();
break;
}
await store.SaveAsync(registration, ct);
await CompleteWorkflowTaskAsync(command.RegistrationId, command.Besluit, ct);
}
// Advance the workflow: complete the open Beoordelen task for this registration. If none is open
// (already completed, or the process hasn't parked yet) the decision still stands — we complete
// nothing rather than fail.
private async Task CompleteWorkflowTaskAsync(RegistrationId registrationId, BeoordelingsBesluit besluit, CancellationToken ct)
{
var open = await tasks.GetOpenBeoordelingenAsync(ct);
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
if (task is not null)
await tasks.CompleteBeoordelingAsync(task.TaskId, besluit, ct);
}
}
@@ -1,45 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>
/// Handles one acquired <c>RegistratieVerlopen</c> external-worker job (S-10a, ADR-0017): load the
/// registration the job correlates to and expire it to VERLOPEN — the 30-day document-wait timer fired
/// before the documents arrived, so the case is cancelled. Pure application logic over ports; it knows
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
/// <see cref="OpenZaakWorker"/>.
/// </summary>
public sealed class ExpireRegistrationWorker(IRegistrationStore store, IAclClient acl)
{
/// <summary>
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
/// registration is already resolved — a redelivered expiry (VERLOPEN), or one withdrawn/decided
/// while it waited (INGETROKKEN/INGESCHREVEN/AFGEWEZEN) — is a no-op, so the job still completes
/// rather than throwing into a redelivery loop. Only a still-open registration is expired. An
/// unknown registration is an error: it throws, leaving the job un-completed for Flowable to redeliver.
/// </summary>
public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(job);
var registration = await store.GetAsync(job.RegistrationId, ct)
?? throw new InvalidOperationException(
$"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}.");
// Only a still-open registration lapses; an already-resolved one (expired, or withdrawn/decided
// while it waited) is left untouched so the job can complete without violating the aggregate.
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
return;
// Cancel the ZGW zaak before advancing the aggregate (mirrors the approval path): if the ACL
// call fails it throws, the aggregate stays open, and the job is redelivered (§8.6) — rather
// than leaving the aggregate VERLOPEN while the zaak stays open. The status guard above stops a
// redelivered job from cancelling the zaak twice (a second resultaat would be a 400). A
// registration expired before its zaak was opened has nothing to cancel.
if (registration.ZaakUrl is not null)
await acl.CancelZaakAsync(registration.ZaakUrl, ct);
registration.Expire();
await store.SaveAsync(registration, ct);
}
}
@@ -28,7 +28,7 @@ public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl)
if (registration.ZaakUrl is not null) if (registration.ZaakUrl is not null)
return registration.ZaakUrl; return registration.ZaakUrl;
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, registration.Id.ToString(), ct); var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct);
registration.AttachZaak(zaakUrl); registration.AttachZaak(zaakUrl);
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
return zaakUrl; return zaakUrl;
+4 -83
View File
@@ -11,28 +11,10 @@ public interface IWorkflowClient
{ {
/// <summary> /// <summary>
/// Start one <c>registratie</c> process instance for the given registration, carrying the /// Start one <c>registratie</c> process instance for the given registration, carrying the
/// registration id (so the <c>OpenZaakAanmaken</c> external task can be correlated back to its /// registration id so the <c>OpenZaakAanmaken</c> external task can be correlated back to its
/// aggregate) and the diploma origin (so the workflow's DMN can route foreign diplomas through /// aggregate. Returns the process instance id.
/// CBGV-advies, S-13). Returns the process instance id.
/// </summary> /// </summary>
Task<string> StartRegistrationProcessAsync( Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default);
/// <summary>
/// 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>
/// Signal that the required documents have arrived (S-10a): complete the <c>WachtOpDocumenten</c>
/// user task in the instance so the process leaves the 30-day wait state and continues to
/// beoordeling (ADR-0017). Best-effort — if the instance is not parked at that task (already
/// continued, or timed out) it is a no-op. The upload trigger that calls this is wired in S-10b.
/// </summary>
Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -42,55 +24,9 @@ public interface IWorkflowClient
/// </summary> /// </summary>
public interface IAclClient public interface IAclClient
{ {
/// <summary>Open a zaak for the registration. <paramref name="reference"/> is the registration's Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default);
/// own reference (its id); the ACL records it as the zaak's identificatie so the openbaar register
/// can show the same reference the zorgprofessional was given (S-09b follow-up, adr-proposal #78).</summary>
Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default);
/// <summary>
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting
/// the zaak's final status — which OpenZaak notifies over NRC; the domain never names statustypen.
/// </summary>
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
/// <summary>
/// Store an uploaded diploma against the zaak (S-10b). The domain hands over the zaak, the raw file
/// bytes, and the file's name/type; the ACL creates the ZGW informatieobject and relates it to the
/// zaak (§8.1). Returns the stored document's URL.
/// </summary>
Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default);
/// <summary>
/// Cancel the zaak on document-timeout expiry (S-10c): the 30-day document term lapsed, so the ACL
/// translates this to the ZGW cancellation status/resultaat. The domain never names statustypen.
/// </summary>
Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default);
} }
/// <summary>
/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the
/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open
/// <c>Beoordelen</c> tasks (the werkbak), claims one for a behandelaar, and completes it with the
/// decision; it never names Flowable's REST shapes.
/// </summary>
public interface IUserTaskClient
{
/// <summary>The werkbak: the <c>Beoordelen</c> tasks awaiting a behandelaar, each with the
/// registration it belongs to.</summary>
Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default);
/// <summary>Claim a beoordeling task for a behandelaar (assigns it to them).</summary>
Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default);
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
}
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
/// complete it) and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
/// <summary> /// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice /// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive. /// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive.
@@ -109,18 +45,3 @@ public interface IRegistrationStore
/// it) and the registration id it carries as a process variable. /// it) and the registration id it carries as a process variable.
/// </summary> /// </summary>
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId); public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
/// <summary>
/// An acquired <c>BeoordelingEscaleren</c> escalation job (S-14): the Flowable job id and the process
/// instance whose still-open <c>Beoordelen</c> task must be reassigned from behandelaar to teamlead
/// once the 14-day boundary timer fires (ADR-0015).
/// </summary>
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
/// <summary>
/// An acquired <c>RegistratieVerlopen</c> job (S-10a): the Flowable job id and the registration id it
/// carries as a process variable. The 30-day boundary timer on <c>WachtOpDocumenten</c> spawns it when
/// the required documents were not supplied in time; expiring the correlated registration to VERLOPEN
/// cancels the case (ADR-0017).
/// </summary>
public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId);
@@ -1,54 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's upload of the diploma their registration is waiting for ("documenten
/// aanleveren"). <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by
/// the BFF): only the registration's own bsn may provide its documents. <paramref name="Content"/> is
/// the raw file, with its <paramref name="FileName"/> and <paramref name="ContentType"/>.</summary>
public sealed record ProvideDocumentsCommand(
RegistrationId RegistrationId, string Bsn, byte[] Content, string FileName, string ContentType);
/// <summary>The outcome of a provide-documents request.</summary>
public enum ProvideDocumentsOutcome
{
/// <summary>The documents were accepted; the process's document wait was completed (if any).</summary>
Accepted,
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone else
/// (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
}
/// <summary>
/// The provide-documents use case (S-10a/S-10b): a zorgprofessional uploads the diploma their
/// registration is parked waiting for. The document is stored in ZGW via the ACL (§8.1), then the
/// WachtOpDocumenten task is completed so the registratie process leaves the 30-day wait and continues
/// to beoordeling (ADR-0017). Owner-scoped by bsn. Both steps are best-effort about missing preconditions
/// (mirroring <see cref="WithdrawRegistration"/>): storage needs an opened zaak, and completion needs a
/// running process — a request that arrives before either still stands, storing/completing what it can.
/// </summary>
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl)
{
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct);
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
if (registration is null || registration.Bsn != command.Bsn)
return ProvideDocumentsOutcome.NotFound;
// Store the diploma against the zaak (once it is opened) — the ACL is the only ZGW caller (§8.1).
if (registration.ZaakUrl is not null)
await acl.StoreDiplomaAsync(
registration.ZaakUrl, command.Content, command.FileName, command.ContentType, ct);
// Complete the document wait (if a process is running) so beoordeling can proceed.
if (registration.ProcessInstanceId is not null)
await workflow.CompleteDocumentWaitAsync(registration.ProcessInstanceId, ct);
return ProvideDocumentsOutcome.Accepted;
}
}
@@ -2,10 +2,8 @@ using Big.Domain;
namespace Big.Application; namespace Big.Application;
/// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts. The /// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts.</summary>
/// diploma origin defaults to domestic (the DigiD path); a foreign (eIDAS) submission sets it to public sealed record SubmitRegistrationCommand(string Bsn);
/// <see cref="DiplomaOrigin.Buitenlands"/> so the workflow's DMN routes it through CBGV-advies (S-13).</summary>
public sealed record SubmitRegistrationCommand(string Bsn, DiplomaOrigin DiplomaOrigin = DiplomaOrigin.Binnenlands);
/// <summary> /// <summary>
/// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it, /// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it,
@@ -20,14 +18,13 @@ public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient
{ {
ArgumentNullException.ThrowIfNull(command); ArgumentNullException.ThrowIfNull(command);
var registration = Registration.Submit(command.Bsn, command.DiplomaOrigin); var registration = Registration.Submit(command.Bsn);
// Persist before starting the process so the worker can correlate the OpenZaakAanmaken // Persist before starting the process so the worker can correlate the OpenZaakAanmaken
// job back to an aggregate that already exists (ADR-0009). // job back to an aggregate that already exists (ADR-0009).
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
var processInstanceId = await workflow.StartRegistrationProcessAsync( var processInstanceId = await workflow.StartRegistrationProcessAsync(registration.Id, ct);
registration.Id, registration.DiplomaOrigin, ct);
registration.RecordProcessStarted(processInstanceId); registration.RecordProcessStarted(processInstanceId);
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
@@ -1,40 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
/// public-facing reference (its id) plus the bsn and status a behandelaar needs to triage it.</summary>
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
/// <summary>
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
/// whose registration the domain doesn't know, or whose registration is no longer open for beoordeling
/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped.
/// </summary>
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
{
public async Task<IReadOnlyList<WerkbakItem>> GetAsync(CancellationToken ct = default)
{
var open = await tasks.GetOpenBeoordelingenAsync(ct);
var items = new List<WerkbakItem>(open.Count);
foreach (var task in open)
{
var registration = await store.GetAsync(task.RegistrationId, ct);
if (registration is null || !IsOpenForBeoordeling(registration.Status))
continue;
items.Add(new WerkbakItem(
registration.Id.ToString(), registration.Bsn, registration.Status.ToString()));
}
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;
}
@@ -1,55 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
/// only the registration's own bsn may withdraw it.</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a withdrawal request.</summary>
public enum WithdrawOutcome
{
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
Withdrawn,
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone
/// else (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
}
/// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
{
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct);
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
if (registration is null || registration.Bsn != command.Bsn)
return WithdrawOutcome.NotFound;
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken)
return WithdrawOutcome.Withdrawn;
registration.Withdraw();
await store.SaveAsync(registration, ct);
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
if (registration.ProcessInstanceId is not null)
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
return WithdrawOutcome.Withdrawn;
}
}
@@ -1,17 +0,0 @@
namespace Big.Domain;
/// <summary>
/// Where a zorgprofessional's diploma was issued. It is the input to the diploma-eligibility decision
/// (S-13): a <see cref="Buitenlands"/> (foreign) diploma routes the registratie through an extra
/// CBGV-advies assessment step, a <see cref="Binnenlands"/> (domestic) one goes straight to beoordeling.
/// The decision itself lives in the workflow's DMN, not here (ADR-0016); the domain only carries the
/// origin and hands it to the process as a start variable.
/// </summary>
public enum DiplomaOrigin
{
/// <summary>A Dutch (domestic) diploma. Default for a registration submitted via DigiD.</summary>
Binnenlands,
/// <summary>A foreign diploma (e.g. an eIDAS submission). Triggers the CBGV-advies step.</summary>
Buitenlands,
}
+4 -97
View File
@@ -7,11 +7,10 @@ namespace Big.Domain;
/// </summary> /// </summary>
public sealed class Registration public sealed class Registration
{ {
private Registration(RegistrationId id, string bsn, DiplomaOrigin diplomaOrigin) private Registration(RegistrationId id, string bsn)
{ {
Id = id; Id = id;
Bsn = bsn; Bsn = bsn;
DiplomaOrigin = diplomaOrigin;
Status = RegistrationStatus.Ingediend; Status = RegistrationStatus.Ingediend;
} }
@@ -21,10 +20,6 @@ public sealed class Registration
/// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary> /// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary>
public string Bsn { get; } public string Bsn { get; }
/// <summary>Where the diploma was issued. Rides along to the process as a start variable and
/// drives the diploma-eligibility DMN's foreign→CBGV-advies routing (S-13, ADR-0016).</summary>
public DiplomaOrigin DiplomaOrigin { get; }
public RegistrationStatus Status { get; private set; } public RegistrationStatus Status { get; private set; }
/// <summary>The Flowable process instance driving this registration, once started.</summary> /// <summary>The Flowable process instance driving this registration, once started.</summary>
@@ -33,13 +28,11 @@ public sealed class Registration
/// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary> /// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary>
public Uri? ZaakUrl { get; private set; } public Uri? ZaakUrl { get; private set; }
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>. /// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.</summary>
/// The diploma origin defaults to <see cref="DiplomaOrigin.Binnenlands"/> — the common DigiD path; public static Registration Submit(string bsn)
/// a foreign (eIDAS) submission passes <see cref="DiplomaOrigin.Buitenlands"/>.</summary>
public static Registration Submit(string bsn, DiplomaOrigin diplomaOrigin = DiplomaOrigin.Binnenlands)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(bsn); ArgumentException.ThrowIfNullOrWhiteSpace(bsn);
return new Registration(RegistrationId.New(), bsn, diplomaOrigin); return new Registration(RegistrationId.New(), bsn);
} }
/// <summary>Record that the registratie workflow process has been started for this registration.</summary> /// <summary>Record that the registratie workflow process has been started for this registration.</summary>
@@ -71,90 +64,4 @@ public sealed class Registration
ZaakUrl = zaakUrl; ZaakUrl = zaakUrl;
} }
/// <summary>
/// A behandelaar picks the registration up for beoordeling. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
/// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
/// </summary>
public void TakeIntoBehandeling()
{
if (Status == RegistrationStatus.InBehandeling)
return;
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
Status = RegistrationStatus.InBehandeling;
}
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
/// has already been decided cannot be approved again.
/// </summary>
public void Approve()
{
if (ZaakUrl is null)
throw new InvalidOperationException(
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
RequireOpenForDecision(nameof(Approve));
Status = RegistrationStatus.Ingeschreven;
}
/// <summary>
/// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Afgewezen"/>. Unlike
/// approval this needs no zaak: a registration can be rejected before or after its zaak is opened.
/// A registration that has already been decided cannot be rejected again.
/// </summary>
public void Reject()
{
RequireOpenForDecision(nameof(Reject));
Status = RegistrationStatus.Afgewezen;
}
/// <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;
}
/// <summary>
/// Expire the registration — the 30-day document-wait timer fired before the required documents
/// were supplied, so the registratie process cancels the case (S-10a). Allowed while it is still
/// open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a decided (INGESCHREVEN/AFGEWEZEN) or
/// withdrawn (INGETROKKEN) registration can no longer expire. Re-expiring one already
/// <see cref="RegistrationStatus.Verlopen"/> is a no-op — the worker job may be redelivered (§8.6).
/// </summary>
public void Expire()
{
if (Status == RegistrationStatus.Verlopen)
return;
RequireOpenForDecision(nameof(Expire));
Status = RegistrationStatus.Verlopen;
}
// A decision (or withdrawal, or expiry) is only valid while the registration is still open
// (INGEDIEND or IN_BEHANDELING).
private void RequireOpenForDecision(string decision)
{
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision}).");
}
} }
@@ -1,28 +1,10 @@
namespace Big.Domain; 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. The walking
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one /// skeleton knows only <see cref="Ingediend"/>; withdrawal, beoordeling and herregistratie
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected). /// states arrive in their own slices (Iteration 2+).</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>
Ingediend, Ingediend,
/// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
InBehandeling,
/// <summary>Approved: entered in the register. Terminal.</summary>
Ingeschreven,
/// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen,
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
Ingetrokken,
/// <summary>Lapsed: the required documents were not supplied within the 30-day window, so the
/// registratie process cancelled the case (S-10a). Terminal.</summary>
Verlopen,
} }
@@ -11,10 +11,10 @@ namespace Big.Infrastructure;
/// </summary> /// </summary>
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
{ {
public async Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default) public async Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
{ {
using var response = await http.PostAsJsonAsync( using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn, reference), ct); new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct) var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
@@ -22,57 +22,7 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
return new Uri(opened.ZaakUrl); return new Uri(opened.ZaakUrl);
} }
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "statussen"), new SetStatusRequest(zaakUrl.ToString()), ct);
response.EnsureSuccessStatusCode();
}
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "annuleringen"), new CancelZaakRequest(zaakUrl.ToString()), ct);
response.EnsureSuccessStatusCode();
}
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(content);
// The file crosses this boundary base64-encoded in JSON — the domain and ACL contracts are
// JSON, and a diploma is small (S-10b, ADR). The ACL turns it into a ZGW informatieobject.
using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "documenten"),
new StoreDocumentRequest(zaakUrl.ToString(), Convert.ToBase64String(content), fileName, contentType), ct);
response.EnsureSuccessStatusCode();
var stored = await response.Content.ReadFromJsonAsync<StoreDocumentResponse>(ct)
?? throw new InvalidOperationException("The ACL returned an empty document response.");
return new Uri(stored.InformatieobjectUrl);
}
private sealed record OpenZaakRequest(
[property: JsonPropertyName("bsn")] string Bsn,
[property: JsonPropertyName("reference")] string Reference);
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record CancelZaakRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record StoreDocumentRequest(
[property: JsonPropertyName("zaakUrl")] string ZaakUrl,
[property: JsonPropertyName("contentBase64")] string ContentBase64,
[property: JsonPropertyName("fileName")] string FileName,
[property: JsonPropertyName("contentType")] string ContentType);
private sealed record StoreDocumentResponse(
[property: JsonPropertyName("informatieobjectUrl")] string InformatieobjectUrl);
} }
@@ -1,37 +0,0 @@
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// One poll tick of the beoordeling-escalation worker (S-14, ADR-0015): acquire the parked
/// <c>BeoordelingEscaleren</c> jobs — the tokens the 14-day boundary timer on <c>Beoordelen</c> spawns
/// — reassign each instance's still-open <c>Beoordelen</c> task to the teamlead, and complete the job.
/// A job that fails is logged and left un-completed so Flowable redelivers it (§8.6). Split out from
/// the hosted pump so the acquire→reassign→complete logic is unit-testable without a running host.
/// </summary>
public sealed class BeoordelingEscalatieProcessor(
IBeoordelingEscalatieClient client,
ILogger<BeoordelingEscalatieProcessor> logger)
{
/// <summary>Acquire and process up to <paramref name="maxJobs"/> escalations. Returns the number acquired.</summary>
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
{
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(maxJobs, ct);
foreach (var job in jobs)
{
try
{
await client.ReassignBeoordelingToTeamleadAsync(job.ProcessInstanceId, ct);
await client.CompleteBeoordelingEscalatieJobAsync(job.JobId, ct);
}
catch (Exception ex)
{
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
logger.LogError(ex, "BeoordelingEscaleren job {JobId} failed; leaving it for redelivery.", job.JobId);
}
}
return jobs.Count;
}
}
@@ -1,49 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// The hosted polling loop of the beoordeling-escalation worker (S-14, ADR-0015): on an interval it
/// resolves a scoped <see cref="BeoordelingEscalatieProcessor"/> and asks it to drain the parked
/// <c>BeoordelingEscaleren</c> jobs. A deliberately thin shell — all acquire/reassign/complete logic
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
/// and loop resilience. Structurally identical to <see cref="OpenZaakJobPump"/>.
/// </summary>
public sealed class BeoordelingEscalatiePump(
IServiceScopeFactory scopeFactory,
FlowableOptions options,
ILogger<BeoordelingEscalatiePump> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<BeoordelingEscalatieProcessor>();
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
logger.LogError(ex, "BeoordelingEscaleren job poll failed; retrying after the poll interval.");
}
try
{
await Task.Delay(options.PollInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
@@ -15,31 +15,18 @@ namespace Big.Infrastructure;
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
/// </summary> /// </summary>
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient, IRegistratieVerlopenClient : IWorkflowClient, IExternalWorkerClient
{ {
private const string Topic = "OpenZaakAanmaken"; private const string Topic = "OpenZaakAanmaken";
private const string EscalatieTopic = "BeoordelingEscaleren";
private const string VerlopenTopic = "RegistratieVerlopen";
private const string ProcessDefinitionKey = "registratie"; private const string ProcessDefinitionKey = "registratie";
private const string BeoordelenTaskKey = "Beoordelen";
private const string WachtOpDocumentenTaskKey = "WachtOpDocumenten";
private const string BehandelaarGroup = "behandelaar";
private const string TeamleadGroup = "teamlead";
private const string RegistrationIdVariable = "registrationId"; private const string RegistrationIdVariable = "registrationId";
private const string DiplomaOriginVariable = "diplomaOrigin";
private const string ZaakUrlVariable = "zaakUrl"; private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit";
private const string IngetrokkenMessage = "RegistratieIngetrokken";
public async Task<string> StartRegistrationProcessAsync( public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{ {
var request = new StartProcessRequest( var request = new StartProcessRequest(
ProcessDefinitionKey, ProcessDefinitionKey,
[ [new Variable(RegistrationIdVariable, "string", registrationId.ToString())]);
new Variable(RegistrationIdVariable, "string", registrationId.ToString()),
new Variable(DiplomaOriginVariable, "string", diplomaOrigin.ToString()),
]);
var created = await PostAsync<StartProcessRequest, ProcessInstance>( var created = await PostAsync<StartProcessRequest, ProcessInstance>(
"service/runtime/process-instances", request, ct) "service/runtime/process-instances", request, ct)
@@ -68,148 +55,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
{
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
"service/query/tasks", request, ct);
var tasks = page?.Data ?? [];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
}
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
{
using var response = await SendAsync(
$"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct);
response.EnsureSuccessStatusCode();
}
public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
{
var request = new CompleteTaskRequest(
"complete",
[new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]);
using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct);
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();
}
public async Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
{
// Find the still-open WachtOpDocumenten task in this instance and complete it, so the process
// leaves the 30-day wait and continues to beoordeling (S-10a, ADR-0017). If the instance is no
// longer parked there (already continued, or the timer already cancelled it) this is a
// best-effort no-op — mirroring the withdrawal/escalation correlation (§8.6).
var query = new TaskByInstanceQueryRequest(processInstanceId, WachtOpDocumentenTaskKey);
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
"service/query/tasks", query, ct);
var task = page?.Data?.FirstOrDefault();
if (task is null)
return;
using var response = await SendAsync(
$"service/runtime/tasks/{task.Id}", new CompleteTaskRequest("complete", []), ct);
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
{
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredEscalatieJob>>(
"external-job-api/acquire/jobs", request, ct) ?? [];
return [.. jobs.Select(job => new EscalatieJob(job.Id, job.ProcessInstanceId))];
}
public async Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
// The escalation token runs in parallel to the still-open Beoordelen task (non-interrupting
// boundary timer); find that task in this instance so we can move it to the teamlead. If the
// behandelaar completed it just before the timer fired there is nothing to reassign — a
// best-effort no-op (the timer/completion race, cf. §8.6).
var query = new TaskByInstanceQueryRequest(processInstanceId, BeoordelenTaskKey);
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
"service/query/tasks", query, ct);
var task = page?.Data?.FirstOrDefault();
if (task is null)
return;
// Add teamlead, then drop behandelaar: the task now belongs to the teamlead group.
using (var added = await SendAsync(
$"service/runtime/tasks/{task.Id}/identitylinks",
new IdentityLinkRequest(TeamleadGroup, "candidate"), ct))
added.EnsureSuccessStatusCode();
await DeleteAsync(
$"service/runtime/tasks/{task.Id}/identitylinks/groups/{BehandelaarGroup}/candidate", ct);
}
public async Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
using var response = await SendAsync(
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
{
var request = new AcquireJobsRequest(VerlopenTopic, options.LockDuration, maxJobs, options.WorkerId);
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredJob>>(
"external-job-api/acquire/jobs", request, ct) ?? [];
return [.. jobs.Select(job => new RegistratieVerlopenJob(job.Id, RegistrationId.Parse(job.RegistrationId())))];
}
public async Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
{
using var response = await SendAsync(
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{
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 DeleteAsync(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Delete, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct) private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
{ {
using var response = await SendAsync(path, body, ct); using var response = await SendAsync(path, body, ct);
@@ -217,9 +62,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
return await response.Content.ReadFromJsonAsync<TResponse>(ct); return await response.Content.ReadFromJsonAsync<TResponse>(ct);
} }
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct, HttpMethod? method = null) private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
{ {
var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path)) var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{ {
Content = JsonContent.Create(body), Content = JsonContent.Create(body),
}; };
@@ -244,46 +89,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("workerId")] string WorkerId, [property: JsonPropertyName("workerId")] string WorkerId,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables); [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record TaskQueryRequest(
[property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey,
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
private sealed record TaskByInstanceQueryRequest(
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId,
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey);
private sealed record IdentityLinkRequest(
[property: JsonPropertyName("group")] string Group,
[property: JsonPropertyName("type")] string Type);
private sealed record ClaimTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("assignee")] string Assignee);
private sealed record CompleteTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record MessageEventRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("messageName")] string MessageName);
private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
private sealed record UserTaskDto(
[property: JsonPropertyName("id")] string Id,
// Flowable's task-query returns the (included) process variables under "variables", not
// "processVariables"; the request opts in via includeProcessVariables.
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
{
/// <summary>The registration id this task carries as a process variable.</summary>
public string RegistrationId() =>
Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value
?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable.");
}
private sealed record Variable( private sealed record Variable(
[property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("type")] string Type,
@@ -291,15 +96,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
private sealed record ExecutionQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredEscalatieJob(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId);
private sealed record AcquiredJob( private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables) [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)

Some files were not shown because too many files have changed in this diff Show More