Compare commits

..

3 Commits

Author SHA1 Message Date
1c185e6686 S-09b: Approval flow — temp admin endpoint + status transition to projection (#77)
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build (push) Successful in 1m13s
CI / unit (push) Successful in 1m26s
CI / frontend (push) Successful in 2m35s
CI / mutation (push) Successful in 6m6s
CI / verify-stack (push) Successful in 7m34s
## What & why

S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar.

## Changes (bottom-up, each red→green TDD)

- **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`.
- **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test.
- **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status.
- **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN.
- **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note.

## Key decisions (see ADR-0011)

- **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant.
- **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+).

## Verification

- All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean.
- No new compose config (the eindstatus-discovery approach avoided it).
- The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally).

closes #75

Reviewed-on: #77
2026-07-14 09:04:57 +00:00
bc9831c113 S-09: Openbaar Register portal — public lookup (#76)
All checks were successful
CI / lint (push) Successful in 1m9s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m57s
CI / mutation (push) Successful in 5m19s
CI / verify-stack (push) Successful in 6m31s
Anonymous openbaar portal completing the walking skeleton (submit → projection → public visibility).

closes #10
2026-07-13 14:35:34 +00:00
7e8c5d7b51 Merge pull request 'ci: speed up pipeline — NuGet cache + prebuilt Playwright image' (#74) from chore/73-ci-speedups into main
All checks were successful
CI / lint (push) Successful in 1m13s
CI / build (push) Successful in 57s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m46s
CI / mutation (push) Successful in 4m5s
CI / verify-stack (push) Successful in 6m22s
Reviewed-on: #74
2026-07-13 13:59:15 +00:00
60 changed files with 1640 additions and 37 deletions

View File

@@ -162,20 +162,36 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
**Out of scope (whole of S-08):** document upload, status tracking page. **Out of scope (whole of S-08):** document upload, status tracking page.
### S-09 · Openbaar Register portal — public lookup ### S-09 · Openbaar Register portal — public lookup *(#10)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Confirms the walking skeleton end-to-end. **Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Shows the public-visibility half of the walking skeleton.
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
**Acceptance:** **Acceptance:**
- E2E test: zorgprofessional registers via self-service (S-08), behandelaar approves via a temporary admin endpoint (no behandel-portal yet), openbaar register shows the entry. - E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
- Public-safe field whitelist enforced and tested. - Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
**Touches:** `apps/openbaar/`, projection-api hardening, tests. **Touches:** `apps/openbaar/`, compose serving, e2e, docs.
**Out of scope:** advanced search filters, sorting. **Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
**End of walking skeleton.** Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases. ### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
**Outcome:** A behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval transitions the zaak status through the ACL → NRC → event-subscriber → projection, and the openbaar register then shows the entry as approved.
**Acceptance:**
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
- Temporary admin approve endpoint transitions a registration via a real ZGW status set (behind the ACL, §8).
- E2E: register (S-08) → approve → openbaar shows the entry as approved.
**Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
**Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
**End of walking skeleton** (S-09 + S-09b). Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
--- ---

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 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

21
apps/openbaar/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Multi-stage build for the openbaar 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/openbaar apps/openbaar
COPY libs libs
RUN pnpm nx build openbaar
FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
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).
EXPOSE 80

View File

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

23
apps/openbaar/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
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 anonymous openbaar 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.
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,80 @@
{
"name": "openbaar",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/openbaar/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/openbaar",
"browser": "apps/openbaar/src/main.ts",
"tsConfig": "apps/openbaar/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/openbaar/public"
}
],
"styles": ["apps/openbaar/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": "openbaar:build:production"
},
"development": {
"buildTarget": "openbaar: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": "openbaar:build",
"staticFilePath": "dist/apps/openbaar/browser",
"spa": true
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,19 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
/**
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
],
};

View File

View File

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

View File

@@ -0,0 +1,4 @@
import { Route } from '@angular/router';
import { RegisterPage } from './register/register-page';
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];

View File

@@ -0,0 +1,14 @@
import { provideRouter } from '@angular/router';
import { render } 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 RegisterPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
});
});

View File

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

View File

@@ -0,0 +1,54 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
<p utrecht-paragraph>
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
</p>
<div role="search">
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
<input
id="register-search"
type="search"
utrecht-textbox
[ngModel]="query()"
(ngModelChange)="query.set($event)"
[ngModelOptions]="{ standalone: true }"
(keyup.enter)="search()"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="loading()"
(click)="search()"
>
Zoeken
</button>
</div>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (searched() && entries().length === 0) {
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
} @else if (entries().length > 0) {
<table utrecht-table>
<caption>Inschrijvingen in het openbaar register</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@for (entry of entries(); track entry.id) {
<tr>
<td>{{ entry.id }}</td>
<td>{{ entry.status }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>

View File

@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND' },
{ id: 'zaak-def', status: 'INGESCHREVEN' },
];
function providers(get = vi.fn().mockReturnValue(of(sample))) {
return {
get,
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
};
}
describe('RegisterPage', () => {
it('lists the public register entries from the BFF on open', async () => {
const { get } = providers();
await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled();
expect(await screen.findByText(/zaak-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/zaak-def/)).toBeTruthy();
});
it('searches by the entered term', async () => {
const get = vi.fn().mockReturnValue(of(sample));
await render(RegisterPage, { providers: providers(get).providers });
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
});
it('shows an empty-state message when the register has no matches', async () => {
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
await render(RegisterPage, { providers: providers(get).providers });
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(RegisterPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});

View File

@@ -0,0 +1,45 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
*/
@Component({
selector: 'app-register-page',
imports: [FormsModule, UtrechtComponentsModule],
templateUrl: './register-page.html',
})
export class RegisterPage {
private readonly bff = inject(BffApiV1Service);
protected readonly query = signal('');
protected readonly entries = signal<OpenbaarEntry[]>([]);
protected readonly loading = signal(false);
protected readonly searched = signal(false);
constructor() {
// Show the full register on open; the search box narrows it.
this.search();
}
search(): void {
const q = this.query().trim();
this.loading.set(true);
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
next: (rows: OpenbaarEntry[]) => {
this.entries.set(rows);
this.loading.set(false);
this.searched.set(true);
},
error: () => {
this.entries.set([]);
this.loading.set(false);
this.searched.set(true);
},
});
}
}

View File

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

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
# 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.

View File

@@ -168,3 +168,62 @@ curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and > `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice. > the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
---
## S-09 — Openbaar Register portal (public visibility)
**Outcome:** the entry a zorgprofessional submits via self-service becomes publicly visible in the
anonymous openbaar register portal — closing the walking-skeleton loop (submit → process → projection
→ public visibility).
**The path:** self-service submit → BFF → domain → (zaak) OpenZaak → NRC → Event Subscriber →
projection → openbaar portal reads the BFF's public-safe `GET /openbaar/register`.
```bash
# 1. Bring the full stack up (self-service :8140, openbaar :8141).
make up
# 2. Submit a registration via the self-service portal (mock DigiD: jan-burger / test123),
# or drive the whole happy path automatically (login → submit → public visibility):
make verify-e2e
# 3. Open the public register — no login. It lists the submitted entry (id + status only).
# Only public-safe fields cross the BFF: bsn / naam never appear.
open http://localhost:8141/ # search box; searches the BFF by referentie
curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view via the BFF proxy
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
```
> The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below.
---
## 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+).

View File

@@ -103,3 +103,17 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
touching the app or its production config. touching the app or its production config.
- `tests/e2e` is a standalone Playwright project (its own `package.json`), not an Nx project — it's a - `tests/e2e` is a standalone Playwright project (its own `package.json`), not an Nx project — it's a
live-stack check like the other `verify-*` runners, not part of the `frontend` unit lane. live-stack check like the other `verify-*` runners, not part of the `frontend` unit lane.
## Openbaar Register portal (S-09, #10)
- **Anonymous, no auth.** The openbaar register is a public read, so `apps/openbaar` has no
`angular-auth-oidc-client`, no interceptor, and no `config.json` — `main.ts` bootstraps `appConfig`
directly with just `provideHttpClient` + `provideRouter`. This is the deliberate contrast to
self-service and keeps the app trivially cacheable/CDN-able.
- **Same-origin via nginx, like self-service.** The compose `openbaar` image serves the built app and
reverse-proxies `/openbaar` to the BFF; the api-client's relative calls stay same-origin (no CORS).
Served on `:8141`, health-checked over IPv4 (`127.0.0.1`), no Keycloak dependency.
- **Public-safe by construction.** The portal only ever sees the BFF's `OpenbaarProjection.PublicView`
(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
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.

View File

@@ -458,6 +458,27 @@ services:
condition: service_started condition: service_started
networks: [cg] networks: [cg]
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141: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
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:

View File

@@ -24,8 +24,18 @@ app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationT
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();
});
app.Run(); app.Run();
public sealed record OpenZaakRequest(string Bsn); public sealed record OpenZaakRequest(string Bsn);
public sealed record SetStatusRequest(string ZaakUrl);
public partial class Program; public partial class Program;

