Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3afa5a552a | ||
|
|
8591b89e0c | ||
|
|
248f57024e |
+5
-118
@@ -9,12 +9,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
|
||||
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
|
||||
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
|
||||
|
||||
@@ -70,12 +64,6 @@ jobs:
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make unit
|
||||
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
|
||||
- name: Unit test summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||
frontend:
|
||||
@@ -90,12 +78,6 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
- run: make frontend
|
||||
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
|
||||
- name: Frontend test summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
mutation:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -111,29 +93,6 @@ jobs:
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make mutation
|
||||
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
|
||||
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
|
||||
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
|
||||
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
|
||||
- name: Mutation score summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
{
|
||||
echo "## 🧬 Mutation testing"
|
||||
echo
|
||||
for svc in acl event-subscriber domain bff; do
|
||||
echo "### $svc"
|
||||
echo
|
||||
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
|
||||
if [ -n "$report" ]; then
|
||||
sed '1s/^\xef\xbb\xbf//' "$report"
|
||||
else
|
||||
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
||||
@@ -170,107 +129,35 @@ jobs:
|
||||
path: services/bff/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
|
||||
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
|
||||
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
|
||||
# in a built image and everything reaches services by container IP. Needs Docker + egress
|
||||
# One stage for every check that needs the live stack. On the single self-hosted
|
||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
||||
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
|
||||
# image and everything reaches services by container IP. Needs Docker + egress
|
||||
# (base images, nuget, selectielijst.openzaak.nl).
|
||||
#
|
||||
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
|
||||
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
|
||||
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
|
||||
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
|
||||
#
|
||||
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
|
||||
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
|
||||
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
|
||||
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
|
||||
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
|
||||
# re-run exercises verify-stack, so we still get the signal.
|
||||
verify-stack:
|
||||
needs: [mutation]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
|
||||
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
|
||||
- name: Bring up the full stack & wait for health
|
||||
id: up
|
||||
run: make verify-up
|
||||
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||
id: obs
|
||||
run: OBS_TIMEOUT=180 make verify-observability
|
||||
- name: ACL ↔ OpenZaak integration tests
|
||||
id: acl
|
||||
run: make verify-acl
|
||||
- name: OpenZaak → NRC notification delivery
|
||||
id: nrc
|
||||
run: make verify-nrc
|
||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||
id: projection
|
||||
run: make verify-projection
|
||||
- name: Domain → Flowable → ACL → OpenZaak
|
||||
id: domain
|
||||
run: make verify-domain
|
||||
- name: BFF → Keycloak + domain + projection
|
||||
id: bff
|
||||
run: make verify-bff
|
||||
- name: Distributed traces reach Tempo (one connected trace across services)
|
||||
id: tracing
|
||||
run: TRACING_TIMEOUT=120 make verify-tracing
|
||||
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
||||
id: metrics
|
||||
run: METRICS_TIMEOUT=120 make verify-metrics
|
||||
- name: Self-service e2e (Playwright, login → submit → success)
|
||||
id: e2e
|
||||
run: make verify-e2e
|
||||
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
|
||||
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
|
||||
# job-level status-function `if` of #134) so it renders even after a check fails.
|
||||
- name: verify-stack check summary
|
||||
if: always()
|
||||
env:
|
||||
UP: ${{ steps.up.outcome }}
|
||||
OBS: ${{ steps.obs.outcome }}
|
||||
ACL: ${{ steps.acl.outcome }}
|
||||
NRC: ${{ steps.nrc.outcome }}
|
||||
PROJECTION: ${{ steps.projection.outcome }}
|
||||
DOMAIN: ${{ steps.domain.outcome }}
|
||||
BFF: ${{ steps.bff.outcome }}
|
||||
TRACING: ${{ steps.tracing.outcome }}
|
||||
METRICS: ${{ steps.metrics.outcome }}
|
||||
E2E: ${{ steps.e2e.outcome }}
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
|
||||
{
|
||||
echo "## 🔌 verify-stack checks"
|
||||
echo
|
||||
echo "| Check | Result |"
|
||||
echo "| ----- | :----: |"
|
||||
echo "| Bring up + health | $(icon "$UP") |"
|
||||
echo "| Observability backplane | $(icon "$OBS") |"
|
||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
|
||||
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
|
||||
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
|
||||
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
|
||||
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
|
||||
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
|
||||
- name: e2e spec summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
# Log dump must precede teardown (which removes the containers).
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer tempo prometheus grafana 2>&1 || true
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
@@ -57,7 +57,3 @@ vitest.config.*.timestamp*
|
||||
tests/e2e/node_modules/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
__pycache__/
|
||||
TestResults/
|
||||
test-output/
|
||||
tests/e2e/playwright-report.json
|
||||
|
||||
+6
-40
@@ -199,25 +199,9 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
|
||||
|
||||
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
||||
|
||||
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
|
||||
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
|
||||
|
||||
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
|
||||
|
||||
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
|
||||
|
||||
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
|
||||
|
||||
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
|
||||
|
||||
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
|
||||
|
||||
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
|
||||
|
||||
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
|
||||
|
||||
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
|
||||
|
||||
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
|
||||
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
|
||||
|
||||
### S-11 · Withdrawal (Flow 3)
|
||||
|
||||
@@ -239,39 +223,21 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
|
||||
|
||||
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
||||
|
||||
### S-26 · Self-service — resume an existing registration after refresh — #111
|
||||
|
||||
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
|
||||
|
||||
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||
|
||||
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
|
||||
### S-15 · Beheer-portal — catalogus & default-fill rules
|
||||
|
||||
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
|
||||
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
|
||||
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
|
||||
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
|
||||
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard
|
||||
|
||||
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
|
||||
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
|
||||
|
||||
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
|
||||
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
|
||||
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
|
||||
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
|
||||
|
||||
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
|
||||
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
||||
# 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)
|
||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
|
||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||
# 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
|
||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
||||
endif
|
||||
endif
|
||||
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
|
||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||
@@ -70,9 +70,8 @@ build:
|
||||
dotnet build $(SLN) -c Release
|
||||
|
||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
||||
unit:
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||
|
||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||
@@ -115,11 +114,6 @@ local:
|
||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
|
||||
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
|
||||
verify-local:
|
||||
bash infra/run-local-flow-check.sh
|
||||
|
||||
## local-down: stop and remove the bind-mount stack
|
||||
local-down:
|
||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||
@@ -171,21 +165,6 @@ verify-bff:
|
||||
verify-e2e:
|
||||
bash infra/run-e2e-check.sh
|
||||
|
||||
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
|
||||
## Prometheus datasources) is live, against the already-running stack (S-16a).
|
||||
verify-observability:
|
||||
bash infra/run-observability-check.sh
|
||||
|
||||
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
|
||||
## (S-16b), against the already-running stack.
|
||||
verify-tracing:
|
||||
bash infra/run-tracing-check.sh
|
||||
|
||||
## verify-metrics: assert the services expose /metrics and Prometheus scrapes the golden
|
||||
## signals (S-16c), against the already-running stack.
|
||||
verify-metrics:
|
||||
bash infra/run-metrics-check.sh
|
||||
|
||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||
## tear down (always). For fast single-concern local iteration use `integration`
|
||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||
|
||||
@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
|
||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -64,9 +64,7 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
"watch": false
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Multi-stage build for the beheer 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/beheer apps/beheer
|
||||
COPY libs libs
|
||||
RUN pnpm nx build beheer
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /src/dist/apps/beheer/browser /usr/share/nginx/html
|
||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,34 +0,0 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'app',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'app',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
@@ -1,24 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||
|
||||
# Same-origin API: proxy the beheer endpoint group to the bff service. The api-client uses
|
||||
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
|
||||
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
|
||||
location /beheer/ {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"name": "beheer",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"sourceRoot": "apps/beheer/src",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular/build:application",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/beheer",
|
||||
"browser": "apps/beheer/src/main.ts",
|
||||
"tsConfig": "apps/beheer/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/beheer/public"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/beheer/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": "beheer:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "beheer:build:development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
},
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/web:file-server",
|
||||
"options": {
|
||||
"buildTarget": "beheer:build",
|
||||
"staticFilePath": "dist/apps/beheer/browser",
|
||||
"spa": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/medewerker"
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,65 +0,0 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { BffApiV1Service } from 'api-client';
|
||||
import { authInterceptor } from 'auth';
|
||||
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
||||
import { SECURE_API_ROUTES } from './app.config';
|
||||
|
||||
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
|
||||
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
|
||||
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
|
||||
// the beheer calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
|
||||
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
|
||||
// source and token storage are faked, so the assertion turns on the actual route-matching.
|
||||
describe('beheer medewerker token wiring', () => {
|
||||
let http: HttpTestingController;
|
||||
let bff: BffApiV1Service;
|
||||
const token = 'medewerker-access-token';
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: ConfigurationService,
|
||||
useValue: {
|
||||
hasAtLeastOneConfig: () => true,
|
||||
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
|
||||
},
|
||||
},
|
||||
{
|
||||
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||
provide: AbstractSecurityStorage,
|
||||
useValue: {
|
||||
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||
write: () => undefined,
|
||||
remove: () => undefined,
|
||||
clear: () => undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
bff = TestBed.inject(BffApiV1Service);
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('attaches the bearer token to the relative catalogus call', () => {
|
||||
bff.getBeheerCatalogiZaaktypen().subscribe();
|
||||
|
||||
const req = http.expectOne('/beheer/catalogi/zaaktypen');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||
bff.getOpenbaarRegister().subscribe();
|
||||
|
||||
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
req.flush([]);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { authInterceptor, provideMedewerkerAuth } from 'auth';
|
||||
import { appRoutes } from './app.routes';
|
||||
|
||||
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||
export interface RuntimeConfig {
|
||||
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||
authority: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
|
||||
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
|
||||
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
|
||||
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
|
||||
*/
|
||||
export const SECURE_API_ROUTES = ['/beheer/'];
|
||||
|
||||
/**
|
||||
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
||||
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
||||
*/
|
||||
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||
return {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(appRoutes),
|
||||
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||
provideMedewerkerAuth({
|
||||
authority: runtime.authority,
|
||||
redirectUrl: origin,
|
||||
secureRoutes: SECURE_API_ROUTES,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<nav aria-label="Beheer" class="utrecht-theme">
|
||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
|
||||
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
|
||||
</nav>
|
||||
<router-outlet></router-outlet>
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { authenticatedGuard } from 'auth';
|
||||
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||
import { DefaultFillPage } from './default-fill/default-fill-page';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||
{ path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] },
|
||||
];
|
||||
@@ -1,15 +0,0 @@
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { render, screen } from '@testing-library/angular';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the router outlet shell', async () => {
|
||||
const { container } = await render(App, {
|
||||
providers: [provideRouter([])],
|
||||
});
|
||||
|
||||
// The shell is a thin host for routed pages (the CatalogusPage owns the heading).
|
||||
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||
expect(screen).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
imports: [RouterModule],
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {
|
||||
protected title = 'beheer';
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Catalogus</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
|
||||
in een latere slice.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
|
||||
opnieuw.
|
||||
</p>
|
||||
} @else if (loaded() && items().length === 0) {
|
||||
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
|
||||
} @else if (items().length > 0) {
|
||||
<table utrecht-table>
|
||||
<caption>
|
||||
Gepubliceerde zaaktypen
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Identificatie</th>
|
||||
<th scope="col">Omschrijving</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (zaaktype of items(); track zaaktype.identificatie) {
|
||||
<tr>
|
||||
<td>{{ zaaktype.identificatie }}</td>
|
||||
<td>{{ zaaktype.omschrijving }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -1,75 +0,0 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { CatalogusPage } from './catalogus-page';
|
||||
|
||||
const sample: BeheerZaaktype[] = [
|
||||
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
|
||||
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
|
||||
];
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||
login(): void {
|
||||
/* not exercised here */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised here */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const getBeheerCatalogiZaaktypen =
|
||||
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
|
||||
return {
|
||||
getBeheerCatalogiZaaktypen,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('CatalogusPage', () => {
|
||||
it('lists the published zaaktypen on open', async () => {
|
||||
const { getBeheerCatalogiZaaktypen, providers } = setup();
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
|
||||
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
|
||||
expect(screen.getByText('BIG-registratie')).toBeTruthy();
|
||||
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
|
||||
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a load failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(CatalogusPage, { providers: setup().providers });
|
||||
|
||||
const results = await axe(container, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||
});
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
|
||||
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
|
||||
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-catalogus-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './catalogus-page.html',
|
||||
})
|
||||
export class CatalogusPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly items = signal<BeheerZaaktype[]>([]);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.bff.getBeheerCatalogiZaaktypen().subscribe({
|
||||
next: (rows: BeheerZaaktype[]) => {
|
||||
this.items.set(rows);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
|
||||
error: () => {
|
||||
this.items.set([]);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Default-fill</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
|
||||
voor de eerstvolgende zaak.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (loaded()) {
|
||||
<form (submit)="save(); $event.preventDefault()">
|
||||
<p>
|
||||
<label for="bronorganisatie">Bronorganisatie</label><br />
|
||||
<input
|
||||
id="bronorganisatie"
|
||||
name="bronorganisatie"
|
||||
[value]="bronorganisatie()"
|
||||
(input)="bronorganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
|
||||
<input
|
||||
id="verantwoordelijkeOrganisatie"
|
||||
name="verantwoordelijkeOrganisatie"
|
||||
[value]="verantwoordelijkeOrganisatie()"
|
||||
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
|
||||
<input
|
||||
id="vertrouwelijkheidaanduiding"
|
||||
name="vertrouwelijkheidaanduiding"
|
||||
[value]="vertrouwelijkheidaanduiding()"
|
||||
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
|
||||
Opslaan
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (saved()) {
|
||||
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
|
||||
}
|
||||
@if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
|
||||
het opnieuw.
|
||||
</p>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -1,90 +0,0 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { DefaultFillPage } from './default-fill-page';
|
||||
|
||||
const current: BeheerDefaultFill = {
|
||||
bronorganisatie: '517439943',
|
||||
verantwoordelijkeOrganisatie: '517439943',
|
||||
vertrouwelijkheidaanduiding: 'openbaar',
|
||||
};
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||
login(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
overrides: {
|
||||
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
|
||||
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
|
||||
return {
|
||||
getBeheerDefaultFill,
|
||||
putBeheerDefaultFill,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DefaultFillPage', () => {
|
||||
it('loads the current default-fill into the form on open', async () => {
|
||||
const { getBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
expect(getBeheerDefaultFill).toHaveBeenCalled();
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
expect(bron.value).toBe('517439943');
|
||||
});
|
||||
|
||||
it('saves the edited values via the BFF', async () => {
|
||||
const { putBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
fireEvent.input(bron, { target: { value: '999999999' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
|
||||
);
|
||||
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a save failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(DefaultFillPage, { providers: setup().providers });
|
||||
|
||||
const results = await axe(container, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||
});
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
|
||||
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
|
||||
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-default-fill-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './default-fill-page.html',
|
||||
})
|
||||
export class DefaultFillPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly bronorganisatie = signal('');
|
||||
protected readonly verantwoordelijkeOrganisatie = signal('');
|
||||
protected readonly vertrouwelijkheidaanduiding = signal('');
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly saved = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff.getBeheerDefaultFill().subscribe({
|
||||
next: (d: BeheerDefaultFill) => {
|
||||
this.bronorganisatie.set(d.bronorganisatie);
|
||||
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
|
||||
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.saving.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff
|
||||
.putBeheerDefaultFill({
|
||||
bronorganisatie: this.bronorganisatie(),
|
||||
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
|
||||
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.saved.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Beheerportaal 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>
|
||||
@@ -1,10 +0,0 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { App } from './app/app';
|
||||
import { appConfig, type RuntimeConfig } from './app/app.config';
|
||||
|
||||
// Load environment config before bootstrap so the OIDC authority is set per environment
|
||||
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
|
||||
fetch('config.json')
|
||||
.then((response) => response.json() as Promise<RuntimeConfig>)
|
||||
.then((config) => bootstrapApplication(App, appConfig(config)))
|
||||
.catch((err) => console.error(err));
|
||||
@@ -1,2 +0,0 @@
|
||||
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||
@import '@utrecht/design-tokens/dist/index.css';
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -17,9 +17,5 @@ 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).
|
||||
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -64,9 +64,7 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
"watch": false
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
|
||||
#
|
||||
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
|
||||
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
|
||||
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
|
||||
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
|
||||
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
|
||||
#
|
||||
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
|
||||
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
|
||||
# entrypoint executes or sources it.
|
||||
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
|
||||
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
|
||||
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||
echo "portal-nginx-resolver: set resolver to $ns"
|
||||
fi
|
||||
@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
|
||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
||||
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
||||
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -64,9 +64,7 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
"watch": false
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -3,56 +3,9 @@
|
||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||
|
||||
@if (submitted()) {
|
||||
@if (withdrawn()) {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||
</p>
|
||||
} @else {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||
</p>
|
||||
@if (documentsProvided()) {
|
||||
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
|
||||
} @else {
|
||||
@if (provideDocumentsFailed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
|
||||
<label utrecht-form-label for="diploma">Diploma</label>
|
||||
<input
|
||||
id="diploma"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
[disabled]="providingDocuments()"
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
<button
|
||||
utrecht-button
|
||||
appearance="primary-action-button"
|
||||
type="button"
|
||||
[disabled]="providingDocuments() || !selectedFile()"
|
||||
(click)="provideDocuments()"
|
||||
>
|
||||
Documenten aanleveren
|
||||
</button>
|
||||
}
|
||||
@if (withdrawFailed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
utrecht-button
|
||||
appearance="secondary-action-button"
|
||||
type="button"
|
||||
[disabled]="withdrawing()"
|
||||
(click)="withdraw()"
|
||||
>
|
||||
Trek aanvraag in
|
||||
</button>
|
||||
}
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||
</p>
|
||||
} @else {
|
||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||
@if (failed()) {
|
||||
|
||||
@@ -17,29 +17,12 @@ class FakeAuth extends AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
function providers(
|
||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
|
||||
getCurrent = vi.fn().mockReturnValue(of(undefined)),
|
||||
) {
|
||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
||||
return {
|
||||
post,
|
||||
withdraw,
|
||||
provideDocuments,
|
||||
getCurrent,
|
||||
providers: [
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
{
|
||||
provide: BffApiV1Service,
|
||||
useValue: {
|
||||
getSelfServiceRegistrations: getCurrent,
|
||||
postSelfServiceRegistrations: post,
|
||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||
},
|
||||
},
|
||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -60,21 +43,6 @@ describe('RegistrationPage', () => {
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
|
||||
const { post, providers: p } = providers(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
// The confirmation view is restored from the in-flight registration — no submit click.
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
expect(screen.getByText(/reg-77/)).toBeTruthy();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error and keeps the submit available when the BFF call fails', async () => {
|
||||
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
|
||||
await render(RegistrationPage, { providers: p });
|
||||
@@ -88,76 +56,6 @@ describe('RegistrationPage', () => {
|
||||
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||
const { withdraw, providers: p } = providers();
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||
|
||||
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
|
||||
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
|
||||
|
||||
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
|
||||
const { provideDocuments, providers: p } = providers();
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
|
||||
// Choose the file, then upload it.
|
||||
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||
|
||||
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
|
||||
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
|
||||
expect(provideDocuments).toHaveBeenCalledWith(
|
||||
'reg-9',
|
||||
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a diploma-upload failure and keeps the action available', async () => {
|
||||
const { providers: p } = providers(
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
vi.fn().mockReturnValue(of(undefined)),
|
||||
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||
const { providers: p } = providers(
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||
*
|
||||
* On load it asks the BFF for the caller's current open registration and restores the submitted view
|
||||
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
|
||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-registration-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './registration-page.html',
|
||||
})
|
||||
export class RegistrationPage implements OnInit {
|
||||
export class RegistrationPage {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
@@ -26,30 +22,6 @@ export class RegistrationPage implements OnInit {
|
||||
protected readonly reference = signal<string | undefined>(undefined);
|
||||
protected readonly submitted = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly withdrawing = signal(false);
|
||||
protected readonly withdrawn = signal(false);
|
||||
protected readonly withdrawFailed = signal(false);
|
||||
protected readonly providingDocuments = signal(false);
|
||||
protected readonly documentsProvided = signal(false);
|
||||
protected readonly provideDocumentsFailed = signal(false);
|
||||
protected readonly selectedFile = signal<File | undefined>(undefined);
|
||||
|
||||
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
|
||||
* current open registration, or 204 (empty body) when there is none — in which case we show the
|
||||
* submit form as before. Failures are non-fatal for the same reason. */
|
||||
ngOnInit(): void {
|
||||
this.bff.getSelfServiceRegistrations().subscribe({
|
||||
next: (current: CurrentRegistration | void) => {
|
||||
if (current && current.registrationId) {
|
||||
this.reference.set(current.registrationId);
|
||||
this.submitted.set(true);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// No resumable registration (or the lookup failed) — fall back to the submit form.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.submitting.set(true);
|
||||
@@ -67,74 +39,4 @@ export class RegistrationPage implements OnInit {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.selectedFile.set(input.files?.[0] ?? undefined);
|
||||
}
|
||||
|
||||
async provideDocuments(): Promise<void> {
|
||||
const reference = this.reference();
|
||||
const file = this.selectedFile();
|
||||
if (!reference || !file) {
|
||||
return;
|
||||
}
|
||||
this.providingDocuments.set(true);
|
||||
this.provideDocumentsFailed.set(false);
|
||||
let contentBase64: string;
|
||||
try {
|
||||
contentBase64 = await readAsBase64(file);
|
||||
} catch {
|
||||
this.provideDocumentsFailed.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
return;
|
||||
}
|
||||
this.bff
|
||||
.postSelfServiceRegistrationsIdDocuments(reference, {
|
||||
contentBase64,
|
||||
fileName: file.name,
|
||||
contentType: file.type || 'application/pdf',
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.documentsProvided.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
},
|
||||
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||
error: () => {
|
||||
this.provideDocumentsFailed.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
withdraw(): void {
|
||||
const reference = this.reference();
|
||||
if (!reference) {
|
||||
return;
|
||||
}
|
||||
this.withdrawing.set(true);
|
||||
this.withdrawFailed.set(false);
|
||||
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||
next: () => {
|
||||
this.withdrawn.set(true);
|
||||
this.withdrawing.set(false);
|
||||
},
|
||||
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||
error: () => {
|
||||
this.withdrawFailed.set(true);
|
||||
this.withdrawing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
|
||||
function readAsBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-17
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
|
||||
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
|
||||
pattern on `Beoordelen`).
|
||||
|
||||
## Context
|
||||
|
||||
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
|
||||
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
|
||||
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
|
||||
|
||||
Two forces shape this.
|
||||
|
||||
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
|
||||
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
|
||||
escalation happens alongside it.
|
||||
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
|
||||
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
|
||||
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
|
||||
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
|
||||
(§8.2)" posture since ADR-0009.
|
||||
|
||||
## Decision
|
||||
|
||||
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
|
||||
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
|
||||
behandelaar group to teamlead.**
|
||||
|
||||
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
|
||||
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
|
||||
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
|
||||
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
|
||||
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
|
||||
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
|
||||
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
|
||||
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
|
||||
belongs to the teamlead; its history and variables are untouched.
|
||||
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
|
||||
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
|
||||
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
|
||||
`OpenZaakAanmaken` worker.
|
||||
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
|
||||
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
|
||||
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
|
||||
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
|
||||
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
|
||||
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
|
||||
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
|
||||
timer.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
|
||||
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
|
||||
on redelivery.
|
||||
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
|
||||
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
|
||||
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
|
||||
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
|
||||
surface to the engine for no capability the external-worker route lacks.
|
||||
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
|
||||
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
|
||||
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
|
||||
@@ -1,77 +0,0 @@
|
||||
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-17
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
|
||||
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
|
||||
|
||||
## Context
|
||||
|
||||
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
|
||||
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
|
||||
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
|
||||
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
|
||||
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
|
||||
governing the registration workflow" (Flowable as a peer orchestration module).
|
||||
|
||||
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
|
||||
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
|
||||
back. That is one option; it is not the only one, and it is not the cleanest.
|
||||
|
||||
## Decision
|
||||
|
||||
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
|
||||
as a DMN service task (`flowable:type="dmn"`); an exclusive gateway routes on its output. The domain's
|
||||
only new job is to carry the diploma origin and pass it into the process as a start variable.**
|
||||
|
||||
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
|
||||
→ `route` (`Buitenlands` ⇒ `CBGV_ADVIES`, otherwise `DIRECT`). A DMN service task
|
||||
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
|
||||
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
|
||||
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. (A `businessRuleTask` would
|
||||
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
|
||||
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
|
||||
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
|
||||
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
|
||||
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
|
||||
never evaluates the DMN and never learns the route — that is the process's concern.
|
||||
- **Deployed as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
|
||||
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
|
||||
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
|
||||
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
|
||||
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
|
||||
— the resource is stored but no decision is created, so the service task fails at runtime with
|
||||
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
|
||||
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
|
||||
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
|
||||
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
|
||||
With the decision present in the DMN repository, the process's DMN service task resolves it across
|
||||
deployments by key (verified live), so no shared parent deployment id is needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
|
||||
framing); business users can read/adjust the decision table without touching domain code.
|
||||
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
|
||||
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
|
||||
- The domain change is minimal and additive: one value on the aggregate, one start variable.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
|
||||
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
|
||||
- The DMN and its service-task wiring are validated live (verify-domain drives a foreign
|
||||
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
|
||||
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
|
||||
cover only that the origin is carried into the process.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
|
||||
the domain to Flowable for a decision and splits the routing across two places (domain computes,
|
||||
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
|
||||
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
|
||||
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.
|
||||
@@ -1,90 +0,0 @@
|
||||
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-20
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
|
||||
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
|
||||
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
|
||||
|
||||
## Context
|
||||
|
||||
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
|
||||
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
|
||||
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
|
||||
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
|
||||
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
|
||||
aggregate stays in sync.
|
||||
|
||||
## Decision
|
||||
|
||||
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
|
||||
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
|
||||
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
|
||||
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
|
||||
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
|
||||
|
||||
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
|
||||
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
|
||||
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
|
||||
between the gateway and Beoordelen.
|
||||
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
|
||||
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
|
||||
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
|
||||
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
|
||||
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
|
||||
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
|
||||
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
|
||||
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
|
||||
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
|
||||
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
|
||||
without persisting again; an unknown registration throws so the job is redelivered.
|
||||
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
|
||||
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
|
||||
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
|
||||
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
|
||||
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
|
||||
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
|
||||
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
|
||||
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
|
||||
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
|
||||
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
|
||||
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
|
||||
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
|
||||
behind the same action.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
|
||||
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
|
||||
worker trio (worker + processor + pump), one Workflow Client method.
|
||||
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
|
||||
introduced in S-10a.
|
||||
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
|
||||
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
|
||||
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
|
||||
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
|
||||
through the process.
|
||||
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
|
||||
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
|
||||
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
|
||||
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
|
||||
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
|
||||
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
|
||||
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
|
||||
status — the case would still look open.
|
||||
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
|
||||
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
|
||||
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
|
||||
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
|
||||
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
|
||||
@@ -1,74 +0,0 @@
|
||||
# ADR-0018: Diploma upload is stored in the ZGW Documenten API, fronted by the ACL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-20
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10b (#103); proposal #107. Builds on ADR-0001 (ACL is the only ZGW caller),
|
||||
ADR-0003 (ACL default-fill), ADR-0017 (document-wait + provision trigger). Carves the zaak-close on
|
||||
expiry to #106 (S-10c).
|
||||
|
||||
## Context
|
||||
|
||||
S-10a wired the "documenten aanleveren" trigger (portal → BFF → domain → complete the WachtOpDocumenten
|
||||
wait) with the file itself stubbed. S-10b makes the upload real: the diploma must be **stored in the
|
||||
ZGW Documenten (DRC) API** and related to the zaak. §8.1 makes the ACL the only code that talks to ZGW.
|
||||
The DRC API is served by the same OpenZaak container as the Zaken/Catalogi APIs.
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL fronts the Documenten API: it creates an `enkelvoudiginformatieobject` and relates it to the
|
||||
zaak. The file travels base64-encoded in JSON across every hop (the portal encodes it client-side); a
|
||||
"Diploma" `informatieobjecttype` is seeded in the catalogus and injected into the ACL like the
|
||||
zaaktype.**
|
||||
|
||||
- **ACL gateway.** `OpenZaakGateway.StoreDocumentAsync` POSTs the `enkelvoudiginformatieobject`
|
||||
(`/documenten/api/v1/enkelvoudiginformatieobjecten`, base64 `inhoud`, `bestandsomvang`,
|
||||
`status=definitief`) then relates it to the zaak (`/zaken/api/v1/zaakinformatieobjecten`), reusing the
|
||||
established gateway patterns (ZGW Bearer JWT, buffered non-chunked body for uwsgi, **no CRS headers** —
|
||||
the Documenten API is not geo, unlike zaak-create). `AclService.StoreDiplomaAsync` default-fills the
|
||||
ZGW-mandatory fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, `taal=nld`,
|
||||
creatiedatum); the domain hands over only the zaak, the bytes, and the file's name/type. No new ZGW
|
||||
scopes were needed — the seed applicatie holds `heeft_alle_autorisaties`.
|
||||
- **The file travels as base64 JSON end-to-end.** The portal reads the chosen file client-side
|
||||
(`FileReader`) and posts `{ contentBase64, fileName, contentType }` as JSON to the BFF; the BFF
|
||||
forwards it to the domain, and the domain to the ACL, all as JSON. This deviates from proposal #107's
|
||||
"multipart on the portal→BFF hop": base64 JSON keeps **one** contract shape across all four services
|
||||
(no `IFormFile`/antiforgery plumbing, no multipart in the generated client), and a diploma is a small
|
||||
placeholder PDF, so the ~33% base64 overhead is immaterial. The ACL turns the base64 back into the
|
||||
ZGW `inhoud`.
|
||||
- **Storing precedes completing the wait.** `ProvideDocuments` (from S-10a) now stores the diploma via
|
||||
the ACL — once the zaak is opened — and then completes the `WachtOpDocumenten` task, so a registration
|
||||
reaches beoordeling only after its diploma is stored. Both steps stay best-effort about missing
|
||||
preconditions (no zaak yet → skip storage; no process yet → skip completion), mirroring withdrawal.
|
||||
- **Catalogus.** `seed_catalogus.py` (OZ_PUBLISH) creates a "Diploma" `informatieobjecttype`, relates it
|
||||
to the zaaktype (`zaaktype-informatieobjecttypen`, while both concept), publishes both, and prints
|
||||
`INFORMATIEOBJECTTYPE_URL`; verify-domain injects it as `Acl__Defaults__InformatieobjecttypeUrl`
|
||||
(a zeros-uuid placeholder otherwise, so the ACL still boots).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- §8.1 stays intact: the ACL is still the only ZGW caller; the portal only talks to the BFF; the domain
|
||||
only crosses the ACL boundary. Adding a document was almost entirely additive (one gateway method, one
|
||||
default, one seed block).
|
||||
- One JSON contract shape across portal/BFF/domain/ACL keeps the generated client and the service
|
||||
contracts uniform; the upload is exercised live (ACL integration test against real OpenZaak; the
|
||||
Playwright journey uploads a real PDF).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Base64 inflates the payload ~33% and holds the whole file in memory at each hop — fine for a small
|
||||
diploma, but not a pattern to reuse for large documents without streaming/multipart.
|
||||
- The zaak is **not** set to a cancellation status when the 30-day term lapses — carved to #106 (S-10c),
|
||||
which adds the cancellation statustype/resultaattype + ACL method + expiry-worker wiring.
|
||||
- Providing documents before the zaak is opened silently skips storage (best-effort); the e2e/live flow
|
||||
avoids this by uploading only after the openbaar register shows the zaak (INGEDIEND).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Multipart on the portal→BFF hop** (proposal #107). Rejected: it splits the transport into two shapes
|
||||
(multipart then JSON), needs `IFormFile` + antiforgery handling and a multipart method in the generated
|
||||
client, for no benefit at diploma size.
|
||||
- **The domain talks to the Documenten API directly.** Rejected outright: violates §8.1 (only the ACL
|
||||
talks to ZGW).
|
||||
@@ -1,81 +0,0 @@
|
||||
# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-21
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
|
||||
reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
|
||||
document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
|
||||
open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
|
||||
two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
|
||||
(§8.1, the only code that talks to ZGW).
|
||||
|
||||
The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
|
||||
The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
|
||||
ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
|
||||
closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
|
||||
|
||||
## Decision
|
||||
|
||||
**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
|
||||
resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
|
||||
(name) rather than by position or the eindstatus flag alone.**
|
||||
|
||||
- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
|
||||
eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
|
||||
resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
|
||||
approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
|
||||
zaaktype's single `selectielijstProcestype` so they validate on publish.
|
||||
- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
|
||||
+ `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
|
||||
before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
|
||||
`AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
|
||||
constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
|
||||
- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
|
||||
is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
|
||||
by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
|
||||
- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
|
||||
advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
|
||||
redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
|
||||
open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
|
||||
registration that lapsed before its zaak was opened has nothing to cancel.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
|
||||
- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
|
||||
the change is additive and §8 stays clean (only the ACL talks to ZGW).
|
||||
- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
|
||||
with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
|
||||
cancelled end-to-end.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
|
||||
cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
|
||||
domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
|
||||
fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
|
||||
`Vervallen` resultaat) is a possible follow-up.
|
||||
- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
|
||||
constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
|
||||
is documented in the gateway.
|
||||
- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
|
||||
OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
|
||||
cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
|
||||
the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
|
||||
explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
|
||||
- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
|
||||
zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
|
||||
the approval eindstatus resolution.
|
||||
- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
|
||||
ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
|
||||
@@ -1,92 +0,0 @@
|
||||
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
|
||||
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
|
||||
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
|
||||
|
||||
## Context
|
||||
|
||||
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
|
||||
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
|
||||
could not complete the flow:
|
||||
|
||||
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
|
||||
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
|
||||
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
|
||||
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
|
||||
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
|
||||
and the openbaar register stayed empty (S-06).
|
||||
|
||||
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
|
||||
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
|
||||
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
|
||||
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
|
||||
the compose file is written and cannot be a static value.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
|
||||
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
|
||||
|
||||
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
|
||||
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
|
||||
identical to the CI `flowable-init`. Idempotent.
|
||||
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
|
||||
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
|
||||
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
|
||||
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
|
||||
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
|
||||
compose placeholders before the app reads config. The ACL `depends_on: local-seed
|
||||
(service_completed_successfully)`.
|
||||
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
|
||||
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
|
||||
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
|
||||
cycle with the ACL bootstrap.
|
||||
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
|
||||
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
|
||||
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
|
||||
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
|
||||
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
|
||||
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
|
||||
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
|
||||
in the openbaar register — the red-to-green test for all three gaps.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
|
||||
the slice's stated outcome.
|
||||
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
|
||||
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
|
||||
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
|
||||
image and the CI stack are untouched.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
|
||||
Mitigated by reusing the same underlying scripts and cross-referencing them.
|
||||
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
|
||||
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
|
||||
running with a placeholder).
|
||||
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
|
||||
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
|
||||
idempotent re-seed reports current-IP URLs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
|
||||
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
|
||||
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
|
||||
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
|
||||
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
|
||||
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
|
||||
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
|
||||
browser. Rejected.
|
||||
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
|
||||
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
|
||||
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
|
||||
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
|
||||
@@ -1,67 +0,0 @@
|
||||
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
|
||||
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
|
||||
|
||||
## Context
|
||||
|
||||
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
|
||||
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
|
||||
the compose file is written — every stack had to seed the catalogus and then capture + inject the
|
||||
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed` → `acl.env` bootstrap
|
||||
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
|
||||
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
|
||||
|
||||
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl` → `ZaaktypeIdentificatie`
|
||||
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
|
||||
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…` →
|
||||
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
|
||||
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
|
||||
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
|
||||
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
|
||||
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
|
||||
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
|
||||
re-resolves.
|
||||
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
|
||||
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
|
||||
400.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
|
||||
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
|
||||
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
|
||||
self-configures from the catalogus it already talks to.
|
||||
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
|
||||
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
|
||||
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
|
||||
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
|
||||
(`http://openzaak:8000/…` → `zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
|
||||
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
|
||||
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
|
||||
covers it (§5).
|
||||
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
|
||||
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
|
||||
URLValidator) — unchanged from before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
|
||||
if the catalogus isn't published yet). Rejected in favour of lazy.
|
||||
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
|
||||
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
|
||||
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
|
||||
exactly what S-27 was carved out to fix.
|
||||
@@ -1,79 +0,0 @@
|
||||
# ADR-0022: Quartz.NET for time-triggered fleet sweeps
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-17 (#18) · **Proposal issue:** #120
|
||||
|
||||
## Context
|
||||
|
||||
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional
|
||||
must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day,
|
||||
scan the register for inscriptions whose deadline is within the reminder window and
|
||||
remind each one.
|
||||
|
||||
The Domain Service already runs periodic background work — `OpenZaakJobPump`,
|
||||
`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job
|
||||
pollers**: they drain Flowable's external-task/job queues at-least-once, picking up
|
||||
work as soon as it is parked, on a short poll interval. The reminder sweep is a
|
||||
different shape of work: **time-triggered**, once a day, over our own store — there
|
||||
is no queue to drain and no "as soon as possible" requirement.
|
||||
|
||||
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide
|
||||
sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new
|
||||
dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
|
||||
|
||||
## Decision
|
||||
|
||||
**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
|
||||
reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.**
|
||||
|
||||
- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily
|
||||
at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it
|
||||
resolves the pure `HerregistratieReminderSweep` (application layer) and logs how
|
||||
many reminders went out.
|
||||
- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`,
|
||||
which the store query and the sweep both build on. The sweep marks each reminded
|
||||
inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice
|
||||
(§8.6).
|
||||
|
||||
Two options were rejected:
|
||||
|
||||
1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it
|
||||
drifts to process-start time, has no cron/misfire semantics, and contradicts the
|
||||
PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is
|
||||
for.
|
||||
2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the
|
||||
pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue
|
||||
continuously" work is churn and a boundary change for negative benefit. The
|
||||
teachable distinction is worth keeping: **pumps drain queues; Quartz fires
|
||||
sweeps.**
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Cron scheduling with restart-stable timing and misfire handling, for free.
|
||||
- The reminder rule is one domain method, reused by the store query and the sweep;
|
||||
the scheduler owns none of the policy.
|
||||
- The reference app now demonstrates the intended Scheduler component.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service.
|
||||
- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model
|
||||
two genuinely different concerns, documented here.
|
||||
|
||||
**Follow-up**
|
||||
|
||||
- The validity term (5 years) and reminder lead time (16 weeks) are domain
|
||||
calibration knobs; promote them to beheer config (S-15) if a demo needs them
|
||||
per-catalogus.
|
||||
- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered
|
||||
store is a later concern if the Domain Service is scaled out.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. Quartz is internal to the Domain Service and drives an application use case
|
||||
over the store port. No ZGW or Flowable coupling is added; the sweep talks to no
|
||||
peer module.
|
||||
@@ -1,74 +0,0 @@
|
||||
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-16a (#122), first of the S-16 (#17) split
|
||||
|
||||
## Context
|
||||
|
||||
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
|
||||
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
|
||||
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
|
||||
backplane must stand up first: a local, CI-friendly place for traces and metrics to
|
||||
land, viewable in one UI, reaching green health within the 3-minute compose budget.
|
||||
|
||||
Two shape decisions are non-obvious enough to record.
|
||||
|
||||
## Decision
|
||||
|
||||
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
|
||||
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
|
||||
and ship the config baked into small built images.**
|
||||
|
||||
### Trace backend: Tempo (not Jaeger)
|
||||
|
||||
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
|
||||
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
|
||||
add a second UI and a second mental model for no benefit at this scale.
|
||||
|
||||
### No OTLP collector
|
||||
|
||||
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
|
||||
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
|
||||
If we later need fan-out, tail sampling, or log processing, a collector is an
|
||||
additive change — the services already speak OTLP.
|
||||
|
||||
### Config baked into built images, not config volumes
|
||||
|
||||
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
|
||||
**verbatim** images and get their config streamed into external named volumes by
|
||||
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
|
||||
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
|
||||
are **not** peer modules we must run verbatim, so we take the simpler path: a
|
||||
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
|
||||
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
|
||||
and no Makefile sprawl.
|
||||
|
||||
### Verified, not assumed
|
||||
|
||||
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
|
||||
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
|
||||
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
|
||||
method) — so the check proves the datasources are actually wired, not merely that
|
||||
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
|
||||
itself, so no in-image healthcheck tool is required.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One UI for traces + metrics + (future) logs. Config is versioned in
|
||||
`infra/observability/` and self-contained in the images.
|
||||
- Backplane is independent of app instrumentation — #123 and #124 build on it.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Three more images built each CI run (kept small; not on the health-gate list).
|
||||
- Storage is ephemeral container fs — a demo backplane, not a retention target.
|
||||
Object storage for Tempo / remote-write for Prometheus is a later concern.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. The stack is passive infrastructure: services *push* OTLP and *expose*
|
||||
`/metrics`; nothing in the stack calls into a service or a peer module.
|
||||
@@ -1,53 +0,0 @@
|
||||
# ADR-0024: Expose OTel metrics with the (prerelease) Prometheus AspNetCore exporter
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-16c (#124), last of the S-16 (#17) split
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0023 already fixed the shape of metrics collection: **Prometheus scrapes each
|
||||
service's `/metrics`** (pull, no collector). S-16c implements it. That needs a package
|
||||
that turns the OpenTelemetry `MeterProvider` into a Prometheus scrape endpoint inside
|
||||
ASP.NET Core. The canonical one is `OpenTelemetry.Exporter.Prometheus.AspNetCore`
|
||||
(`AddPrometheusExporter()` + `app.MapPrometheusScrapingEndpoint()`).
|
||||
|
||||
The catch: that exporter has **never had a stable release** — the whole OTel .NET
|
||||
Prometheus exporter line is versioned `-beta` (we pin `1.17.0-beta.1`, matched to the
|
||||
`1.17.0` core we already use). Adding it is a new dependency (CLAUDE.md §14), and taking
|
||||
a prerelease package into all five services is the decision worth recording.
|
||||
|
||||
## Decision
|
||||
|
||||
**Add `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` to the five .NET
|
||||
services and expose `/metrics` with it.**
|
||||
|
||||
- What it gives us: the OTel-native pull endpoint, so the meters we already register for
|
||||
tracing-adjacent instrumentation surface as Prometheus text with zero extra plumbing.
|
||||
- What we'd write to replace it: a hand-rolled `IMetricsListener`/`MeterListener` that
|
||||
formats Prometheus exposition text — real work, and a reimplementation of a widely-used
|
||||
library for no gain.
|
||||
- Risk it adds: a prerelease API that can shift between betas. Contained: it is only
|
||||
wired in `Program.cs` (two calls per service, excluded from mutation), the version is
|
||||
pinned, and `verify-metrics` proves the endpoint + scrape actually work each CI run.
|
||||
|
||||
The alternative — pushing metrics over OTLP to a collector that re-exposes them — was
|
||||
already rejected in ADR-0023 (no collector hop). Not revisited here.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Golden-signal metrics on `/metrics` with the standard OTel names
|
||||
(`http_server_request_duration_seconds`, `dotnet_*`), scraped straight by Prometheus.
|
||||
- No collector, no bespoke exposition code.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A `-beta` package in production services. Mitigated by the pin + the `verify-metrics`
|
||||
CI gate; upgrading tracks the OTel core version bumps.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. Metrics are passive: Prometheus pulls; no service calls into the stack.
|
||||
@@ -1,58 +0,0 @@
|
||||
# ADR-0025: The BFF reads the catalogus directly from the ACL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-15a (#130), first of the S-15 (#16) split
|
||||
|
||||
## Context
|
||||
|
||||
The beheer portal shows a read-only view of the ZTC catalogus (the published
|
||||
zaaktypen). Two coupling rules constrain where that data can come from:
|
||||
|
||||
- **§8.1** — only the ACL may talk to the ZGW APIs (Catalogi included). So the
|
||||
catalogus read *must* originate in the ACL.
|
||||
- **§8.3** — portals talk only to the BFF. So the portal reaches the ACL only
|
||||
through the BFF.
|
||||
|
||||
That leaves the question of *how the BFF gets the data*. Until now the BFF fanned
|
||||
out to exactly two backends — the Domain Service and the read projection. The
|
||||
catalogus is neither: it is not a registration (domain) nor a projected read model.
|
||||
|
||||
## Decision
|
||||
|
||||
**The BFF calls the ACL directly for the beheer catalogus read** — a new typed
|
||||
`IAclClient` (`GET /catalogi/zaaktypen`), configured by `Downstream:Acl:BaseUrl`,
|
||||
mirroring the existing `IDomainClient` / `IProjectionClient` pattern.
|
||||
|
||||
Rejected alternative — **route it through the Domain Service** (BFF → domain →
|
||||
ACL): the catalogus is not a domain concern, so the domain would gain a
|
||||
pass-through endpoint that owns no aggregate and no invariant, blurring the
|
||||
domain's responsibility purely to avoid a new edge. That is worse coupling, not
|
||||
better.
|
||||
|
||||
This adds one service-to-service edge (BFF → ACL) — an architecturally
|
||||
significant boundary change (§14), hence this ADR. It does **not** bend §8: the
|
||||
ACL stays the only code that reads ZGW, and the portal still talks only to the
|
||||
BFF. The ACL endpoint is a plain read that trusts its callers (§8.3); the
|
||||
beheerder authorization lives at the BFF (medewerker realm + `beheerder` role).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The catalogus read follows the shortest honest path; the domain stays about
|
||||
registrations.
|
||||
- Symmetric with the other downstream clients — nothing new to learn.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The BFF now depends on three backends instead of two. The ACL must be reachable
|
||||
for the beheer portal to load (it already is — the BFF is on the same network).
|
||||
- A second consumer of the ACL (alongside the domain and event-subscriber), so
|
||||
ACL read endpoints are now part of more than one caller's contract.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
A new BFF → ACL edge. §8.1 and §8.3 remain intact; §14 (boundary change) is the
|
||||
reason this ADR exists.
|
||||
@@ -1,61 +0,0 @@
|
||||
# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-15b (#131), second of the S-15 (#16) split
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every
|
||||
zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an
|
||||
immutable singleton). S-15b lets a beheerder **edit** those values from the portal
|
||||
and have the next zaak reflect them — so the defaults must become mutable at runtime.
|
||||
|
||||
Two questions: **what** is editable, and **where** the mutable state lives.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the three ZGW default-fill fields a runtime-mutable, in-memory store
|
||||
(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per
|
||||
zaak; the beheer `PUT /default-fill` replaces it.**
|
||||
|
||||
### Only the three ZGW fill fields are editable
|
||||
|
||||
`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`,
|
||||
`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache
|
||||
(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache
|
||||
and is catalogus *wiring*, not "default fill". So they **stay static config** and are
|
||||
out of scope for the CRUD. The editable set is exactly `Bronorganisatie`,
|
||||
`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`).
|
||||
|
||||
### In-memory, not persisted
|
||||
|
||||
The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it
|
||||
reverts to the configured env. That is acceptable for this reference app: the slice
|
||||
demonstrates the *pattern* (beheer edits config that the ACL honours), not durable
|
||||
config management. The ACL stays stateless — no DB, no EF, no migration, no extra
|
||||
compose service.
|
||||
|
||||
- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency.
|
||||
- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable,
|
||||
audited, multi-instance config is needed — the port stays the same.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts.
|
||||
- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Edits don't survive a restart and aren't shared across replicas (single-instance
|
||||
assumption). Documented ceiling above.
|
||||
- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the
|
||||
store) — a deliberate split by editability.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it.
|
||||
The ACL remains the owner of the ZGW-facing config.
|
||||
@@ -5,212 +5,6 @@ copy-pasteable walkthrough against a local `make up` stack.
|
||||
|
||||
---
|
||||
|
||||
## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026)
|
||||
|
||||
**Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie,
|
||||
verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak
|
||||
is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill`
|
||||
(beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per
|
||||
zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the
|
||||
zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart.
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan.
|
||||
open http://localhost:8143/default-fill
|
||||
#
|
||||
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
||||
# beheerder role (BFF unit):
|
||||
# Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update
|
||||
# Bff.Tests → BeheerDefaultFillEndpointTests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
|
||||
|
||||
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
|
||||
the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE
|
||||
zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm +
|
||||
`beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL
|
||||
directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next.
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen.
|
||||
open http://localhost:8143
|
||||
#
|
||||
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
||||
make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus"
|
||||
#
|
||||
# 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests):
|
||||
# Bff.Tests → BeheerEndpointTests.
|
||||
```
|
||||
|
||||
**Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm
|
||||
(`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its
|
||||
realm-role lifting, requiring `beheerder` rather than `behandelaar`.
|
||||
|
||||
---
|
||||
|
||||
## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023)
|
||||
|
||||
**Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics`
|
||||
— ASP.NET Core + `HttpClient` instrumentation plus the built-in `System.Runtime` meter. Prometheus
|
||||
scrapes each service (one job per service), and a **pre-built Grafana dashboard** — *Request path —
|
||||
golden signals* — plots the four golden signals: **traffic** (req/s), **errors** (5xx/s), **latency**
|
||||
(p95 request duration), and **saturation** (CPU cores in use), split by service. It populates under load.
|
||||
|
||||
```bash
|
||||
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Prometheus scraped the
|
||||
# golden-signal metric from every service.
|
||||
make verify-metrics # → OK — targets up: [...]; request metric scraped from: [...]
|
||||
|
||||
# 2. By hand: drive the stack, generate some load, then open the dashboard.
|
||||
make up
|
||||
for i in $(seq 1 50); do curl -s localhost:8080/openbaar/register >/dev/null; done # BFF → projection-api
|
||||
open http://localhost:3000 # Grafana → Dashboards → "Request path — golden signals"
|
||||
open http://localhost:9090/targets # Prometheus → every service target UP
|
||||
```
|
||||
|
||||
**The path:** each host adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation +
|
||||
AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`; Prometheus scrapes
|
||||
`<service>:8080/metrics` (config in `infra/observability/prometheus/prometheus.yml`); Grafana ships the
|
||||
dashboard via provisioning against the fixed `prometheus` datasource uid. No metrics are pushed over
|
||||
OTLP — Prometheus pulls, so there is no collector hop (ADR-0023).
|
||||
|
||||
---
|
||||
|
||||
## S-16b — distributed traces across the .NET services (#123, ADR-0023)
|
||||
|
||||
**Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit
|
||||
OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo.
|
||||
Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for
|
||||
free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak;
|
||||
bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace
|
||||
begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023).
|
||||
|
||||
```bash
|
||||
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace
|
||||
# spanning multiple services.
|
||||
make verify-tracing # → OK — trace <id> spans ['bff', 'projection-api']
|
||||
|
||||
# 2. By hand: drive the stack, then explore traces in Grafana.
|
||||
make up
|
||||
curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api
|
||||
open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace
|
||||
```
|
||||
|
||||
**The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation +
|
||||
AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||
come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo
|
||||
datasource.
|
||||
|
||||
---
|
||||
|
||||
## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023)
|
||||
|
||||
**Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP
|
||||
trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned.
|
||||
Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the
|
||||
backplane up and proves Grafana can reach both datasources. Config is baked into small built images
|
||||
(`infra/observability/`) — no collector, no config-volume seeding.
|
||||
|
||||
```bash
|
||||
# 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus
|
||||
# datasources reachable through Grafana). This is a CI verify-stack step.
|
||||
make up
|
||||
make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable
|
||||
|
||||
# 2. Or just the backplane, no full stack needed (no external egress):
|
||||
docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana
|
||||
open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo
|
||||
open http://localhost:9090 # Prometheus
|
||||
```
|
||||
|
||||
**The path:** services will export OTLP → **Tempo:4317** and expose `/metrics` ← **Prometheus** scrapes;
|
||||
**Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`.
|
||||
|
||||
---
|
||||
|
||||
## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022)
|
||||
|
||||
**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from
|
||||
which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job
|
||||
in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription
|
||||
inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged.
|
||||
The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism
|
||||
from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues,
|
||||
ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a
|
||||
log line.
|
||||
|
||||
```bash
|
||||
# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep):
|
||||
cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \
|
||||
--filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep"
|
||||
# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped
|
||||
# on the next sweep; the sweep flags + persists every due inscription and returns their ids.
|
||||
|
||||
# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on:
|
||||
curl -s localhost:8000/registrations/<id> | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}'
|
||||
# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept.
|
||||
```
|
||||
|
||||
**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob`
|
||||
→ `HerregistratieReminderSweep` → `IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by
|
||||
the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log.
|
||||
|
||||
---
|
||||
|
||||
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
|
||||
|
||||
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
|
||||
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
|
||||
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
|
||||
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
|
||||
empty, and the openbaar register showed nothing.)
|
||||
|
||||
```bash
|
||||
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
|
||||
make local
|
||||
|
||||
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
|
||||
# the werkbak, and the reference appears in the openbaar register:
|
||||
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
|
||||
|
||||
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
||||
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||
```
|
||||
|
||||
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
|
||||
> single-label host on zaak-create (ADR-0020 + ADR-0021).
|
||||
|
||||
---
|
||||
|
||||
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
|
||||
|
||||
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
|
||||
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
|
||||
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
|
||||
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
|
||||
|
||||
```bash
|
||||
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
|
||||
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
|
||||
|
||||
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
|
||||
make verify-local # local stack — still green, now with no zaaktype-URL injection
|
||||
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
|
||||
```
|
||||
|
||||
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
|
||||
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
|
||||
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
|
||||
|
||||
---
|
||||
|
||||
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
||||
|
||||
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
||||
@@ -481,210 +275,3 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
|
||||
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
||||
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
||||
> drives the behandel portal in place of the old admin endpoint.
|
||||
|
||||
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
|
||||
|
||||
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
|
||||
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
|
||||
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
|
||||
behandelaar's werkbak.
|
||||
|
||||
```text
|
||||
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
|
||||
# note the "Referentie" on the confirmation.
|
||||
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
|
||||
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
|
||||
# appears — its Beoordelen task was cancelled.
|
||||
```
|
||||
|
||||
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
|
||||
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
|
||||
interrupting boundary event ends it → the werkbak drops the case.
|
||||
|
||||
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
|
||||
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
||||
|
||||
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
|
||||
|
||||
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
|
||||
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
|
||||
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
|
||||
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
|
||||
its identity throughout — only who may claim it changes.
|
||||
|
||||
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
|
||||
verify-domain check automates):
|
||||
|
||||
```bash
|
||||
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
|
||||
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
|
||||
# but NOT claimed.
|
||||
#
|
||||
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
|
||||
FL=http://localhost:8090/flowable-rest/service
|
||||
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"execute"}'
|
||||
#
|
||||
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
|
||||
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
|
||||
```
|
||||
|
||||
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren`
|
||||
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
|
||||
candidate group behandelaar → teamlead (§8.2).
|
||||
|
||||
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the
|
||||
> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
|
||||
> and reassignment are asserted live by the verify-domain check.
|
||||
|
||||
## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016)
|
||||
|
||||
A registration's diploma origin decides its route. A DMN service task in the registratie
|
||||
process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a
|
||||
**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before
|
||||
beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the
|
||||
DMN, not in code — a beheerder can read and adjust the decision table directly.
|
||||
|
||||
The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to
|
||||
the domain directly, so the demo drives it through the domain endpoint:
|
||||
|
||||
```bash
|
||||
# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference):
|
||||
DOM=http://localhost:8080 # domain service
|
||||
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
|
||||
#
|
||||
# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task
|
||||
# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
|
||||
FL=http://localhost:8090/flowable-rest/service
|
||||
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
|
||||
#
|
||||
# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task:
|
||||
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"complete"}'
|
||||
# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen.
|
||||
```
|
||||
|
||||
**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN
|
||||
DMN service task sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`;
|
||||
domestic: `Beoordelen` directly (§8.2, ADR-0016).
|
||||
|
||||
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
|
||||
> scenarios and unit tests (the origin is carried into the process); the DMN decision and the
|
||||
> foreign→CBGV routing are asserted live by the verify-domain check.
|
||||
|
||||
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
|
||||
|
||||
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
|
||||
for the citizen's documents (their diploma). Two things can happen:
|
||||
|
||||
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
|
||||
routing (S-13) → beoordeling.
|
||||
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
|
||||
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
|
||||
**VERLOPEN** (the case is cancelled).
|
||||
|
||||
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
|
||||
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
|
||||
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
|
||||
demonstrated by firing the 30-day timer early via the management API.
|
||||
|
||||
```bash
|
||||
DOM=http://localhost:8080 # domain service
|
||||
FL=http://localhost:8090/flowable-rest/service # flowable-rest
|
||||
|
||||
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
|
||||
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
|
||||
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
|
||||
|
||||
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
|
||||
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"complete"}'
|
||||
|
||||
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
|
||||
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
|
||||
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||
JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
|
||||
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
|
||||
```
|
||||
|
||||
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
|
||||
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
|
||||
→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017).
|
||||
|
||||
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
|
||||
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
|
||||
> verify-domain check.
|
||||
|
||||
## S-10b — Diploma upload stored in the ZGW Documenten API (#103, ADR-0018)
|
||||
|
||||
The self-service "Documenten aanleveren" action (S-10a) is now a **real file upload**: after submitting,
|
||||
the citizen picks a PDF and uploads it. The portal base64-encodes the file client-side and posts it to
|
||||
the BFF; the BFF forwards it to the domain, which stores it via the **ACL** as a ZGW
|
||||
`enkelvoudiginformatieobject` in the **Documenten (DRC) API** and relates it to the zaak — then completes
|
||||
the `WachtOpDocumenten` wait so beoordeling can proceed. Per §8.1 only the ACL talks to ZGW.
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. Log in as jan-burger / test123, submit, then — once the openbaar register shows the row —
|
||||
# choose a PDF under "Documenten aanleveren" and upload it. The page confirms "aangeleverd".
|
||||
open http://localhost:8140
|
||||
#
|
||||
# 2. Automated: the walking-skeleton e2e now uploads a real PDF before the behandelaar approves.
|
||||
make verify-e2e
|
||||
#
|
||||
# 3. The ACL integration test proves the document is really created in the Documenten API and
|
||||
# related to the zaak (against a live OpenZaak):
|
||||
make verify-acl # → "Storing a diploma creates a real informatieobject related to the zaak"
|
||||
```
|
||||
|
||||
**The path:** portal (base64) → BFF `POST /self-service/registrations/{id}/documents` → domain
|
||||
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
|
||||
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
|
||||
|
||||
## S-10c — the ZGW zaak is cancelled when the document term lapses (#106)
|
||||
|
||||
When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen` —
|
||||
it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set
|
||||
to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the
|
||||
approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019).
|
||||
|
||||
```bash
|
||||
# 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat
|
||||
# against a live OpenZaak:
|
||||
make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat"
|
||||
#
|
||||
# 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts
|
||||
# the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd):
|
||||
make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
|
||||
```
|
||||
|
||||
**The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL
|
||||
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
|
||||
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
|
||||
job for redelivery rather than diverging the two (ADR-0019).
|
||||
|
||||
@@ -151,19 +151,3 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
|
||||
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
||||
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||
path.
|
||||
|
||||
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
|
||||
|
||||
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
|
||||
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
|
||||
|
||||
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
|
||||
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
|
||||
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
|
||||
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
|
||||
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
|
||||
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
|
||||
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
|
||||
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
|
||||
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
|
||||
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
|
||||
|
||||
@@ -196,52 +196,3 @@ service name; the notif verify harness also registers the sink callback by IP.
|
||||
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
|
||||
**401** without the configured `Authorization`. The verify sink
|
||||
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
|
||||
|
||||
---
|
||||
|
||||
## 7. A job with `if: ${{ !cancelled() }}` (or `always()`) + `needs` sticks in "waiting"
|
||||
|
||||
**Symptom** — after upgrading to **Gitea 1.27** + **act_runner 2.0.0**, one job never
|
||||
starts: the run sits in state `waiting` forever, the job has **no logs** (never
|
||||
dispatched to a runner), and the other jobs finish normally. `main` stays pending/red.
|
||||
Seen on the `verify-stack` job (#134).
|
||||
|
||||
**Why** — Gitea 1.27 reworked cancellation/aggregation: a job gated by a
|
||||
**status-function `if`** (`always()` / `cancelled()` / `!cancelled()`) on top of
|
||||
`needs` now routes through a new transitional **`Cancelling`** job state plus a
|
||||
server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On the
|
||||
1.27 + 2.0.0 pairing that handshake doesn't resolve for such a job, so it's never
|
||||
offered to a runner and never leaves `waiting`. Jobs with no `if`/`needs` are
|
||||
unaffected. (Related upstream: go-gitea/gitea#31074, #27116, #35782.)
|
||||
|
||||
**Fix** — don't gate a `needs` job with a status-function `if`. Use the default
|
||||
`if: success()` (i.e. omit the `if`). If you need "run even when an upstream job
|
||||
fails", prefer serialising with a `concurrency` group over `needs` + `always()`.
|
||||
|
||||
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
|
||||
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
|
||||
workflow fix to produce a fresh run.
|
||||
|
||||
---
|
||||
|
||||
## 8. Job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea ≥1.27 + runner ≥2.0
|
||||
|
||||
Markdown a step appends to the `$GITHUB_STEP_SUMMARY` file renders on the run page
|
||||
(no artifact download). We use it for per-run reports (#136): mutation scores
|
||||
(Stryker `markdown` reporter), per-service unit results (`infra/trx-summary.py` over
|
||||
TRX), per-frontend results (`infra/vitest-summary.py` over each app's vitest JSON),
|
||||
the verify-stack check table, and per-spec e2e results (`infra/playwright-summary.py`).
|
||||
|
||||
**Requirements / conventions:**
|
||||
|
||||
- Requires **Gitea ≥ 1.27** (stores/renders summaries) and **act_runner ≥ 2.0.0**
|
||||
(uploads them). Older pairings silently skip the upload.
|
||||
- **Guard every write:** `[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0` — on a runner
|
||||
without support the var is unset and `>> "$GITHUB_STEP_SUMMARY"` would be an
|
||||
ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
|
||||
- Use `if: always()` (step-level) on summary steps so they render even when the thing
|
||||
they report on failed. Step-level `always()` is fine on 2.0.0 — unlike the *job*-level
|
||||
status-function `if` of §7.
|
||||
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
|
||||
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
|
||||
(capturing the test exit code first) so the summary step can read it.
|
||||
|
||||
@@ -14,7 +14,6 @@ All test users share the password **`test123`**.
|
||||
| Realm | Mimics | User | Identifying claim |
|
||||
|---|---|---|---|
|
||||
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
|
||||
| `digid` | DigiD (burgers) | `sanne-burger` | `bsn` = `231477813` (S-26 resume e2e — its own user so it can leave an open registration) |
|
||||
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
|
||||
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
||||
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
||||
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||
# macOS, or rootless Podman on Linux).
|
||||
#
|
||||
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
|
||||
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
|
||||
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
|
||||
# completes the whole flow with no manual steps. `make verify-local` asserts it.
|
||||
#
|
||||
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
||||
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
||||
# docker compose -f infra/docker-compose.local.yml down --volumes
|
||||
@@ -22,12 +17,7 @@
|
||||
#
|
||||
# Port map (host):
|
||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
|
||||
#
|
||||
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
|
||||
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
|
||||
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
|
||||
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||
|
||||
services:
|
||||
|
||||
@@ -215,12 +205,6 @@ services:
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
|
||||
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
|
||||
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
|
||||
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
|
||||
KC_HOSTNAME: http://localhost:8180
|
||||
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||
ports:
|
||||
- "8180:8080"
|
||||
volumes:
|
||||
@@ -262,79 +246,38 @@ services:
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
||||
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||
# must go via dmn-api. The registratie process's DMN service task then resolves the decision across
|
||||
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
|
||||
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
|
||||
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||
echo "diploma-eligibility DMN already deployed; skip"
|
||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||
fi
|
||||
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie BPMN already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
||||
fi
|
||||
depends_on:
|
||||
flowable-rest:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
|
||||
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
|
||||
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
|
||||
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
|
||||
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
|
||||
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
|
||||
local-seed:
|
||||
image: docker.io/library/python:3-slim
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
|
||||
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
|
||||
- seed-env:/out
|
||||
command: ["sh", "/work/seed-zaaktype.sh"]
|
||||
depends_on:
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── ACL ──────────────────────────────────────────────────────────────────
|
||||
acl:
|
||||
build:
|
||||
context: ../services/acl
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business
|
||||
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
|
||||
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
|
||||
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
|
||||
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
|
||||
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
|
||||
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
|
||||
environment:
|
||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
|
||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
||||
Acl__OpenZaak__ClientId: big-reference-seed
|
||||
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||
ports:
|
||||
- "8100:8080"
|
||||
volumes:
|
||||
- seed-env:/seed:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
@@ -344,8 +287,6 @@ services:
|
||||
depends_on:
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
local-seed:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── BFF ──────────────────────────────────────────────────────────────────
|
||||
@@ -354,14 +295,6 @@ services:
|
||||
context: ../services/bff
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/bff:dev
|
||||
environment:
|
||||
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
|
||||
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
|
||||
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
|
||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
@@ -370,39 +303,6 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
domain:
|
||||
condition: service_healthy
|
||||
projection-api:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
|
||||
domain:
|
||||
build:
|
||||
context: ../services/domain
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/domain:dev
|
||||
environment:
|
||||
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||
Flowable__Username: rest-admin
|
||||
Flowable__Password: test
|
||||
Acl__BaseUrl: http://acl:8080/
|
||||
ports:
|
||||
- "8130:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
acl:
|
||||
condition: service_healthy
|
||||
flowable-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||
@@ -428,10 +328,6 @@ services:
|
||||
image: register-referentie/event-subscriber:dev
|
||||
environment:
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
|
||||
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
|
||||
# canonical compose).
|
||||
Acl__BaseUrl: http://acl:8080/
|
||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||
ports:
|
||||
- "8110:8080"
|
||||
@@ -444,33 +340,6 @@ services:
|
||||
depends_on:
|
||||
projection-db:
|
||||
condition: service_healthy
|
||||
acl:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
|
||||
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
|
||||
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
|
||||
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
|
||||
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
|
||||
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
|
||||
# for the event-subscriber without creating a cycle with the ACL bootstrap.
|
||||
nrc-subscribe:
|
||||
image: docker.io/library/python:3-slim
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
|
||||
environment:
|
||||
NRC_BASE: http://nrc-web:8000
|
||||
SINK_HOST: event-subscriber
|
||||
SINK_PORT: "8080"
|
||||
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||
command: ["python", "/work/register-abonnement.py"]
|
||||
depends_on:
|
||||
nrc-web:
|
||||
condition: service_healthy
|
||||
event-subscriber:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
projection-api:
|
||||
@@ -493,80 +362,11 @@ services:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
|
||||
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
|
||||
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
|
||||
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
|
||||
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
|
||||
self-service:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/self-service/Dockerfile
|
||||
image: register-referentie/self-service:dev
|
||||
ports:
|
||||
- "8140:80"
|
||||
volumes:
|
||||
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
openbaar:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/openbaar/Dockerfile
|
||||
image: register-referentie/openbaar:dev
|
||||
ports:
|
||||
- "8141:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
behandel:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/behandel/Dockerfile
|
||||
image: register-referentie/behandel:dev
|
||||
ports:
|
||||
- "8142:80"
|
||||
volumes:
|
||||
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
flowable-db:
|
||||
projection-db:
|
||||
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||
seed-env:
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
+14
-118
@@ -15,12 +15,12 @@
|
||||
#
|
||||
# docker compose -f infra/docker-compose.yml up -d --build --wait
|
||||
#
|
||||
# After first boot, seed + publish the BIG catalogus:
|
||||
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
|
||||
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
|
||||
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
|
||||
# single-label host is rejected):
|
||||
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
|
||||
# After first boot, seed the BIG catalogus and note the zaaktype URL:
|
||||
# python infra/openzaak/seed_catalogus.py
|
||||
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
|
||||
# service:
|
||||
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
|
||||
# docker compose -f infra/docker-compose.yml up -d acl
|
||||
|
||||
services:
|
||||
|
||||
@@ -259,30 +259,19 @@ services:
|
||||
flowable-init:
|
||||
image: docker.io/curlimages/curl:latest
|
||||
restart: "no"
|
||||
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by
|
||||
# infra/seed-config.sh.
|
||||
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
|
||||
volumes:
|
||||
- fl-bpmn:/work:ro
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
|
||||
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
|
||||
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||
echo "diploma-eligibility DMN already deployed; skip"
|
||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||
fi
|
||||
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie BPMN already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
||||
fi
|
||||
depends_on:
|
||||
flowable-rest:
|
||||
@@ -296,10 +285,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: acl
|
||||
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
|
||||
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
|
||||
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
|
||||
@@ -308,12 +293,8 @@ services:
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
|
||||
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
|
||||
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
|
||||
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
# Override with the real zaaktype URL after running seed_catalogus.py.
|
||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||
ports:
|
||||
- "8100:8080"
|
||||
healthcheck:
|
||||
@@ -338,10 +319,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/domain:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: domain
|
||||
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||
Flowable__Username: rest-admin
|
||||
Flowable__Password: test
|
||||
@@ -368,10 +345,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/bff:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: bff
|
||||
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
|
||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||
@@ -380,8 +353,6 @@ services:
|
||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
# The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025).
|
||||
Downstream__Acl__BaseUrl: http://acl:8080/
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
@@ -426,10 +397,6 @@ services:
|
||||
dockerfile: services/event-subscriber/Dockerfile
|
||||
image: register-referentie/event-subscriber:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: event-subscriber
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
|
||||
# the ACL — the only code allowed to read ZGW (§8.1, #78).
|
||||
@@ -459,10 +426,6 @@ services:
|
||||
dockerfile: services/projection-api/Dockerfile
|
||||
image: register-referentie/projection-api:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: projection-api
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
ports:
|
||||
- "8120:8080"
|
||||
@@ -546,73 +509,6 @@ services:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# The beheer portal: nginx serves the Angular app and reverse-proxies /beheer to the BFF.
|
||||
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
|
||||
beheer:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/beheer/Dockerfile
|
||||
image: register-referentie/beheer:dev
|
||||
ports:
|
||||
- "8143:80"
|
||||
healthcheck:
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
||||
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
||||
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
||||
# /metrics (S-16c), and Grafana reads both with datasources auto-provisioned.
|
||||
# Config is baked into small built images (COPY) rather than streamed into
|
||||
# external config volumes like the upstream CG modules — these aren't verbatim
|
||||
# peer images, so a built image is the simpler path that still reaches sibling
|
||||
# containers on the CI runner. Not in WAIT_SVCS: run-observability-check.sh
|
||||
# polls Grafana itself, so no in-image healthcheck tool is needed.
|
||||
tempo:
|
||||
build:
|
||||
context: ./observability/tempo
|
||||
image: register-referentie/tempo:dev
|
||||
command: ["-config.file=/etc/tempo.yaml"]
|
||||
# Cap the backplane's footprint so it can't starve the app stack + the Playwright browser on the
|
||||
# memory-tight CI runner (verify-e2e OOM history, commit d5e5fa2). Generous vs idle (~150M).
|
||||
mem_limit: 400m
|
||||
networks: [cg]
|
||||
|
||||
prometheus:
|
||||
build:
|
||||
context: ./observability/prometheus
|
||||
image: register-referentie/prometheus:dev
|
||||
mem_limit: 400m
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks: [cg]
|
||||
|
||||
grafana:
|
||||
build:
|
||||
context: ./observability/grafana
|
||||
image: register-referentie/grafana:dev
|
||||
mem_limit: 512m
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- tempo
|
||||
- prometheus
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
|
||||
@@ -35,35 +35,24 @@ services:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# Deploys registratie.bpmn (process engine) and diploma-eligibility.dmn (DMN engine) via the REST
|
||||
# API once flowable-rest is up. Idempotent: skips each if already deployed.
|
||||
# Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
|
||||
# Idempotent: skips if a deployment named "registratie" already exists.
|
||||
flowable-init:
|
||||
image: docker.io/curlimages/curl:latest
|
||||
restart: "no"
|
||||
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by
|
||||
# infra/seed-config.sh.
|
||||
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
|
||||
volumes:
|
||||
- fl-bpmn:/work:ro
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
|
||||
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
|
||||
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||
echo "diploma-eligibility DMN already deployed; skip"
|
||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||
fi
|
||||
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie BPMN already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
||||
fi
|
||||
depends_on:
|
||||
flowable-rest:
|
||||
|
||||
@@ -38,36 +38,6 @@
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["123456782"] }
|
||||
},
|
||||
{
|
||||
"username": "sanne-burger",
|
||||
"enabled": true,
|
||||
"firstName": "Sanne",
|
||||
"lastName": "Burger",
|
||||
"email": "sanne.burger@example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["231477813"] }
|
||||
},
|
||||
{
|
||||
"username": "emma-burger",
|
||||
"enabled": true,
|
||||
"firstName": "Emma",
|
||||
"lastName": "Burger",
|
||||
"email": "emma.burger@example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["231477805"] }
|
||||
},
|
||||
{
|
||||
"username": "lars-burger",
|
||||
"enabled": true,
|
||||
"firstName": "Lars",
|
||||
"lastName": "Burger",
|
||||
"email": "lars.burger@example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["231477821"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
|
||||
{ "name": "teamlead", "description": "Teamleider behandeling" },
|
||||
{ "name": "beheerder", "description": "Beheert catalogus en default-fill (beheer-portal, S-15)" }
|
||||
{ "name": "teamlead", "description": "Teamleider behandeling" }
|
||||
]
|
||||
},
|
||||
"clients": [
|
||||
@@ -55,16 +54,6 @@
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"realmRoles": ["behandelaar", "teamlead"]
|
||||
},
|
||||
{
|
||||
"username": "bram-beheerder",
|
||||
"enabled": true,
|
||||
"firstName": "Bram",
|
||||
"lastName": "Beheerder",
|
||||
"email": "bram@big.example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"realmRoles": ["beheerder"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/medewerker"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/digid"
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
|
||||
|
||||
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
|
||||
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
|
||||
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
|
||||
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
|
||||
the test zaak it also creates).
|
||||
|
||||
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
|
||||
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
|
||||
zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications
|
||||
abonnement first, then registers one for the current IP. Stdlib only.
|
||||
|
||||
Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
|
||||
"""
|
||||
import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request
|
||||
|
||||
NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/")
|
||||
SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber")
|
||||
SINK_PORT = os.environ.get("SINK_PORT", "8080")
|
||||
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
|
||||
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||
|
||||
|
||||
def token():
|
||||
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||
seg = (
|
||||
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
||||
+ b"."
|
||||
+ b64(json.dumps(
|
||||
{"iss": CID, "iat": int(time.time()), "client_id": CID,
|
||||
"user_id": "local-seed", "user_representation": "local-seed"},
|
||||
separators=(",", ":")).encode())
|
||||
)
|
||||
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
|
||||
|
||||
|
||||
def call(method, url, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Authorization": "Bearer " + token(),
|
||||
"Content-Type": "application/json", "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
return e.code, (json.loads(raw) if raw else None)
|
||||
|
||||
|
||||
def main():
|
||||
ip = socket.gethostbyname(SINK_HOST)
|
||||
callback = f"http://{ip}:{SINK_PORT}/notifications"
|
||||
|
||||
# Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a
|
||||
# fresh one for the current event-subscriber IP.
|
||||
status, body = call("GET", f"{NRC}/api/v1/abonnement")
|
||||
for ab in (body or []) if status == 200 else []:
|
||||
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
|
||||
if ab.get("callbackUrl") == callback:
|
||||
print(f"abonnement already current: {ab['url']}")
|
||||
return
|
||||
call("DELETE", ab["url"])
|
||||
print(f"removed stale abonnement {ab['url']}")
|
||||
|
||||
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
||||
"callbackUrl": callback, "auth": SINK_AUTH,
|
||||
"kanalen": [{"naam": "zaken", "filters": {}}]})
|
||||
if status != 201:
|
||||
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
||||
print(f"abonnement registered: {ab['url']} -> {callback}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step.
|
||||
#
|
||||
# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes
|
||||
# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting
|
||||
# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This
|
||||
# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is
|
||||
# assigned by OpenZaak at creation, so it can't be a static value in the compose file.
|
||||
#
|
||||
# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params
|
||||
# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`.
|
||||
# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which
|
||||
# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020.
|
||||
set -eu
|
||||
|
||||
oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")"
|
||||
OZ_BASE="http://${oz_ip}:8000"
|
||||
export OZ_BASE OZ_PUBLISH=1
|
||||
|
||||
echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
|
||||
out="$(python3 /work/seed_catalogus.py)"
|
||||
echo "$out"
|
||||
|
||||
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27).
|
||||
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||
|
||||
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it
|
||||
# still needs injected is the OpenZaak base URL at a URL-valid host (the container IP), because OpenZaak
|
||||
# rejects a single-label host on zaak-create. The ACL entrypoint sources this.
|
||||
cat > /out/acl.env <<EOF
|
||||
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
|
||||
EOF
|
||||
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)"
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-16c (#124): prove the golden-signal metrics pipeline works end to end.
|
||||
|
||||
Generate anonymous BFF traffic (GET /openbaar/register — no auth, no OpenZaak egress),
|
||||
then query Prometheus and assert (1) every .NET service's scrape target is UP, and (2)
|
||||
the http.server.request.duration histogram is actually being scraped — i.e. the services
|
||||
expose /metrics AND Prometheus collects it, which is exactly what the golden-signal
|
||||
dashboard reads.
|
||||
|
||||
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BFF = os.environ["BFF"] # http://<bff-ip>:8080
|
||||
PROM = os.environ["PROMETHEUS"] # http://<prometheus-ip>:9090
|
||||
TIMEOUT = int(os.environ.get("METRICS_TIMEOUT", "90"))
|
||||
SERVICES = {"acl", "domain", "bff", "event-subscriber", "projection-api"}
|
||||
|
||||
|
||||
def _get(url):
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def generate_traffic():
|
||||
for _ in range(3):
|
||||
try:
|
||||
_get(f"{BFF}/openbaar/register")
|
||||
except urllib.error.HTTPError:
|
||||
pass # a non-2xx still records an http.server metric
|
||||
|
||||
|
||||
def query(promql):
|
||||
q = urllib.parse.quote(promql)
|
||||
try:
|
||||
data = json.loads(_get(f"{PROM}/api/v1/query?query={q}"))
|
||||
except Exception:
|
||||
return []
|
||||
return data.get("data", {}).get("result", [])
|
||||
|
||||
|
||||
def jobs_up():
|
||||
return {r["metric"].get("job") for r in query("up == 1")}
|
||||
|
||||
|
||||
def jobs_with_request_metric():
|
||||
return {r["metric"].get("job")
|
||||
for r in query("http_server_request_duration_seconds_count")}
|
||||
|
||||
|
||||
def main():
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline:
|
||||
generate_traffic()
|
||||
up = jobs_up()
|
||||
scraped = jobs_with_request_metric()
|
||||
if SERVICES.issubset(up) and SERVICES.issubset(scraped):
|
||||
print(f"OK — targets up: {sorted(up & SERVICES)}; "
|
||||
f"request metric scraped from: {sorted(scraped & SERVICES)}")
|
||||
return 0
|
||||
time.sleep(3)
|
||||
print(f"FAIL — up: {sorted(jobs_up() & SERVICES)}; "
|
||||
f"request metric from: {sorted(jobs_with_request_metric() & SERVICES)}; "
|
||||
f"expected all of {sorted(SERVICES)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,4 +0,0 @@
|
||||
# Grafana with datasources + the golden-signals dashboard baked in via provisioning
|
||||
# (S-16a/S-16c, ADR-0023). Everything under provisioning/ is copied in below.
|
||||
FROM grafana/grafana:11.3.0
|
||||
COPY provisioning/ /etc/grafana/provisioning/
|
||||
@@ -1,13 +0,0 @@
|
||||
# Dashboard provider (S-16c, ADR-0023): Grafana loads every *.json in this folder as a
|
||||
# read-only, code-owned dashboard. The golden-signals board is versioned here, not
|
||||
# clicked together in the UI.
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: register-referentie
|
||||
type: file
|
||||
disableDeletion: true
|
||||
allowUiUpdates: false
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"uid": "golden-signals",
|
||||
"title": "Request path — golden signals",
|
||||
"tags": ["s-16c", "golden-signals"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"editable": true,
|
||||
"refresh": "10s",
|
||||
"time": { "from": "now-15m", "to": "now" },
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "job",
|
||||
"type": "query",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"query": "label_values(http_server_request_duration_seconds_count, job)",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"current": { "text": "All", "value": "$__all" },
|
||||
"refresh": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Traffic — requests/sec",
|
||||
"type": "timeseries",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\"}[$__rate_interval]))",
|
||||
"legendFormat": "{{job}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Errors — 5xx responses/sec",
|
||||
"type": "timeseries",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 }, "color": { "mode": "fixed", "fixedColor": "red" } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\",http_response_status_code=~\"5..\"}[$__rate_interval]))",
|
||||
"legendFormat": "{{job}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Latency — p95 request duration",
|
||||
"type": "timeseries",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"fieldConfig": { "defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "histogram_quantile(0.95, sum by (job, le) (rate(http_server_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))",
|
||||
"legendFormat": "{{job}} p95"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Saturation — CPU cores in use",
|
||||
"type": "timeseries",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"fieldConfig": { "defaults": { "unit": "none", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (job) (rate(dotnet_process_cpu_time_seconds_total{job=~\"$job\"}[$__rate_interval]))",
|
||||
"legendFormat": "{{job}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
# Auto-provisioned datasources (S-16a, ADR-0023). Fixed uids so dashboards (S-16c)
|
||||
# and the verify-observability check can reference them by a stable id.
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
|
||||
- name: Tempo
|
||||
uid: tempo
|
||||
type: tempo
|
||||
access: proxy
|
||||
url: http://tempo:3200
|
||||
@@ -1,2 +0,0 @@
|
||||
FROM prom/prometheus:v2.55.1
|
||||
COPY prometheus.yml /etc/prometheus/prometheus.yml
|
||||
@@ -1,27 +0,0 @@
|
||||
# Prometheus scrape config (S-16c, ADR-0023). Each .NET service exposes OTel metrics
|
||||
# at /metrics (Prometheus text format); one scrape job per service, so the service is
|
||||
# identified by the `job` label in the golden-signal dashboard. Targets are reached by
|
||||
# compose service name on the shared `cg` network (internal port 8080).
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
- job_name: acl
|
||||
static_configs:
|
||||
- targets: ['acl:8080']
|
||||
- job_name: domain
|
||||
static_configs:
|
||||
- targets: ['domain:8080']
|
||||
- job_name: bff
|
||||
static_configs:
|
||||
- targets: ['bff:8080']
|
||||
- job_name: event-subscriber
|
||||
static_configs:
|
||||
- targets: ['event-subscriber:8080']
|
||||
- job_name: projection-api
|
||||
static_configs:
|
||||
- targets: ['projection-api:8080']
|
||||
@@ -1,4 +0,0 @@
|
||||
# Tempo with our config baked in — so it reaches sibling containers on the CI
|
||||
# runner without the external-config-volume dance the upstream CG images need.
|
||||
FROM grafana/tempo:2.6.1
|
||||
COPY tempo.yaml /etc/tempo.yaml
|
||||
@@ -1,27 +0,0 @@
|
||||
# Grafana Tempo — single-binary, all-in-one, local storage (S-16a, ADR-0023).
|
||||
# Ingests OTLP directly (services export straight to Tempo; no collector hop).
|
||||
# Storage is ephemeral container fs — this is a local/CI demo backplane, not a
|
||||
# retention target. ponytail: local backend, swap for object storage if traces
|
||||
# must outlive the stack.
|
||||
server:
|
||||
http_listen_port: 3200
|
||||
|
||||
distributor:
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
ingester:
|
||||
max_block_duration: 5m
|
||||
|
||||
storage:
|
||||
trace:
|
||||
backend: local
|
||||
local:
|
||||
path: /var/tempo/blocks
|
||||
wal:
|
||||
path: /var/tempo/wal
|
||||
Binary file not shown.
@@ -10,7 +10,7 @@ Creates (if absent):
|
||||
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
|
||||
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
|
||||
"""
|
||||
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.parse, urllib.request
|
||||
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request
|
||||
|
||||
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
|
||||
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||
@@ -77,12 +77,8 @@ def publish_zaaktype(zt):
|
||||
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
|
||||
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
|
||||
"""
|
||||
# Ontvangen (begin) → Afgehandeld (eind, highest volgnummer). "Geannuleerd" (S-10c) sits between
|
||||
# them: a non-terminal status the document-timeout branch sets, so it never displaces the Afgehandeld
|
||||
# eindstatus the approval path resolves. Keyed by volgnummer on a fresh catalogus (CI reseeds); a
|
||||
# stale local stack must reset its OpenZaak volumes for the renumbering to take effect.
|
||||
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
|
||||
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Geannuleerd"), (3, "Afgehandeld")]:
|
||||
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
|
||||
if volgnummer not in have_st:
|
||||
st, body = api("POST", "/statustypen", {
|
||||
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
|
||||
@@ -99,42 +95,25 @@ def publish_zaaktype(zt):
|
||||
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
|
||||
print("create roltype Aanvrager")
|
||||
|
||||
# Two resultaattypen, keyed by omschrijving so each is created independently (idempotent):
|
||||
# "Geregistreerd" — the approval outcome (S-09b)
|
||||
# "Vervallen" — the document-timeout cancellation outcome (S-10c)
|
||||
# Both selectielijstklassen must share the zaaktype's selectielijstProcestype, so pick two
|
||||
# Selectielijst resultaten from a single procestype and set that procestype on the zaaktype.
|
||||
have_rt = {r.get("omschrijving") for r in find(f"/resultaattypen?zaaktype={zt['url']}&status=alles")}
|
||||
wanted = [("Geregistreerd", "blijvend_bewaren"), ("Vervallen", "vernietigen")]
|
||||
if all(naam in have_rt for naam, _ in wanted):
|
||||
print("skip resultaattypen Geregistreerd + Vervallen")
|
||||
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
|
||||
print("skip resultaattype Geregistreerd")
|
||||
else:
|
||||
# Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so
|
||||
# both klassen validate against the zaaktype's selectielijstProcestype.
|
||||
procestype = selectielijst("/resultaten?pageSize=1")["results"][0]["procesType"]
|
||||
resultaten = selectielijst(f"/resultaten?procesType={urllib.parse.quote(procestype, safe='')}")["results"]
|
||||
if len(resultaten) < len(wanted):
|
||||
sys.exit(f"selectielijst procestype has too few resultaten ({len(resultaten)}) for {len(wanted)} resultaattypen")
|
||||
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
|
||||
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
|
||||
oms_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"]
|
||||
|
||||
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype})
|
||||
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
|
||||
# The selectielijstklasse and the zaaktype must share a procestype.
|
||||
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
|
||||
if st != 200:
|
||||
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
|
||||
|
||||
for i, (naam, archiefnominatie) in enumerate(wanted):
|
||||
if naam in have_rt:
|
||||
print(f"skip resultaattype {naam}")
|
||||
continue
|
||||
st, body = api("POST", "/resultaattypen", {
|
||||
"zaaktype": zt["url"], "omschrijving": naam,
|
||||
"resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"],
|
||||
"archiefnominatie": archiefnominatie,
|
||||
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
||||
})
|
||||
if st != 201:
|
||||
sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}")
|
||||
print(f"create resultaattype {naam}")
|
||||
st, body = api("POST", "/resultaattypen", {
|
||||
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
|
||||
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
|
||||
"archiefnominatie": "blijvend_bewaren",
|
||||
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
||||
})
|
||||
if st != 201:
|
||||
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
|
||||
print("create resultaattype Geregistreerd")
|
||||
|
||||
if zt.get("concept", True):
|
||||
st, body = api("POST", f"{zt['url']}/publish")
|
||||
@@ -145,58 +124,6 @@ def publish_zaaktype(zt):
|
||||
print("skip publish (already published)")
|
||||
|
||||
|
||||
def seed_informatieobjecttype(cat, zt):
|
||||
"""Create the "Diploma" informatieobjecttype and relate it to the zaaktype (both idempotent).
|
||||
|
||||
A diploma uploaded in S-10b is filed under this informatieobjecttype; OpenZaak only accepts a
|
||||
document (and its zaak relation) once the informatieobjecttype is published AND allowed for the
|
||||
zaak's zaaktype (a zaaktype-informatieobjecttype relation). Both the relation and this call must run
|
||||
while the zaaktype is still a concept, so seed this *before* publishing the zaaktype. Returns the
|
||||
informatieobjecttype dict.
|
||||
"""
|
||||
iots = [i for i in find(f"/informatieobjecttypen?catalogus={cat['url']}&status=alles")
|
||||
if i.get("omschrijving") == "Diploma"]
|
||||
if iots:
|
||||
iot = iots[0]
|
||||
print(f"skip informatieobjecttype Diploma ({iot['url']}) concept={iot.get('concept')}")
|
||||
else:
|
||||
st, iot = api("POST", "/informatieobjecttypen", {
|
||||
"catalogus": cat["url"],
|
||||
"omschrijving": "Diploma",
|
||||
"vertrouwelijkheidaanduiding": "openbaar",
|
||||
"informatieobjectcategorie": "diploma",
|
||||
"beginGeldigheid": "2026-01-01",
|
||||
})
|
||||
if st != 201:
|
||||
sys.exit(f"create informatieobjecttype -> {st}: {json.dumps(iot, indent=2)}")
|
||||
print(f"create informatieobjecttype Diploma ({iot['url']})")
|
||||
|
||||
# Relate it to the zaaktype (must be done while both are concept).
|
||||
relations = find(f"/zaaktype-informatieobjecttypen?zaaktype={zt['url']}&status=alles")
|
||||
if any(r.get("informatieobjecttype") == iot["url"] for r in relations):
|
||||
print("skip zaaktype-informatieobjecttype Diploma")
|
||||
else:
|
||||
st, body = api("POST", "/zaaktype-informatieobjecttypen", {
|
||||
"zaaktype": zt["url"], "informatieobjecttype": iot["url"],
|
||||
"volgnummer": 1, "richting": "inkomend"})
|
||||
if st != 201:
|
||||
sys.exit(f"relate zaaktype-informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
|
||||
print("create zaaktype-informatieobjecttype Diploma")
|
||||
|
||||
return iot
|
||||
|
||||
|
||||
def publish_informatieobjecttype(iot):
|
||||
"""Publish the informatieobjecttype (idempotent) so documents may reference it."""
|
||||
if iot.get("concept", True):
|
||||
st, body = api("POST", f"{iot['url']}/publish")
|
||||
if st != 200:
|
||||
sys.exit(f"publish informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
|
||||
print(f"publish informatieobjecttype Diploma ({iot['url']})")
|
||||
else:
|
||||
print("skip publish informatieobjecttype (already published)")
|
||||
|
||||
|
||||
def main():
|
||||
# 1. Catalogus
|
||||
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
|
||||
@@ -271,16 +198,10 @@ def main():
|
||||
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
|
||||
# those relations and publish — needed so a real zaak POST is accepted, which
|
||||
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
|
||||
iot = None
|
||||
if PUBLISH:
|
||||
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
|
||||
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
|
||||
if z.get("identificatie") == "BIG-REGISTRATIE")
|
||||
# Seed + relate the Diploma informatieobjecttype (S-10b) while the zaaktype is still concept,
|
||||
# then publish both. Publish the informatieobjecttype before the zaaktype so the zaaktype's
|
||||
# relations reference a published type.
|
||||
iot = seed_informatieobjecttype(cat, zt)
|
||||
publish_informatieobjecttype(iot)
|
||||
publish_zaaktype(zt)
|
||||
|
||||
# 5. Verify the JWT client can list the zaaktype (concepts included).
|
||||
@@ -293,10 +214,6 @@ def main():
|
||||
# zaaktype URL to configure the ACL's default-fill (ADR-0003/0009).
|
||||
zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE")
|
||||
print(f"ZAAKTYPE_URL {zt_url}")
|
||||
# Machine-readable informatieobjecttype URL (S-10b) so callers can configure the ACL's document
|
||||
# default-fill. Only emitted when publishing — a concept informatieobjecttype can't back a document.
|
||||
if iot is not None:
|
||||
print(f"INFORMATIEOBJECTTYPE_URL {iot['url']}")
|
||||
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
|
||||
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a per-spec table from a Playwright JSON report for a Gitea job summary (#136).
|
||||
|
||||
Reads the JSON report (default: tests/e2e/playwright-report.json) that run-e2e-check.sh copies out
|
||||
of the e2e container, and prints a markdown table (one row per spec) to stdout. The CI step
|
||||
redirects it into $GITHUB_STEP_SUMMARY. Stdlib only.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
STATUS_ICON = {"expected": "✅", "unexpected": "❌", "skipped": "⏭️", "flaky": "⚠️"}
|
||||
|
||||
|
||||
def walk(suite, out):
|
||||
for spec in suite.get("specs", []):
|
||||
# A spec's status is carried on its test(s): expected/unexpected/skipped/flaky.
|
||||
statuses = [t.get("status") for t in spec.get("tests", [])]
|
||||
status = ("unexpected" if "unexpected" in statuses
|
||||
else "flaky" if "flaky" in statuses
|
||||
else "skipped" if statuses and all(s == "skipped" for s in statuses)
|
||||
else "expected" if spec.get("ok", False)
|
||||
else "unexpected")
|
||||
out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""),
|
||||
"title": spec.get("title", ""), "status": status})
|
||||
for child in suite.get("suites", []):
|
||||
walk(child, out)
|
||||
|
||||
|
||||
def main(path):
|
||||
if not os.path.exists(path):
|
||||
print("## 🎭 e2e (Playwright)\n\n_No e2e report — the run did not reach the e2e step._")
|
||||
return 0
|
||||
with open(path) as fh:
|
||||
report = json.load(fh)
|
||||
specs = []
|
||||
for suite in report.get("suites", []):
|
||||
walk(suite, specs)
|
||||
|
||||
print("## 🎭 e2e (Playwright)\n")
|
||||
stats = report.get("stats", {})
|
||||
if stats:
|
||||
print(f"**{stats.get('expected', 0)} passed · {stats.get('unexpected', 0)} failed · "
|
||||
f"{stats.get('flaky', 0)} flaky · {stats.get('skipped', 0)} skipped** "
|
||||
f"({round(stats.get('duration', 0) / 1000)}s)\n")
|
||||
if not specs:
|
||||
print("_No specs ran._")
|
||||
return 0
|
||||
print("| Spec | Result |")
|
||||
print("| ---- | :----: |")
|
||||
for s in specs:
|
||||
print(f"| {s['file']} › {s['title']} | {STATUS_ICON.get(s['status'], '❔')} |")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "tests/e2e/playwright-report.json"))
|
||||
+6
-244
@@ -30,18 +30,16 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
|
||||
oz_base="http://$oz_ip:8000"
|
||||
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
|
||||
|
||||
echo ">> seeding + publishing a BIG zaaktype (idempotent)"
|
||||
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
|
||||
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||
seed_out="$(docker start -a "$sid")"
|
||||
zt_url="$(docker start -a "$sid" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
||||
docker rm -f "$sid" >/dev/null
|
||||
printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
||||
echo ">> zaaktype: $zt_url"
|
||||
|
||||
# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
|
||||
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
|
||||
# (OpenZaak rejects a single-label host on zaak-create).
|
||||
echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)"
|
||||
ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
||||
echo ">> recreating the acl service pointed at the seeded zaaktype (host-consistent)"
|
||||
ACL_ZAAKTYPE_URL="$zt_url" ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
||||
|
||||
echo ">> submitting a registration to the domain"
|
||||
@@ -95,27 +93,6 @@ print(next((t['id'] for t in (d.get('data') or [])
|
||||
|
||||
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
|
||||
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
|
||||
wacht_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten","includeProcessVariables":true}'
|
||||
|
||||
# S-10a: every registration now parks at WachtOpDocumenten first (interrupting P30D timer). Completing
|
||||
# that task stands in for the citizen's document upload (wired for real in S-10b), letting the process
|
||||
# advance to the diploma routing / Beoordelen so the checks below still hold. The 30-day timeout branch
|
||||
# is exercised separately at the end.
|
||||
complete_wacht() { # reg_id
|
||||
local rid="$1" wid="" r
|
||||
for _ in $(seq 1 30); do
|
||||
r="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||
wid="$(printf '%s' "$r" | task_for_reg "$rid")"
|
||||
[ -n "$wid" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$wid" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $rid" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
flcurl -X POST "$fl_base/runtime/tasks/$wid" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
|
||||
echo ">> completed WachtOpDocumenten for $rid (documents received)"
|
||||
}
|
||||
|
||||
echo ">> completing WachtOpDocumenten so the process advances (documents received)"
|
||||
complete_wacht "$reg_id"
|
||||
|
||||
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
||||
task_id=""
|
||||
@@ -153,7 +130,6 @@ loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
|
||||
reg_id2="${loc2##*/}"
|
||||
echo ">> second registration $reg_id2"
|
||||
complete_wacht "$reg_id2"
|
||||
|
||||
echo ">> polling Flowable for its Beoordelen task"
|
||||
task_id2=""
|
||||
@@ -181,218 +157,4 @@ for _ in $(seq 1 15); do
|
||||
done
|
||||
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
|
||||
|
||||
# ── S-13: diploma-eligibility routing. A registration with a FOREIGN diploma must route through the
|
||||
# extra CBGVAdvies user task before Beoordelen (the DMN service task sets route=CBGV_ADVIES and the
|
||||
# gateway branches, ADR-0016). The domestic DIRECT path is already proven by the first registration
|
||||
# above, which parked straight at Beoordelen. ──────────────────────────────────────────────────────
|
||||
cbgv_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies","includeProcessVariables":true}'
|
||||
echo ">> submitting a registration with a foreign diploma"
|
||||
locf="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' \
|
||||
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
|
||||
reg_idf="${locf##*/}"
|
||||
echo ">> foreign registration $reg_idf"
|
||||
complete_wacht "$reg_idf"
|
||||
|
||||
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
|
||||
cbgv_task=""
|
||||
for _ in $(seq 1 30); do
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$cbgv_query" 2>/dev/null || true)"
|
||||
cbgv_task="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
|
||||
[ -n "$cbgv_task" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$cbgv_task" ] || { echo "FAIL — no CBGVAdvies task appeared for the foreign registration $reg_idf" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo ">> CBGVAdvies task $cbgv_task is waiting"
|
||||
|
||||
echo ">> asserting it has NOT reached Beoordelen yet (still awaiting CBGV-advies)"
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
|
||||
early="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
|
||||
[ -z "$early" ] || { echo "FAIL — foreign registration reached Beoordelen ($early) before CBGV-advies" >&2; exit 1; }
|
||||
|
||||
echo ">> completing the CBGV-advies task"
|
||||
flcurl -X POST "$fl_base/runtime/tasks/$cbgv_task" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
|
||||
|
||||
echo ">> asserting it now advances to Beoordelen"
|
||||
onward=""
|
||||
for _ in $(seq 1 15); do
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||
[ -n "$(printf '%s' "$resp" | task_for_reg "$reg_idf")" ] && { onward=1; break; }
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$onward" ] || { echo "FAIL — foreign registration did not reach Beoordelen after CBGV-advies" >&2; exit 1; }
|
||||
echo "OK — foreign diploma routed through CBGV-advies, then on to Beoordelen (DMN + gateway)"
|
||||
|
||||
# ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer
|
||||
# early via Flowable's management API (the timer job is moved to executable and run), which routes a
|
||||
# parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
|
||||
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
|
||||
echo ">> submitting a third registration to escalate"
|
||||
loc3="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
|
||||
reg_id3="${loc3##*/}"
|
||||
echo ">> third registration $reg_id3"
|
||||
|
||||
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
|
||||
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
except Exception:
|
||||
d={}
|
||||
rid=os.environ['REG_ID']
|
||||
t=next((t for t in (d.get('data') or [])
|
||||
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
|
||||
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
|
||||
|
||||
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
|
||||
candidate_groups() { python3 -c "import sys,json
|
||||
try:
|
||||
links=json.load(sys.stdin)
|
||||
except Exception:
|
||||
links=[]
|
||||
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
|
||||
|
||||
# The first job id in a management jobs/timer-jobs response on stdin.
|
||||
first_job_id() { python3 -c "import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
except Exception:
|
||||
d={}
|
||||
print(((d.get('data') or [{}])[0]).get('id',''))"; }
|
||||
|
||||
complete_wacht "$reg_id3"
|
||||
|
||||
echo ">> polling Flowable for its Beoordelen task"
|
||||
task_id3=""; pid3=""
|
||||
for _ in $(seq 1 30); do
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
|
||||
[ -n "$task_id3" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
|
||||
|
||||
echo ">> asserting the task starts out claimable by the behandelaar group"
|
||||
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
|
||||
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
|
||||
|
||||
echo ">> firing the 14-day boundary timer early via the management API"
|
||||
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
|
||||
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
|
||||
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
|
||||
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
|
||||
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
|
||||
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
|
||||
if [ -n "$async_id" ]; then
|
||||
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
|
||||
|
||||
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
|
||||
escalated=""
|
||||
for _ in $(seq 1 30); do
|
||||
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
|
||||
[ "$groups" = "teamlead" ] && { escalated=1; break; }
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
|
||||
|
||||
# ── S-10a/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block
|
||||
# above — its documents never arrive. We fire its 30-day boundary timer early via the management API;
|
||||
# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task.
|
||||
# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the
|
||||
# registration to VERLOPEN (ADR-0017). ─────────────────────────────────────────────────────────────
|
||||
echo ">> submitting a registration to let its document term lapse"
|
||||
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||
[ -n "$locv" ] || { echo "FAIL — timeout POST /registrations returned no Location" >&2; exit 1; }
|
||||
reg_idv="${locv##*/}"
|
||||
echo ">> timeout registration $reg_idv"
|
||||
|
||||
echo ">> polling Flowable for its WachtOpDocumenten task"
|
||||
wacht_id=""; pidv=""
|
||||
for _ in $(seq 1 30); do
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||
read -r wacht_id pidv <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_idv")"
|
||||
[ -n "$wacht_id" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$wacht_id" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $reg_idv" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo ">> WachtOpDocumenten task $wacht_id (instance $pidv) is waiting for documents"
|
||||
|
||||
echo ">> firing the 30-day document timer early via the management API"
|
||||
timer_idv="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pidv" | first_job_id)"
|
||||
[ -n "$timer_idv" ] || { echo "FAIL — no timer job found for instance $pidv" >&2; exit 1; }
|
||||
# Move the timer job to an executable async job; the async executor fires the interrupting boundary
|
||||
# event. It may run before we look, so executing it explicitly is a best-effort nudge (as for S-14).
|
||||
flcurl -X POST "$fl_base/management/timer-jobs/$timer_idv" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||
async_idv="$(flcurl "$fl_base/management/jobs?processInstanceId=$pidv" 2>/dev/null | first_job_id || true)"
|
||||
if [ -n "$async_idv" ]; then
|
||||
flcurl -X POST "$fl_base/management/jobs/$async_idv" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo ">> timer fired; the RegistratieVerlopen token is parked for the domain worker"
|
||||
|
||||
echo ">> polling the domain until the timeout worker expires the registration to VERLOPEN"
|
||||
verlopen=""
|
||||
for _ in $(seq 1 30); do
|
||||
body="$(docker run --rm --network "$net" curlimages/curl:latest -fsS "http://$dom_ip:8080$locv" 2>/dev/null || true)"
|
||||
printf '%s' "$body" | grep -qi 'verlopen' && { verlopen=1; break; }
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
|
||||
|
||||
# S-10c: the worker cancels the ZGW zaak (ACL-first, before it expires the aggregate), so a VERLOPEN
|
||||
# registration must carry a zaak whose current status is "Geannuleerd". Read it back from OpenZaak with
|
||||
# a ZGW token minted like the seed's client (the same client OpenZaak trusts for this stack).
|
||||
zaak_url_v="$(printf '%s' "$body" | grep -oiE 'http://[^"]*/zaken/api/v1/zaken/[a-f0-9-]+' | head -1)"
|
||||
[ -n "$zaak_url_v" ] || { echo "FAIL — VERLOPEN registration $reg_idv exposes no zaak URL (body: $body)" >&2; exit 1; }
|
||||
echo ">> confirming the zaak $zaak_url_v reached the Geannuleerd status in OpenZaak"
|
||||
|
||||
read_zaak_status() {
|
||||
# -i so the heredoc reaches `python -` on the container's stdin (without it the script is empty).
|
||||
docker run --rm -i --network "$net" \
|
||||
-e OZ_CLIENT_ID="${OZ_CLIENT_ID:-big-reference-seed}" \
|
||||
-e OZ_SECRET="${OZ_SECRET:-insecure-dev-secret-change-me}" \
|
||||
python:3-slim python - "$1" <<'PY'
|
||||
import base64, hashlib, hmac, json, os, sys, time, urllib.request
|
||||
cid, sec = os.environ["OZ_CLIENT_ID"], os.environ["OZ_SECRET"]
|
||||
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||
def token():
|
||||
hdr = {"alg": "HS256", "typ": "JWT"}
|
||||
pl = {"iss": cid, "iat": int(time.time()), "client_id": cid, "user_id": "verify", "user_representation": "verify"}
|
||||
seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + b64(json.dumps(pl, separators=(",", ":")).encode())
|
||||
return (seg + b"." + b64(hmac.new(sec.encode(), seg, hashlib.sha256).digest())).decode()
|
||||
def get(url):
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": "Bearer " + token(), "Accept": "application/json", "Accept-Crs": "EPSG:4326"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
zaak = get(sys.argv[1])
|
||||
status_url = zaak.get("status")
|
||||
if not status_url:
|
||||
print(""); sys.exit(0)
|
||||
print(get(get(status_url)["statustype"]).get("omschrijving", ""))
|
||||
PY
|
||||
}
|
||||
|
||||
geannuleerd=""
|
||||
for _ in $(seq 1 15); do
|
||||
oms="$(read_zaak_status "$zaak_url_v" 2>/dev/null | tr -d '\r' || true)"
|
||||
[ "$oms" = "Geannuleerd" ] && { geannuleerd=1; break; }
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$geannuleerd" ] || { echo "FAIL — zaak $zaak_url_v not Geannuleerd after timeout (current status omschrijving: '$oms')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo "OK — the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
|
||||
exit 0
|
||||
|
||||
@@ -26,8 +26,4 @@ cid="$(docker create --network "$net" -w /e2e --ipc=host \
|
||||
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
||||
rc=0
|
||||
docker start -a "$cid" || rc=$?
|
||||
# Copy the Playwright JSON report out — regardless of pass/fail — for the CI job summary (#136).
|
||||
docker cp "$cid:/e2e/playwright-report.json" "$root/tests/e2e/playwright-report.json" 2>/dev/null || true
|
||||
exit $rc
|
||||
docker start -a "$cid"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Acceptance check for the local stack (S-B04, #110): a fresh `make local` must complete the whole
|
||||
# flow with NO manual seeding. Run against an already-up local stack (infra/docker-compose.local.yml)
|
||||
# via the host-published ports. It exercises, and thereby covers, the three bring-up gaps the slice
|
||||
# fixes:
|
||||
#
|
||||
# 1. zaaktype seeded + ACL wired -> a submitted registration opens a zaak (zaakUrl gets filled).
|
||||
# 2. diploma-eligibility DMN deployed -> providing documents completes WachtOpDocumenten, routes
|
||||
# through the DMN, and the case lands on Beoordelen (visible in the behandel werkbak).
|
||||
# 3. NRC abonnement registered -> the zaak shows up in the openbaar (public) register.
|
||||
#
|
||||
# Before the fix this fails at step 1 (ACL points at a placeholder zaaktype -> OpenZaak 400).
|
||||
set -euo pipefail
|
||||
|
||||
DOM=${DOM:-http://localhost:8130} # domain
|
||||
BFF=${BFF:-http://localhost:8080} # bff (openbaar register)
|
||||
BSN=${BSN:-123456782}
|
||||
# A minimal, valid PDF, base64-encoded (the diploma upload).
|
||||
PDF_B64="$(printf '%%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' | base64 | tr -d '\n')"
|
||||
|
||||
echo ">> 1. submit a registration (no manual seeding expected)"
|
||||
loc="$(curl -fsS -D - -o /dev/null -X POST "$DOM/registrations" \
|
||||
-H 'Content-Type: application/json' -d "{\"bsn\":\"$BSN\"}" \
|
||||
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||
[ -n "$loc" ] || { echo "FAIL: POST /registrations returned no Location" >&2; exit 1; }
|
||||
id="${loc##*/}"
|
||||
echo " accepted: $id"
|
||||
|
||||
echo ">> 2. poll until the ACL opens the zaak (proves the zaaktype is seeded + wired)"
|
||||
zaak=""
|
||||
for _ in $(seq 1 30); do
|
||||
zaak="$(curl -fsS "$DOM$loc" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("zaakUrl") or "")' 2>/dev/null || true)"
|
||||
[ -n "$zaak" ] && break
|
||||
sleep 3
|
||||
done
|
||||
[ -n "$zaak" ] || { echo "FAIL: zaak never opened — ACL zaaktype not wired (gap 1)" >&2; exit 1; }
|
||||
echo " zaak opened: $zaak"
|
||||
|
||||
echo ">> 3. provide documents (proves the diploma-eligibility DMN is deployed)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DOM/registrations/$id/documents" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"bsn\":\"$BSN\",\"contentBase64\":\"$PDF_B64\",\"fileName\":\"diploma.pdf\",\"contentType\":\"application/pdf\"}")"
|
||||
[ "$code" = "204" ] || { echo "FAIL: provide documents -> $code (DMN missing routes WachtOpDocumenten to a 404 — gap 2)" >&2; exit 1; }
|
||||
echo " documents accepted (204)"
|
||||
|
||||
echo ">> 4. poll the werkbak until the registration awaits beoordeling (reached Beoordelen)"
|
||||
in_werkbak=""
|
||||
for _ in $(seq 1 20); do
|
||||
in_werkbak="$(curl -fsS "$DOM/behandel/werkbak" | python3 -c "import sys,json;print(any(r.get('registrationId')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||
[ "$in_werkbak" = "True" ] && break
|
||||
sleep 3
|
||||
done
|
||||
[ "$in_werkbak" = "True" ] || { echo "FAIL: registration never reached the werkbak (gap 2)" >&2; exit 1; }
|
||||
echo " in the werkbak"
|
||||
|
||||
echo ">> 5. poll the openbaar register until the reference is publicly visible (proves NRC abonnement)"
|
||||
public=""
|
||||
for _ in $(seq 1 30); do
|
||||
public="$(curl -fsS "$BFF/openbaar/register" | python3 -c "import sys,json;print(any(r.get('reference')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||
[ "$public" = "True" ] && break
|
||||
sleep 3
|
||||
done
|
||||
[ "$public" = "True" ] || { echo "FAIL: reference never appeared in the openbaar register — NRC abonnement not registered (gap 3)" >&2; exit 1; }
|
||||
echo " visible in the openbaar register"
|
||||
|
||||
echo "OK — a fresh local stack completed the flow with no manual seeding (zaaktype + DMN + abonnement)"
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-16c (#124): assert the golden-signal metrics pipeline works — the .NET services expose
|
||||
# /metrics and Prometheus scrapes them — against an ALREADY-RUNNING full stack. Runs the
|
||||
# driver in a python:3-slim container on the stack network (services reached by container IP;
|
||||
# the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). Does NOT manage
|
||||
# the stack lifecycle.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
|
||||
prom="$(docker ps -q --filter 'name=[-_]prometheus[-_]' | head -1)"
|
||||
[ -n "$bff" ] && [ -n "$prom" ] || { echo "ERROR: bff and/or prometheus not running — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
|
||||
bff_ip="$(ip "$bff")"; prom_ip="$(ip "$prom")"
|
||||
echo ">> network=$net bff=$bff_ip prometheus=$prom_ip"
|
||||
|
||||
cid="$(docker create --network "$net" \
|
||||
-e "BFF=http://$bff_ip:8080" -e "PROMETHEUS=http://$prom_ip:9090" \
|
||||
-e "METRICS_TIMEOUT=${METRICS_TIMEOUT:-90}" \
|
||||
python:3-slim python /metrics-check.py)"
|
||||
docker cp "$here/metrics-check.py" "$cid:/metrics-check.py" >/dev/null
|
||||
rc=0; docker start -a "$cid" || rc=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
exit $rc
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-16a (#122): assert the observability backplane is live against an ALREADY-RUNNING
|
||||
# stack. Runs curl INSIDE the compose network (like the other verify checks) because
|
||||
# the stack's published ports aren't on the CI runner's localhost — the stack is a set
|
||||
# of sibling containers on the host daemon. It asks Grafana to reach its provisioned
|
||||
# datasources — Prometheus via its health method, Tempo via the datasource proxy (Tempo's
|
||||
# Grafana plugin implements no health method) — so it proves the datasources are wired,
|
||||
# not merely that the containers started. Polls, so it tolerates a cold Grafana.
|
||||
#
|
||||
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown).
|
||||
set -euo pipefail
|
||||
|
||||
TIMEOUT="${OBS_TIMEOUT:-60}"
|
||||
AUTH="${GRAFANA_AUTH:-admin:admin}"
|
||||
|
||||
gf="$(docker ps -q --filter 'name=[-_]grafana[-_]' | head -1)"
|
||||
[ -n "$gf" ] || { echo "ERROR: no running grafana container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$gf" | head -1)"
|
||||
gf_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$gf")"
|
||||
base="http://$gf_ip:3000"
|
||||
echo ">> grafana=$gf_ip network=$net"
|
||||
|
||||
# Run curl inside a throwaway container on the stack network (reaches services by IP).
|
||||
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
|
||||
|
||||
# poll <description> <grep -E pattern> <curl args...>
|
||||
poll() {
|
||||
local desc="$1" pat="$2"; shift 2
|
||||
local deadline=$(( $(date +%s) + TIMEOUT ))
|
||||
while :; do
|
||||
if net_curl -fsS "$@" 2>/dev/null | grep -Eq "$pat"; then echo " ✓ $desc"; return 0; fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then echo " ✗ $desc ($*)" >&2; return 1; fi
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
echo "Checking observability backplane at $base ..."
|
||||
poll "Grafana is healthy" \
|
||||
'"database":[[:space:]]*"ok"' "$base/api/health"
|
||||
poll "Prometheus datasource reachable" \
|
||||
'"status":[[:space:]]*"OK"' -u "$AUTH" "$base/api/datasources/uid/prometheus/health"
|
||||
poll "Tempo datasource reachable (via Grafana proxy)" \
|
||||
'"version"' -u "$AUTH" "$base/api/datasources/proxy/uid/tempo/api/status/buildinfo"
|
||||
echo "Observability backplane OK."
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-16b (#123): assert one connected distributed trace spans the .NET services in Tempo,
|
||||
# against an ALREADY-RUNNING full stack. Runs the driver in a python:3-slim container on the
|
||||
# stack network (services reached by container IP; the runner can't reach published ports —
|
||||
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
|
||||
tempo="$(docker ps -q --filter 'name=[-_]tempo[-_]' | head -1)"
|
||||
[ -n "$bff" ] && [ -n "$tempo" ] || { echo "ERROR: bff and/or tempo not running — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
|
||||
bff_ip="$(ip "$bff")"; tempo_ip="$(ip "$tempo")"
|
||||
echo ">> network=$net bff=$bff_ip tempo=$tempo_ip"
|
||||
|
||||
cid="$(docker create --network "$net" \
|
||||
-e "BFF=http://$bff_ip:8080" -e "TEMPO=http://$tempo_ip:3200" \
|
||||
-e "TRACING_TIMEOUT=${TRACING_TIMEOUT:-90}" \
|
||||
python:3-slim python /tracing-check.py)"
|
||||
docker cp "$here/tracing-check.py" "$cid:/tracing-check.py" >/dev/null
|
||||
rc=0; docker start -a "$cid" || rc=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
exit $rc
|
||||
+1
-10
@@ -35,21 +35,12 @@ populate() { # volume source(file or dir/.)
|
||||
|
||||
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
||||
|
||||
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
|
||||
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
|
||||
# does not cascade a .dmn bundled in a process .bar into the DMN engine, so we seed both raw files and
|
||||
# let flowable-init deploy each via its own REST app. We stage them in a temp dir and copy its contents.
|
||||
stage_flowable_workflows() {
|
||||
local dir="$1"
|
||||
cp "$here/../workflows/registratie.bpmn" "$here/../workflows/diploma-eligibility.dmn" "$dir/"
|
||||
}
|
||||
|
||||
for key in "$@"; do
|
||||
case "$key" in
|
||||
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
||||
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
||||
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
||||
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
|
||||
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
|
||||
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-16b (#123): prove distributed tracing works end to end.
|
||||
|
||||
Generate anonymous BFF traffic (GET /openbaar/register, which the BFF serves by
|
||||
calling projection-api — no auth, no OpenZaak egress), then query Tempo and assert
|
||||
that ONE trace contains spans from both `bff` and `projection-api`. That proves the
|
||||
services export OTLP to Tempo AND that the W3C traceparent propagates across the
|
||||
HttpClient hop, stitching the request into a single connected trace.
|
||||
|
||||
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BFF = os.environ["BFF"] # http://<bff-ip>:8080
|
||||
TEMPO = os.environ["TEMPO"] # http://<tempo-ip>:3200
|
||||
TIMEOUT = int(os.environ.get("TRACING_TIMEOUT", "90"))
|
||||
WANT = {"bff", "projection-api"} # the two services that must share one trace
|
||||
|
||||
|
||||
def _get(url):
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def generate_traffic():
|
||||
# A non-2xx still produces spans; only total unreachability of the BFF is fatal.
|
||||
for _ in range(3):
|
||||
try:
|
||||
_get(f"{BFF}/openbaar/register")
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
|
||||
def search_trace_ids():
|
||||
q = urllib.parse.quote('{ resource.service.name = "bff" }')
|
||||
try:
|
||||
data = json.loads(_get(f"{TEMPO}/api/search?q={q}&limit=50"))
|
||||
except Exception:
|
||||
return []
|
||||
return [t["traceID"] for t in data.get("traces", [])]
|
||||
|
||||
|
||||
def services_in_trace(trace_id):
|
||||
try:
|
||||
data = json.loads(_get(f"{TEMPO}/api/traces/{trace_id}"))
|
||||
except Exception:
|
||||
return set()
|
||||
names = set()
|
||||
for batch in data.get("batches", []):
|
||||
for attr in batch.get("resource", {}).get("attributes", []):
|
||||
if attr.get("key") == "service.name":
|
||||
names.add(attr.get("value", {}).get("stringValue"))
|
||||
return names
|
||||
|
||||
|
||||
def main():
|
||||
deadline = time.time() + TIMEOUT
|
||||
generate_traffic()
|
||||
seen = set()
|
||||
while time.time() < deadline:
|
||||
for tid in search_trace_ids():
|
||||
names = services_in_trace(tid)
|
||||
seen |= names
|
||||
if WANT.issubset(names):
|
||||
print(f"OK — trace {tid} spans {sorted(names)}")
|
||||
return 0
|
||||
time.sleep(3)
|
||||
generate_traffic()
|
||||
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a per-test-project table from .trx files for a Gitea job summary (#136).
|
||||
|
||||
Reads every *.trx in the given directory (default: TestResults), pulls each project's
|
||||
counters + assembly name, and prints a GitHub/Gitea-flavoured markdown table to stdout.
|
||||
The CI step redirects that into $GITHUB_STEP_SUMMARY. Stdlib only.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
|
||||
|
||||
|
||||
def project_name(root):
|
||||
# The test assembly path, e.g. …/services/domain/Big.Tests/bin/…/big.tests.dll. Prefer the
|
||||
# owning service folder (services/<name>) so "domain" shows rather than the opaque "big.tests";
|
||||
# fall back to the assembly basename for projects outside services/ (e.g. tests/acceptance).
|
||||
ut = root.find(".//t:TestDefinitions/t:UnitTest", NS)
|
||||
storage = ut.get("storage") if ut is not None else None
|
||||
if not storage:
|
||||
return None
|
||||
parts = storage.replace("\\", "/").split("/")
|
||||
if "services" in parts:
|
||||
return parts[parts.index("services") + 1]
|
||||
base = os.path.basename(parts[-1])
|
||||
return base[:-4] if base.lower().endswith(".dll") else base
|
||||
|
||||
|
||||
def parse(path):
|
||||
root = ET.parse(path).getroot()
|
||||
c = root.find(".//t:ResultSummary/t:Counters", NS)
|
||||
if c is None:
|
||||
return None
|
||||
total = int(c.get("total", 0))
|
||||
if total == 0: # e.g. the Integration project, filtered out of the unit run
|
||||
return None
|
||||
executed = int(c.get("executed", 0))
|
||||
passed = int(c.get("passed", 0))
|
||||
failed = int(c.get("failed", 0)) + int(c.get("error", 0))
|
||||
skipped = total - executed
|
||||
return {
|
||||
"name": project_name(root) or os.path.basename(path),
|
||||
"passed": passed, "failed": failed, "skipped": skipped, "total": total,
|
||||
}
|
||||
|
||||
|
||||
def main(results_dir):
|
||||
rows = [r for r in (parse(p) for p in sorted(glob.glob(os.path.join(results_dir, "*.trx")))) if r]
|
||||
if not rows:
|
||||
print("_No test results found._")
|
||||
return 0
|
||||
rows.sort(key=lambda r: r["name"])
|
||||
print("## ✅ Unit tests\n")
|
||||
print("| Project | Result | Passed | Failed | Skipped | Total |")
|
||||
print("| ------- | :----: | -----: | -----: | ------: | ----: |")
|
||||
for r in rows:
|
||||
status = "❌" if r["failed"] else "✅"
|
||||
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "TestResults"))
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a per-frontend test table from vitest JSON reports for a Gitea job summary (#136).
|
||||
|
||||
Reads every *.json in the given directory (default: test-output), each written by an app's
|
||||
`test` target (reporters: json, outputFile: {workspaceRoot}/test-output/{projectName}.json), and
|
||||
prints a markdown table to stdout — one row per frontend app. The CI step redirects it into
|
||||
$GITHUB_STEP_SUMMARY. Stdlib only.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main(results_dir):
|
||||
rows = []
|
||||
for path in sorted(glob.glob(os.path.join(results_dir, "*.json"))):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
d = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
rows.append({
|
||||
"name": os.path.splitext(os.path.basename(path))[0],
|
||||
"passed": d.get("numPassedTests", 0),
|
||||
"failed": d.get("numFailedTests", 0),
|
||||
"skipped": d.get("numPendingTests", 0) + d.get("numTodoTests", 0),
|
||||
"total": d.get("numTotalTests", 0),
|
||||
"ok": d.get("success", False),
|
||||
})
|
||||
if not rows:
|
||||
print("_No frontend test results found._")
|
||||
return 0
|
||||
print("## 🅰️ Frontend tests\n")
|
||||
print("| Frontend | Result | Passed | Failed | Skipped | Total |")
|
||||
print("| -------- | :----: | -----: | -----: | ------: | ----: |")
|
||||
for r in rows:
|
||||
status = "✅" if r["ok"] and not r["failed"] else "❌"
|
||||
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "test-output"))
|
||||
@@ -24,22 +24,6 @@ import {
|
||||
Observable
|
||||
} from 'rxjs';
|
||||
|
||||
export interface BeheerDefaultFill {
|
||||
bronorganisatie: string;
|
||||
verantwoordelijkeOrganisatie: string;
|
||||
vertrouwelijkheidaanduiding: string;
|
||||
}
|
||||
|
||||
export interface BeheerZaaktype {
|
||||
identificatie: string;
|
||||
omschrijving: string;
|
||||
}
|
||||
|
||||
export interface CurrentRegistration {
|
||||
registrationId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DecideRequest {
|
||||
besluit: string;
|
||||
}
|
||||
@@ -51,14 +35,6 @@ export interface OpenbaarEntry {
|
||||
reference: string | null;
|
||||
}
|
||||
|
||||
export interface ProvideDocumentsRequest {
|
||||
contentBase64: string;
|
||||
/** @nullable */
|
||||
fileName?: string | null;
|
||||
/** @nullable */
|
||||
contentType?: string | null;
|
||||
}
|
||||
|
||||
export interface SubmitAccepted {
|
||||
registrationId: string;
|
||||
status: string;
|
||||
@@ -216,37 +192,6 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>(
|
||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.get<TData>(
|
||||
`/self-service/registrations`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.get<TData>(
|
||||
`/self-service/registrations`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<TData>(
|
||||
`/self-service/registrations`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
@@ -281,44 +226,6 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
|
||||
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
|
||||
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
|
||||
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(
|
||||
id: string,
|
||||
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/documents`,
|
||||
provideDocumentsRequest,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/documents`,
|
||||
provideDocumentsRequest,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/documents`,
|
||||
provideDocumentsRequest,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
@@ -421,100 +328,4 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>(
|
||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.get<TData>(
|
||||
`/beheer/catalogi/zaaktypen`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.get<TData>(
|
||||
`/beheer/catalogi/zaaktypen`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<TData>(
|
||||
`/beheer/catalogi/zaaktypen`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>(
|
||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.get<TData>(
|
||||
`/beheer/default-fill`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.get<TData>(
|
||||
`/beheer/default-fill`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<TData>(
|
||||
`/beheer/default-fill`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
putBeheerDefaultFill<TData = void>(
|
||||
beheerDefaultFill: BeheerDefaultFill, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.put<TData>(
|
||||
`/beheer/default-fill`,
|
||||
beheerDefaultFill,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.put<TData>(
|
||||
`/beheer/default-fill`,
|
||||
beheerDefaultFill,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.put<TData>(
|
||||
`/beheer/default-fill`,
|
||||
beheerDefaultFill,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -5,14 +5,6 @@
|
||||
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
using Acl.Application;
|
||||
using Acl.Infrastructure;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
|
||||
// outgoing HttpClient calls (the ACL → OpenZaak hop), exported over OTLP to Tempo. Service name +
|
||||
// OTLP endpoint come from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter())
|
||||
// OpenTelemetry metrics (S-16c, ADR-0023): golden signals for the request path —
|
||||
// http.server.request.duration (traffic/errors/latency) + http.client.* for downstream hops, plus
|
||||
// the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes these
|
||||
// from /metrics (mapped below); metrics aren't pushed over OTLP, so no collector hop (ADR-0023).
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter("System.Runtime")
|
||||
.AddPrometheusExporter());
|
||||
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:Defaults").Get<AclDefaults>()
|
||||
@@ -33,27 +10,13 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
||||
// The default-fill values are held in a runtime-mutable store (S-15b, ADR-0026), seeded from the
|
||||
// configured Acl:Defaults. The beheer portal edits it; the worker reads it per zaak. The S-27
|
||||
// resolution keys stay on AclDefaults (static) — see DefaultFillSettings.
|
||||
builder.Services.AddSingleton<IDefaultFillStore>(sp =>
|
||||
{
|
||||
var d = sp.GetRequiredService<AclDefaults>();
|
||||
return new InMemoryDefaultFillStore(
|
||||
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
||||
});
|
||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||
builder.Services.AddScoped<AclService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet("/health", () => "Healthy");
|
||||
|
||||
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
|
||||
// The ACL's single operation, exposed as a service endpoint.
|
||||
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
|
||||
{
|
||||
@@ -69,14 +32,6 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// Cancel a zaak on document-timeout expiry (S-10c): set it to its zaaktype's cancellation statustype
|
||||
// + resultaat. The domain hands over only the zaak URL; the ACL owns the ZGW resolution (§8.1).
|
||||
app.MapPost("/annuleringen", async (CancelZaakRequest body, AclService acl, CancellationToken ct) =>
|
||||
{
|
||||
await acl.CancelZaakAsync(new Uri(body.ZaakUrl), ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
|
||||
// the read projection without reading ZGW itself (§8.1, #78).
|
||||
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
|
||||
@@ -85,47 +40,12 @@ app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl
|
||||
return Results.Ok(new { reference });
|
||||
});
|
||||
|
||||
// Store an uploaded diploma against a zaak (S-10b): the domain sends the file as base64; the ACL
|
||||
// creates the ZGW enkelvoudiginformatieobject and relates it to the zaak (§8.1). Returns its URL.
|
||||
app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, CancellationToken ct) =>
|
||||
{
|
||||
var url = await acl.StoreDiplomaAsync(
|
||||
new Uri(body.ZaakUrl), Convert.FromBase64String(body.ContentBase64), body.FileName, body.ContentType, ct);
|
||||
return Results.Ok(new { informatieobjectUrl = url.ToString() });
|
||||
});
|
||||
|
||||
// List the published zaaktypen — the read-only catalogus the beheer portal shows (S-15a). The BFF
|
||||
// proxies this behind medewerker-realm + beheerder authorization; the ACL trusts its callers (§8.3)
|
||||
// and is the only code allowed to read the ZGW Catalogi API (§8.1).
|
||||
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
|
||||
Results.Ok(await acl.ListZaaktypenAsync(ct)));
|
||||
|
||||
// Read the current default-fill settings (beheer config viewer, S-15b).
|
||||
app.MapGet("/default-fill", (AclService acl) => Results.Ok(acl.GetDefaultFill()));
|
||||
|
||||
// Update the default-fill settings from the beheer portal (S-15b). Behind beheerder authorization at
|
||||
// the BFF; the ACL validates the values are present (the three ZGW-mandatory fields).
|
||||
app.MapPut("/default-fill", (DefaultFillSettings body, AclService acl) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Bronorganisatie) ||
|
||||
string.IsNullOrWhiteSpace(body.VerantwoordelijkeOrganisatie) ||
|
||||
string.IsNullOrWhiteSpace(body.Vertrouwelijkheidaanduiding))
|
||||
return Results.BadRequest(new { error = "bronorganisatie, verantwoordelijkeOrganisatie and vertrouwelijkheidaanduiding are all required." });
|
||||
|
||||
acl.UpdateDefaultFill(body);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
||||
|
||||
public sealed record SetStatusRequest(string ZaakUrl);
|
||||
|
||||
public sealed record CancelZaakRequest(string ZaakUrl);
|
||||
|
||||
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
||||
|
||||
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -6,12 +6,5 @@ public sealed class AclDefaults
|
||||
public required string Bronorganisatie { get; init; }
|
||||
public required string VerantwoordelijkeOrganisatie { get; init; }
|
||||
public required string Vertrouwelijkheidaanduiding { get; init; }
|
||||
|
||||
/// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
|
||||
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
|
||||
public required string ZaaktypeIdentificatie { get; init; }
|
||||
|
||||
/// <summary>The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b);
|
||||
/// resolved to a URL by the Catalogi API, like <see cref="ZaaktypeIdentificatie"/>.</summary>
|
||||
public required string InformatieobjecttypeOmschrijving { get; init; }
|
||||
public required Uri ZaaktypeUrl { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,63 +2,32 @@ namespace Acl.Application;
|
||||
|
||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||
public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock)
|
||||
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock)
|
||||
{
|
||||
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||
public Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
// Read the current default-fill per zaak (not at construction), so a beheerder edit (S-15b)
|
||||
// takes effect on the next zaak without a restart.
|
||||
var defaults = fill.Current;
|
||||
var request = new ZaakRequest(
|
||||
defaults.Bronorganisatie,
|
||||
defaults.VerantwoordelijkeOrganisatie,
|
||||
defaults.Vertrouwelijkheidaanduiding,
|
||||
await catalog.GetZaaktypeUrlAsync(ct),
|
||||
defaults.ZaaktypeUrl,
|
||||
clock.Today,
|
||||
registration.Reference);
|
||||
|
||||
return await gateway.OpenZaakAsync(request, ct);
|
||||
return gateway.OpenZaakAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
|
||||
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||
/// 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 async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
||||
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||
/// statustype/resultaat means "cancelled" (§8.1).
|
||||
/// </summary>
|
||||
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <summary>The published zaaktypen, for the beheer catalogus viewer (S-15a). Read-only passthrough:
|
||||
/// no default-fill, the ACL is simply the only code allowed to read ZGW (§8.1).</summary>
|
||||
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
|
||||
gateway.ListZaaktypenAsync(ct);
|
||||
|
||||
/// <summary>The current default-fill settings, for the beheer config viewer (S-15b).</summary>
|
||||
public DefaultFillSettings GetDefaultFill() => fill.Current;
|
||||
|
||||
/// <summary>Replace the default-fill settings from the beheer portal (S-15b). Takes effect on the
|
||||
/// next zaak (the fill is read per zaak, not cached).</summary>
|
||||
public void UpdateDefaultFill(DefaultFillSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
fill.Update(settings);
|
||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||
@@ -68,34 +37,4 @@ public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZa
|
||||
|
||||
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store an uploaded diploma against the zaak (S-10b): default-fill the ZGW-mandatory document
|
||||
/// fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, taal, creatiedatum)
|
||||
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
|
||||
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
|
||||
/// </summary>
|
||||
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
|
||||
|
||||
var defaults = fill.Current;
|
||||
var request = new DocumentRequest(
|
||||
defaults.Bronorganisatie,
|
||||
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||
defaults.Vertrouwelijkheidaanduiding,
|
||||
zaakUrl,
|
||||
clock.Today,
|
||||
Titel: "Diploma",
|
||||
Auteur: "zorgprofessional",
|
||||
Taal: "nld",
|
||||
Bestandsnaam: fileName,
|
||||
Formaat: contentType,
|
||||
Inhoud: content);
|
||||
|
||||
return await gateway.StoreDocumentAsync(request, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Resolves the zaaktype + diploma-informatieobjecttype URLs from the Catalogi API on first
|
||||
/// use and caches them for the process lifetime (S-27, ADR-0021). Lazy (not at startup) so the ACL
|
||||
/// never crash-loops when it boots before the catalogus is seeded/published; a <em>failed</em>
|
||||
/// resolution is not cached, so it is retried on the next call (e.g. once the zaaktype is published).
|
||||
/// A process restart re-resolves.</summary>
|
||||
public sealed class CachedZaaktypeCatalog(IZaakGateway gateway, AclDefaults defaults) : IZaaktypeCatalog
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private Uri? zaaktype;
|
||||
private Uri? informatieobjecttype;
|
||||
|
||||
public Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default) =>
|
||||
ResolveOnceAsync(
|
||||
() => zaaktype, value => zaaktype = value,
|
||||
() => gateway.ResolveZaaktypeUrlAsync(defaults.ZaaktypeIdentificatie, ct), ct);
|
||||
|
||||
public Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default) =>
|
||||
ResolveOnceAsync(
|
||||
() => informatieobjecttype, value => informatieobjecttype = value,
|
||||
() => gateway.ResolveInformatieobjecttypeUrlAsync(defaults.InformatieobjecttypeOmschrijving, ct), ct);
|
||||
|
||||
// Double-checked, single-flight resolution: return the cache if set; otherwise resolve under the
|
||||
// gate and cache only on success (a throw leaves the cache empty so the next call retries).
|
||||
private async Task<Uri> ResolveOnceAsync(Func<Uri?> read, Action<Uri> store, Func<Task<Uri>> resolve, CancellationToken ct)
|
||||
{
|
||||
if (read() is { } cached)
|
||||
return cached;
|
||||
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (read() is { } existing)
|
||||
return existing;
|
||||
|
||||
var resolved = await resolve();
|
||||
store(resolved);
|
||||
return resolved;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>The ZGW default-fill values a beheerder can edit at runtime (S-15b) — the mandatory fields
|
||||
/// the ACL stamps on every zaak (ADR-0003). The S-27 catalog-resolution keys (zaaktype identificatie,
|
||||
/// informatieobjecttype omschrijving) stay static config: editing them would desync the resolved-URL
|
||||
/// cache, and they're catalogus wiring rather than "default fill".</summary>
|
||||
public sealed record DefaultFillSettings(
|
||||
string Bronorganisatie,
|
||||
string VerantwoordelijkeOrganisatie,
|
||||
string Vertrouwelijkheidaanduiding);
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>The fully default-filled diploma document the gateway will create in the ZGW Documenten
|
||||
/// API and relate to the zaak (S-10b). <see cref="Inhoud"/> is the raw file content; the gateway
|
||||
/// base64-encodes it into the ZGW <c>inhoud</c> field.</summary>
|
||||
public sealed record DocumentRequest(
|
||||
string Bronorganisatie,
|
||||
Uri Informatieobjecttype,
|
||||
string Vertrouwelijkheidaanduiding,
|
||||
Uri Zaak,
|
||||
DateOnly Creatiedatum,
|
||||
string Titel,
|
||||
string Auteur,
|
||||
string Taal,
|
||||
string Bestandsnaam,
|
||||
string Formaat,
|
||||
byte[] Inhoud);
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Holds the ACL's current default-fill values, editable at runtime through the beheer portal
|
||||
/// (S-15b). Seeded from config at startup.
|
||||
///
|
||||
/// ponytail: in-memory only — an edit is lost on restart, when it reverts to the configured env
|
||||
/// (ADR-0026). Adequate for the reference demo; back it with a DB if durable, audited config is needed.
|
||||
/// </summary>
|
||||
public interface IDefaultFillStore
|
||||
{
|
||||
DefaultFillSettings Current { get; }
|
||||
|
||||
void Update(DefaultFillSettings settings);
|
||||
}
|
||||
@@ -13,35 +13,7 @@ public interface IZaakGateway
|
||||
/// </summary>
|
||||
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Set the given zaak to the <em>cancellation</em> statustype ("Geannuleerd") and record the
|
||||
/// matching cancellation resultaat ("Vervallen") — the ZGW translation of "the 30-day document term
|
||||
/// lapsed" (S-10c). Distinct from <see cref="SetZaakToEindstatusAsync"/> (approval): the gateway
|
||||
/// resolves both the cancellation statustype and resultaattype from the catalogus by their
|
||||
/// omschrijving, POSTs the resultaat then the status, dated <paramref name="datumStatusGezet"/>.
|
||||
/// </summary>
|
||||
Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
|
||||
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
|
||||
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Store a diploma document (S-10b): create an <c>enkelvoudiginformatieobject</c> in the ZGW
|
||||
/// Documenten API and relate it to the zaak via a <c>zaakinformatieobject</c>. Returns the URL of
|
||||
/// the created informatieobject.
|
||||
/// </summary>
|
||||
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolve the URL of the published zaaktype with the given <paramref name="identificatie"/>
|
||||
/// from the Catalogi API (S-27). Throws if no published zaaktype matches.</summary>
|
||||
Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolve the URL of the published informatieobjecttype with the given
|
||||
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
|
||||
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
|
||||
|
||||
/// <summary>List the published zaaktypen from the Catalogi API — the read-only catalogus the beheer
|
||||
/// portal shows (S-15a). The ACL is the only code allowed to read ZGW (§8.1).</summary>
|
||||
Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Supplies the ACL's zaaktype + diploma-informatieobjecttype URLs, resolved from OpenZaak's
|
||||
/// Catalogi API by their stable business keys (<see cref="AclDefaults.ZaaktypeIdentificatie"/> /
|
||||
/// <see cref="AclDefaults.InformatieobjecttypeOmschrijving"/>) rather than pinned in config (S-27,
|
||||
/// ADR-0021). Implementations resolve lazily on first use and cache the result.</summary>
|
||||
public interface IZaaktypeCatalog
|
||||
{
|
||||
Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default);
|
||||
|
||||
Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>In-memory <see cref="IDefaultFillStore"/> (ADR-0026), seeded from config. Thread-safe: the
|
||||
/// hosted worker reads <see cref="Current"/> per zaak while the beheer endpoint may update it.</summary>
|
||||
public sealed class InMemoryDefaultFillStore(DefaultFillSettings seed) : IDefaultFillStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private DefaultFillSettings _current = seed;
|
||||
|
||||
public DefaultFillSettings Current
|
||||
{
|
||||
get { lock (_gate) return _current; }
|
||||
}
|
||||
|
||||
public void Update(DefaultFillSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
lock (_gate) _current = settings;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a). Public-safe: the
|
||||
/// business <see cref="Identificatie"/> + human <see cref="Omschrijving"/> and the ZGW <see cref="Url"/>
|
||||
/// (the URL is the ACL's own reference, not shown to end users).</summary>
|
||||
public sealed record ZaaktypeSummary(string Identificatie, string Omschrijving, Uri Url);
|
||||
@@ -8,12 +8,6 @@ namespace Acl.Infrastructure;
|
||||
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
|
||||
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
|
||||
{
|
||||
// The ACL owns which ZGW statustype/resultaat carries each domain outcome (§8.1). These
|
||||
// omschrijvingen match the seeded BIG catalogus (infra/openzaak/seed_catalogus.py).
|
||||
private const string GeregistreerdResultaat = "Geregistreerd"; // approval outcome
|
||||
private const string GeannuleerdStatus = "Geannuleerd"; // document-timeout cancellation status (S-10c)
|
||||
private const string VervallenResultaat = "Vervallen"; // document-timeout cancellation outcome (S-10c)
|
||||
|
||||
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
@@ -54,9 +48,7 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||
|
||||
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
||||
// Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking
|
||||
// the first would be ambiguous (the Zaken API does not guarantee order).
|
||||
var resultaattype = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, GeregistreerdResultaat, ct);
|
||||
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.
|
||||
@@ -70,27 +62,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
"Setting the zaak status", ct);
|
||||
}
|
||||
|
||||
public async Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||
|
||||
// Distinct from approval: resolve the cancellation statustype + resultaat by name (Geannuleerd
|
||||
// is a non-terminal statustype, so it is never the eindstatus the approval path resolves).
|
||||
var cancellationStatus = await ResolveStatustypeByOmschrijvingAsync(zaaktypeUrl, GeannuleerdStatus, ct);
|
||||
var cancellationResultaat = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, VervallenResultaat, ct);
|
||||
|
||||
// As with approval, OpenZaak wants the resultaat recorded before the status.
|
||||
await PostAsync("/zaken/api/v1/resultaten",
|
||||
new ResultaatDto(zaakUrl.ToString(), cancellationResultaat.ToString()),
|
||||
"Setting the zaak cancellation resultaat", ct);
|
||||
|
||||
await PostAsync("/zaken/api/v1/statussen",
|
||||
new StatusDto(zaakUrl.ToString(), cancellationStatus.ToString(),
|
||||
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
|
||||
"Setting the zaak cancellation status", ct);
|
||||
}
|
||||
|
||||
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
@@ -109,91 +80,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
return zaak.Identificatie;
|
||||
}
|
||||
|
||||
public async Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// 1. Create the enkelvoudiginformatieobject in the Documenten API (not a geo API — no CRS).
|
||||
var created = await PostForUrlAsync(
|
||||
"/documenten/api/v1/enkelvoudiginformatieobjecten",
|
||||
new EnkelvoudigInformatieobjectDto(
|
||||
request.Bronorganisatie,
|
||||
request.Creatiedatum.ToString("yyyy-MM-dd"),
|
||||
request.Titel,
|
||||
request.Auteur,
|
||||
request.Taal,
|
||||
request.Informatieobjecttype.ToString(),
|
||||
Convert.ToBase64String(request.Inhoud),
|
||||
request.Bestandsnaam,
|
||||
request.Inhoud.Length,
|
||||
request.Vertrouwelijkheidaanduiding,
|
||||
request.Formaat,
|
||||
"definitief",
|
||||
// No usage-rights restrictions apply. Left null, OpenZaak rejects closing the related
|
||||
// zaak with "indicatiegebruiksrecht-unset"; false records the deliberate "none" answer.
|
||||
false),
|
||||
"Creating the informatieobject", ct);
|
||||
|
||||
// 2. Relate it to the zaak (Zaken API — no CRS).
|
||||
await PostAsync("/zaken/api/v1/zaakinformatieobjecten",
|
||||
new ZaakInformatieobjectDto(request.Zaak.ToString(), created.ToString()),
|
||||
"Relating the informatieobject to the zaak", ct);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(identificatie);
|
||||
|
||||
// The published zaaktype with this identificatie; status=definitief excludes concepts.
|
||||
var page = await GetAsync<ZaaktypePage>(
|
||||
"/catalogi/api/v1/zaaktypen?status=definitief&identificatie=" + Uri.EscapeDataString(identificatie),
|
||||
"zaaktypen", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault()
|
||||
?? throw new InvalidOperationException(
|
||||
$"No published zaaktype with identificatie '{identificatie}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||
return new Uri(match.Url);
|
||||
}
|
||||
|
||||
public async Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(omschrijving);
|
||||
|
||||
// The informatieobjecttypen collection has no omschrijving filter, so match client-side over the
|
||||
// published ones.
|
||||
var page = await GetAsync<InformatieobjecttypePage>(
|
||||
"/catalogi/api/v1/informatieobjecttypen?status=definitief", "informatieobjecttypen", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault(i => i.Omschrijving == omschrijving)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No published informatieobjecttype '{omschrijving}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||
return new Uri(match.Url);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Only published zaaktypen (status=definitief excludes concepts) — the read-only catalogus the
|
||||
// beheer portal shows. Public-safe fields only.
|
||||
var page = await GetAsync<ZaaktypePage>("/catalogi/api/v1/zaaktypen?status=definitief", "zaaktypen", ct);
|
||||
return (page.Results ?? [])
|
||||
.Select(z => new ZaaktypeSummary(z.Identificatie ?? "", z.Omschrijving ?? "", new Uri(z.Url)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
|
||||
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, pathAndQuery));
|
||||
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");
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -210,26 +96,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
await EnsureSuccessAsync(response, action, ct);
|
||||
}
|
||||
|
||||
// POSTs a non-geo ZGW resource and returns the created resource's URL (as PostAsync, but reads back
|
||||
// the `url` of the created object). Buffers the body so uwsgi gets a Content-Length.
|
||||
private async Task<Uri> PostForUrlAsync(string path, object dto, string action, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||
{
|
||||
Content = JsonContent.Create(dto),
|
||||
};
|
||||
message.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||
await message.Content.LoadIntoBufferAsync(ct);
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, action, ct);
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<CreatedDto>(ct)
|
||||
?? throw new InvalidOperationException($"OpenZaak returned an empty response for {action}");
|
||||
return new Uri(created.Url);
|
||||
}
|
||||
|
||||
// EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
|
||||
// is essential for diagnosing a rejected request, so surface it in the exception.
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
|
||||
@@ -255,23 +121,13 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
return new Uri(eindstatus.Url);
|
||||
}
|
||||
|
||||
/// <summary>Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd").</summary>
|
||||
private async Task<Uri> ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
|
||||
{
|
||||
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault(s => s.Omschrijving == omschrijving)
|
||||
?? throw new InvalidOperationException($"No '{omschrijving}' statustype found for zaaktype {zaaktypeUrl}");
|
||||
return new Uri(match.Url);
|
||||
}
|
||||
|
||||
/// <summary>Resolve a specific resultaattype from the catalogus by its omschrijving (the seed defines
|
||||
/// "Geregistreerd" for approval and "Vervallen" for a document-timeout cancellation).</summary>
|
||||
private async Task<Uri> ResolveResultaattypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
|
||||
/// <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 match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving)
|
||||
?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}");
|
||||
return new Uri(match.Url);
|
||||
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).
|
||||
@@ -315,8 +171,7 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
private sealed record StatustypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("volgnummer")] int Volgnummer,
|
||||
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus,
|
||||
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
|
||||
|
||||
private sealed record ResultaatDto(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
@@ -326,43 +181,5 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
|
||||
|
||||
private sealed record ResultaattypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||
|
||||
private sealed record CreatedDto(
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
|
||||
private sealed record EnkelvoudigInformatieobjectDto(
|
||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||
[property: JsonPropertyName("creatiedatum")] string Creatiedatum,
|
||||
[property: JsonPropertyName("titel")] string Titel,
|
||||
[property: JsonPropertyName("auteur")] string Auteur,
|
||||
[property: JsonPropertyName("taal")] string Taal,
|
||||
[property: JsonPropertyName("informatieobjecttype")] string Informatieobjecttype,
|
||||
[property: JsonPropertyName("inhoud")] string Inhoud,
|
||||
[property: JsonPropertyName("bestandsnaam")] string Bestandsnaam,
|
||||
[property: JsonPropertyName("bestandsomvang")] int Bestandsomvang,
|
||||
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
|
||||
[property: JsonPropertyName("formaat")] string Formaat,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("indicatieGebruiksrecht")] bool IndicatieGebruiksrecht);
|
||||
|
||||
private sealed record ZaakInformatieobjectDto(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("informatieobject")] string Informatieobject);
|
||||
|
||||
private sealed record ZaaktypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ZaaktypeDto>? Results);
|
||||
|
||||
private sealed record ZaaktypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("identificatie")] string? Identificatie,
|
||||
[property: JsonPropertyName("omschrijving")] string? Omschrijving = null);
|
||||
|
||||
private sealed record InformatieobjecttypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
|
||||
|
||||
private sealed record InformatieobjecttypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user