View File

@@ -17,4 +17,15 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
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);
}
} }

View File

@@ -5,4 +5,11 @@ 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);
} }

View File

@@ -41,6 +41,92 @@ 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);
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, 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);
}
// 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);
}
// 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 the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
{
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
var resultaattype = (page.Results ?? []).FirstOrDefault()
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
return new Uri(resultaattype.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,
@@ -50,4 +136,27 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
private sealed record ZaakCreatedDto( private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url); [property: JsonPropertyName("url")] string Url);
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);
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);
} }

View File

@@ -65,6 +65,43 @@ 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 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}");
}
// 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()

View File

@@ -42,4 +42,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
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());
} }
[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)));
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());
}
} }

View File

@@ -9,12 +9,28 @@ 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;
} }
}
private static AclDefaults Defaults() => new()
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
};
private sealed class FixedClock(DateOnly today) : IClock private sealed class FixedClock(DateOnly today) : IClock
{ {
@@ -61,4 +77,30 @@ public class AclServiceTests
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);
}
} }

View File

@@ -131,8 +131,9 @@ public class OpenZaakGatewayTests
Content = new StringContent("null", Encoding.UTF8, "application/json"), Content = new StringContent("null", Encoding.UTF8, "application/json"),
})); }));
await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest())); () => Gateway(handler).OpenZaakAsync(SampleRequest()));
Assert.Contains("empty zaak response", ex.Message);
} }
[Fact] [Fact]
@@ -144,6 +145,229 @@ 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"}]}""";
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_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("No resultaattypen found", 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 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)
{ {

View File

@@ -23,6 +23,7 @@ builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService
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<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -42,6 +43,18 @@ app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegis
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();
});
// 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) =>
{ {

View File

@@ -0,0 +1,36 @@
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);
}
}

View File

@@ -25,6 +25,12 @@ public interface IWorkflowClient
public interface IAclClient public interface IAclClient
{ {
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default); Task<Uri> OpenZaakAsync(string bsn, 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> /// <summary>

View File

@@ -64,4 +64,23 @@ public sealed class Registration
ZaakUrl = zaakUrl; ZaakUrl = zaakUrl;
} }
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
/// </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.");
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
Status = RegistrationStatus.Ingeschreven;
}
} }

View File

@@ -1,10 +1,13 @@
namespace Big.Domain; namespace Big.Domain;
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking /// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
/// skeleton knows only <see cref="Ingediend"/>; withdrawal, beoordeling and herregistratie /// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
/// states arrive in their own slices (Iteration 2+).</summary> /// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</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>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
Ingeschreven,
} }

View File

@@ -22,7 +22,18 @@ 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)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "statussen"), new SetStatusRequest(zaakUrl.ToString()), ct);
response.EnsureSuccessStatusCode();
}
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn); private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
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);
} }

View File

@@ -41,4 +41,37 @@ public class AclHttpClientTests
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782")); var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public async Task Approves_a_zaak_by_posting_its_url_to_statussen()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.NoContent));
await client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/statussen", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
}
[Fact]
public async Task Approve_throws_when_the_acl_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
}
[Fact]
public async Task Approve_rejects_a_null_zaak_url_without_sending_a_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.NoContent));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.ApproveZaakAsync(null!));
Assert.Null(capture.Seen);
}
} }

View File

@@ -0,0 +1,89 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
public class ApproveRegistrationTests
{
private static Registration WithZaak(string bsn = "123456782")
{
var registration = Registration.Submit(bsn);
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
return registration;
}
[Fact]
public async Task Approving_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
Assert.Equal(1, acl.ApproveCallCount);
// The approved aggregate is persisted (not just mutated in memory).
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store_or_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Approving_an_unknown_registration_throws_and_does_not_call_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Approving_before_the_zaak_is_opened_throws_without_calling_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
Assert.Contains("no zaak", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Re_approving_an_already_ingeschreven_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
// The second approval is a no-op: the ACL is not asked to set the status again.
Assert.Equal(1, acl.ApproveCallCount);
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
}
}

View File

@@ -52,10 +52,20 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
public string? OpenedForBsn { get; private set; } public string? OpenedForBsn { get; private set; }
public int CallCount { get; private set; } public int CallCount { get; private set; }
public Uri? ApprovedZaakUrl { get; private set; }
public int ApproveCallCount { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
{ {
CallCount++; CallCount++;
OpenedForBsn = bsn; OpenedForBsn = bsn;
return Task.FromResult(_zaakUrl); return Task.FromResult(_zaakUrl);
} }
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ApproveCallCount++;
ApprovedZaakUrl = zaakUrl;
return Task.CompletedTask;
}
} }

View File

@@ -87,4 +87,38 @@ public class RegistrationTests
Assert.Contains("/zaken/abc", ex.Message); Assert.Contains("/zaken/abc", ex.Message);
Assert.Contains("/zaken/other", ex.Message); Assert.Contains("/zaken/other", ex.Message);
} }
[Fact]
public void Approving_a_registration_with_a_zaak_sets_it_ingeschreven()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Approving_before_the_zaak_is_opened_is_rejected()
{
var registration = Registration.Submit("123456782");
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
}
[Fact]
public void Approving_an_already_ingeschreven_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
} }

View File

@@ -54,9 +54,9 @@ await app.RunAsync();
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the /// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the
/// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary> /// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary>
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl) public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl, Uri? HoofdObject = null)
{ {
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl); public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl, HoofdObject);
} }
public partial class Program public partial class Program

View File

@@ -11,14 +11,23 @@ public sealed record Notification(
string Kanaal, string Kanaal,
string Resource, string Resource,
string Actie, string Actie,
Uri ResourceUrl) Uri ResourceUrl,
Uri? HoofdObject = null)
{ {
/// <summary>The only event the walking-skeleton projection reacts to: a zaak being created.</summary> /// <summary>A zaak being created — projected as INGEDIEND.</summary>
public bool IsZaakCreated => public bool IsZaakCreated =>
Kanaal == "zaken" && Resource == "zaak" && Actie == "create"; Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
/// <summary>The zaak UUID — the trailing path segment of the resource URL — used as the projection key.</summary> /// <summary>A status being set on a zaak — the approval, projected as INGESCHREVEN (S-09b). In the
public string ZaakId => ResourceUrl.Segments[^1].Trim('/'); /// walking skeleton the only status ever set after creation is the approval, and the subscriber may
/// not read OpenZaak (§8.1), so any status-create is taken as the approval.</summary>
public bool IsZaakStatusSet =>
Kanaal == "zaken" && Resource == "status" && Actie == "create";
/// <summary>The zaak UUID used as the projection key. For a status notification the resource URL is
/// the status, so the zaak comes from <c>hoofdObject</c> (which, for a zaak-create, equals the
/// resource URL) — see the NRC payload note.</summary>
public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/');
/// <summary> /// <summary>
/// A deterministic dedup key. Open Notificaties carries no notification id and may /// A deterministic dedup key. Open Notificaties carries no notification id and may

View File

@@ -7,13 +7,15 @@ namespace EventSubscriber.Application;
/// </summary> /// </summary>
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store) public sealed class NotificationProjector(INotificationLog log, IProjectionStore store)
{ {
/// <summary>Handle one inbound notification. Ignores anything that is not a zaak-created event.</summary> /// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
/// status being set (INGESCHREVEN); ignores everything else.</summary>
public async Task HandleAsync(Notification notification, CancellationToken ct = default) public async Task HandleAsync(Notification notification, CancellationToken ct = default)
{ {
if (!notification.IsZaakCreated) if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
return; return;
var recorded = new RecordedNotification(notification.IdempotencyKey, notification.Actie, notification.ZaakId); var recorded = new RecordedNotification(
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource);
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped // Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
// before it touches the projection, so the projection stays a faithful derived artefact. // before it touches the projection, so the projection stays a faithful derived artefact.
@@ -31,8 +33,10 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
await store.UpsertAsync(ToEntry(recorded), ct); await store.UpsertAsync(ToEntry(recorded), ct);
} }
/// <summary>The minimal projection row for an accepted notification. A zaak-create maps to /// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
/// INGEDIEND; bsn/naam are deferred (ADR-0008).</summary> /// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
private static RegisterEntry ToEntry(RecordedNotification recorded) private static RegisterEntry ToEntry(RecordedNotification recorded)
=> new(recorded.ZaakId, RegistrationStatus.Ingediend); => new(recorded.ZaakId, recorded.Resource == "status"
? RegistrationStatus.Ingeschreven
: RegistrationStatus.Ingediend);
} }

View File

@@ -19,8 +19,10 @@ public interface INotificationLog
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default); Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
} }
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its projection row.</summary> /// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
public sealed record RecordedNotification(string Key, string Actie, string ZaakId); /// projection row — including the ZGW <c>resource</c>, which distinguishes a zaak-create (INGEDIEND)
/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status.</summary>
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource);
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the /// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
/// subscriber writes to it and the projection-api reads it.</summary> /// subscriber writes to it and the projection-api reads it.</summary>

View File

@@ -17,4 +17,7 @@ public static class RegistrationStatus
{ {
/// <summary>A zaak has been created; the registration is submitted.</summary> /// <summary>A zaak has been created; the registration is submitted.</summary>
public const string Ingediend = "INGEDIEND"; public const string Ingediend = "INGEDIEND";
/// <summary>The registration has been approved and entered in the register (S-09b).</summary>
public const string Ingeschreven = "INGESCHREVEN";
} }

View File

@@ -8,6 +8,7 @@ namespace EventSubscriber.Tests;
public sealed class NotificationProjectorTests public sealed class NotificationProjectorTests
{ {
private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111"; private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111";
private const string StatusUrl = "http://openzaak:8000/zaken/api/v1/statussen/22222222-2222-2222-2222-222222222222";
private readonly InMemoryNotificationLog _log = new(); private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new(); private readonly InMemoryProjectionStore _store = new();
@@ -17,6 +18,10 @@ public sealed class NotificationProjectorTests
private static Notification ZaakCreated(string url = ZaakUrl) private static Notification ZaakCreated(string url = ZaakUrl)
=> new("zaken", "zaak", "create", new Uri(url)); => new("zaken", "zaak", "create", new Uri(url));
// A status-set notification: resourceUrl is the status resource, hoofdObject is the zaak it belongs to.
private static Notification StatusSet(string zaakUrl = ZaakUrl, string statusUrl = StatusUrl)
=> new("zaken", "status", "create", new Uri(statusUrl), new Uri(zaakUrl));
[Fact] [Fact]
public async Task creating_a_zaak_writes_one_row_with_status_ingediend() public async Task creating_a_zaak_writes_one_row_with_status_ingediend()
{ {
@@ -62,16 +67,53 @@ public sealed class NotificationProjectorTests
[Theory] [Theory]
[InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource [InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource
[InlineData("documenten", "zaak", "create")] // wrong kanaal only [InlineData("documenten", "zaak", "create")] // wrong kanaal only
[InlineData("zaken", "status", "create")] // wrong resource only
[InlineData("zaken", "zaak", "update")] // wrong actie [InlineData("zaken", "zaak", "update")] // wrong actie
[InlineData("zaken", "zaak", "destroy")] // wrong actie [InlineData("zaken", "zaak", "destroy")] // wrong actie
public async Task only_a_zaken_zaak_create_notification_is_projected(string kanaal, string resource, string actie) [InlineData("zaken", "status", "update")] // a status change we ignore
[InlineData("zaken", "resultaat", "create")] // not a status we project
public async Task an_unrelated_notification_is_not_projected(string kanaal, string resource, string actie)
{ {
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl))); await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl)));
Assert.Empty(await _store.AllAsync()); Assert.Empty(await _store.AllAsync());
} }
[Fact]
public async Task setting_a_status_projects_ingeschreven_keyed_on_the_zaak_not_the_status()
{
await Projector().HandleAsync(StatusSet());
var entry = Assert.Single(await _store.AllAsync());
// Keyed on the zaak (hoofdObject), not the status resource URL.
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
}
[Fact]
public async Task approving_updates_the_existing_zaak_row_from_ingediend_to_ingeschreven()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(StatusSet());
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
}
[Fact]
public async Task rebuild_reproduces_the_approved_status()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(StatusSet());
await projector.RebuildAsync();
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
}
[Fact] [Fact]
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log() public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
{ {

View File

@@ -15,6 +15,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
Key = notification.Key, Key = notification.Key,
Actie = notification.Actie, Actie = notification.Actie,
ZaakId = notification.ZaakId, ZaakId = notification.ZaakId,
Resource = notification.Resource,
ReceivedAt = DateTimeOffset.UtcNow, ReceivedAt = DateTimeOffset.UtcNow,
}); });
@@ -34,6 +35,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> await db.ProcessedNotifications => await db.ProcessedNotifications
.OrderBy(r => r.ReceivedAt) .OrderBy(r => r.ReceivedAt)
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId)) .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource))
.ToListAsync(ct); .ToListAsync(ct);
} }

View File

@@ -0,0 +1,84 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Projection.ReadModel;
#nullable disable
namespace Projection.ReadModel.Migrations
{
[DbContext(typeof(ProjectionDbContext))]
[Migration("20260713145944_AddNotificationResource")]
partial class AddNotificationResource
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
{
b.Property<string>("Key")
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("Actie")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actie");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
.HasColumnName("resource");
b.Property<string>("ZaakId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("zaak_id");
b.HasKey("Key");
b.ToTable("processed_notifications", (string)null);
});
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("Bsn")
.HasColumnType("text")
.HasColumnName("bsn");
b.Property<string>("NaamPlaceholder")
.HasColumnType("text")
.HasColumnName("naam_placeholder");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id");
b.ToTable("register_projection", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Projection.ReadModel.Migrations
{
/// <inheritdoc />
public partial class AddNotificationResource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Pre-existing rows are all zaak-creates (the only notification the projector recorded
// before S-09b), so default the backfill to "zaak" rather than "".
migrationBuilder.AddColumn<string>(
name: "resource",
table: "processed_notifications",
type: "text",
nullable: false,
defaultValue: "zaak");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "resource",
table: "processed_notifications");
}
}
}

View File

@@ -37,6 +37,11 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("received_at"); .HasColumnName("received_at");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
.HasColumnName("resource");
b.Property<string>("ZaakId") b.Property<string>("ZaakId")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")

View File

@@ -35,6 +35,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
e.Property(r => r.Key).HasColumnName("key"); e.Property(r => r.Key).HasColumnName("key");
e.Property(r => r.Actie).HasColumnName("actie").IsRequired(); e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired(); e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
e.Property(r => r.ReceivedAt).HasColumnName("received_at"); e.Property(r => r.ReceivedAt).HasColumnName("received_at");
}); });
} }
@@ -55,5 +56,10 @@ public sealed class ProcessedNotificationRow
public required string Key { get; set; } public required string Key { get; set; }
public required string Actie { get; set; } public required string Actie { get; set; }
public required string ZaakId { get; set; } public required string ZaakId { get; set; }
/// <summary>The ZGW resource (e.g. <c>zaak</c> or <c>status</c>) — retained so a rebuild reprojects
/// the right status without reading OpenZaak (S-09b).</summary>
public required string Resource { get; set; }
public DateTimeOffset ReceivedAt { get; set; } public DateTimeOffset ReceivedAt { get; set; }
} }

View File

@@ -19,12 +19,46 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
// A fixed application-scoped key for the migration advisory lock (any stable 64-bit constant).
private const long MigrationAdvisoryLockKey = 727501;
/// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack /// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).</summary> /// reaches a usable schema without a manual migration step (DoD: compose up reaches green).
///
/// The Event Subscriber and the projection-api share this DB and both migrate on start. EF's
/// migrations-history lock is released between individual migrations, so with more than one pending
/// migration two migrators can interleave and one re-applies a migration the other just did
/// ("column already exists"). Hold a Postgres session <c>pg_advisory_lock</c> across the whole
/// sequence so it runs exactly once; the second migrator then finds nothing pending.</summary>
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default) public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
{ {
await using var scope = services.CreateAsyncScope(); await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
var connection = db.Database.GetDbConnection();
await connection.OpenAsync(ct);
try
{
await ExecuteAsync(connection, $"SELECT pg_advisory_lock({MigrationAdvisoryLockKey})", ct);
try
{
await db.Database.MigrateAsync(ct); await db.Database.MigrateAsync(ct);
} }
finally
{
await ExecuteAsync(connection, $"SELECT pg_advisory_unlock({MigrationAdvisoryLockKey})", ct);
}
}
finally
{
await connection.CloseAsync();
}
}
private static async Task ExecuteAsync(System.Data.Common.DbConnection connection, string sql, CancellationToken ct)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(ct);
}
} }

View File

@@ -10,8 +10,9 @@ builder.Services.AddProjectionReadModel(connectionString);
var app = builder.Build(); var app = builder.Build();
// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the // Ensure the schema exists before serving reads. The Event Subscriber migrates this shared DB too;
// migrations-history lock, so it is safe that the Event Subscriber migrates too. // MigrateProjectionAsync serialises concurrent migrators with a session advisory lock so the whole
// migration sequence runs exactly once (see ServiceCollectionExtensions).
await app.Services.MigrateProjectionAsync(); await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy"); app.MapGet("/health", () => "Healthy");

View File

@@ -26,12 +26,19 @@ public sealed class InMemoryAclClient : IAclClient
public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak"); public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak");
public string? OpenedForBsn { get; private set; } public string? OpenedForBsn { get; private set; }
public Uri? ApprovedZaakUrl { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
{ {
OpenedForBsn = bsn; OpenedForBsn = bsn;
return Task.FromResult(OpenedZaakUrl); return Task.FromResult(OpenedZaakUrl);
} }
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ApprovedZaakUrl = zaakUrl;
return Task.CompletedTask;
}
} }
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary> /// <summary>An in-memory registration store for the domain acceptance scenario.</summary>

View File

@@ -11,10 +11,17 @@ public sealed class InMemoryZaakGateway : IZaakGateway
public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123"); public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123");
public ZaakRequest? Captured { get; private set; } public ZaakRequest? Captured { get; private set; }
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
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(CreatedZaakUrl); return Task.FromResult(CreatedZaakUrl);
} }
public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask;
}
} }

View File

@@ -1,8 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d): a zorgprofessional logs in via mock DigiD and submits a // Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
// registration through the self-service portal → BFF → domain. The confirmation shows the reference. // submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
test('DigiD login → submit → confirmation', async ({ page }) => { // as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
test('DigiD login → submit → public INGEDIEND → approve → public INGESCHREVEN', async ({ page, request }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login. // Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/'); await page.goto('/');
@@ -16,6 +18,42 @@ test('DigiD login → submit → confirmation', async ({ page }) => {
await page.getByRole('button', { name: /indienen/i }).click(); await page.getByRole('button', { name: /indienen/i }).click();
// The BFF accepted it and the page shows the confirmation. // The BFF accepted it and the page shows the confirmation with the reference.
await expect(page.getByText(/ontvangen/i)).toBeVisible(); const confirmation = page.getByText(/ontvangen/i);
await expect(confirmation).toBeVisible();
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
// on open, so reload until a submitted (INGEDIEND) row appears.
await page.goto('http://openbaar/');
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
await expect
.poll(async () => {
await page.reload();
return page.getByRole('cell', { name: 'INGEDIEND' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
// path by the worker, so wait for it before approving.
await expect
.poll(async () => {
const res = await request.get(`http://domain:8080/registrations/${reference}`);
return res.ok() ? (await res.json()).zaakUrl : null;
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeTruthy();
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
expect(approve.status()).toBe(204);
// The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN.
await expect
.poll(async () => {
await page.reload();
return page.getByRole('cell', { name: 'INGESCHREVEN' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
}); });