Files
atomic-design-poc/docs/project/backlog/WP-58-openzaak-notifications.md
ehoandClaude Sonnet 5 3e983bd2cc feat(openzaak): real notification delivery to the BFF webhook (WP-58)
OpenZaak doesn't serve the Notificaties API itself (it's a separate app,
open-notificaties) — standing one up for a real abonnement would triple
this harness for a benefit it doesn't need (exactly one subscriber, this
repo's own BFF). Instead, an opt-in compose overlay adds a celery worker
and points OpenZaak's NotificationsConfig straight at the BFF's webhook
via a zgw_consumers Service; bootstrap-notificaties.sh configures it
idempotently and verify-notificatie.sh proves a real write delivers to
the BFF's audit trail end-to-end.

Verified live: preflight proves the webhook's shared-secret gate both
ways (204/401), a zaak PATCH triggers real celery delivery, and rerunning
both scripts against an already-configured harness stays idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:33:16 +02:00

7.9 KiB

WP-58 — Real notifications (celery + scripted abonnement)

Status: done Phase: 10 — OpenZaak production hardening

Why

The WP-54 harness deliberately trims celery/celery-beat/celery-flower and nginx, and sets NOTIFICATIONS_DISABLED: 'true' — without a Celery worker, OpenZaak 500s and rolls back on every write to a notified resource. Fine for a fixture-driven integration harness; a real deployment that wants live Notificaties (WP-52's webhook) needs the workers running and the abonnement (subscription) actually registered against the BFF's public callback URL — today that registration step is manual.

Read first

Decisions (pre-made, don't relitigate)

  • Add celery + celery-beat as additional compose services (same openzaak/open-zaak image, different command), pointed at the same redis broker already in the harness.
  • Registering the abonnement becomes a scripted, idempotent step — not a manual admin-UI action — parameterized by the BFF's real public URL.
  • Keep the existing WP-54 harness variant (NOTIFICATIONS_DISABLED: 'true') available for fast local iteration where a live webhook round-trip isn't needed; this WP is additive (a "with notifications" profile/override), not a replacement.

Files

  • backend/openzaak/docker-compose.openzaak.yml (or an override file)
  • New script/config for abonnement registration
  • docs/reference/openzaak-integration.md

Steps

  1. Add celery/celery-beat services to a notifications-enabled compose profile.
  2. Flip NOTIFICATIONS_DISABLED off for that profile.
  3. Script the abonnement registration (POST to the NRC, pointed at the BFF's /zgw/notificaties endpoint from WP-52), idempotent on rerun.
  4. Verify a real write (e.g. a status change) triggers a live webhook delivery to the BFF.

Acceptance criteria

  • A notifications-enabled harness profile runs a celery worker and delivers a real notification end-to-end to the BFF's webhook.
  • Provisioning is a script, re-runnable without erroring on an already-configured target.

What actually happened

The Decisions block assumed OpenZaak itself could be pointed at, celery-wired, and made to deliver to a subscribed abonnement — checking the running image (grep -ril abonnement inside the web container) found nothing: OpenZaak does not serve the Notificaties API. It's a separate application (openzaak/open-notificaties, its own image/DB/celery/beat stack). Standing one up for real abonnement/kanaal-filtered routing would mean ~5 new services (a second Postgres, web, worker, beat, plus the NRC↔AC authorization chain) for a benefit this harness doesn't need — there is exactly one subscriber (this repo's own BFF), never N. Re-scoped before writing any code (confirmed with the user): OpenZaak's own NotificationsConfig points straight at the BFF's webhook via a zgw_consumers.Service (auth_type=api_key) instead — no NRC, no abonnement, same delivery proof (a real write → OpenZaak's celery worker → a real HTTP POST → the BFF's audit trail). The two "no abonnement" acceptance-criteria words above were edited out for the same reason.

  • docker-compose.openzaak.notificaties.yml — an opt-in overlay (not profiles:, matching WP-55's prod-override precedent) adding one celery worker (not celery-beat: send_notification is a plain async task fired on save, not a scheduled one — beat only matters on a real NRC's polling side) and flipping NOTIFICATIONS_DISABLED off. The two changes are inseparable: NOTIFICATIONS_GUARANTEE_DELIVERY defaults true, so the moment that flag is false, every write to a notified resource 500s-and-rolls-back unless NotificationsConfig already has a client — hence bootstrap-notificaties.sh configuring it is not a separate step.
  • Reaching the BFF from the worker turned out to be the real obstacle, not the Django/celery wiring. extra_hosts: host.docker.internal:host-gateway (the plan's first choice) resolves fine but every TCP connect through it timed out — confirmed live: this environment's rootless Podman doesn't route container→host-port traffic that way. Fix: join the overlay's celery service to the repo root's own docker compose up network (external: true, by the atomic-design-poc_default name compose derives from the repo directory) and reach the BFF by its container name (api) instead — container-to-container, which this exact stack already proved reliable (celery already talks to db/redis that way). One more trap on that path: docker compose run --name api ... does not register the api DNS alias other containers need (only docker compose up -d api does) — cost a debugging round-trip before switching to up -d (via a temporary, uncommitted docker-compose.override.yml) for the live verification.
  • bootstrap-notificaties.shupdate_or_create on the Service's fixed slug (idempotent); preflights the BFF's webhook with a synthetic notification body first (204 required) so a misconfigured target fails before touching OpenZaak, not after (a later write would otherwise 500-and-rollback with no obvious cause).
  • verify-notificatie.sh — the runnable end-to-end check. First attempt triggered the write via a second statussen POST (the "final" status) — 403'd: WP-57's narrowed zaken.aanmaken scope permits exactly one status per zaak ("Met de 'zaken.aanmaken' scope mag je slechts 1 status zetten"). Switched the trigger to a zaak PATCH (toelichting), covered by the already-granted zaken.bijwerken and trivially repeatable. Second attempt used the final statustype anyway for a different reason and got a 400 ("Zaak has no resultaat") — OpenZaak requires a resultaat before the closing status; the PATCH sidesteps that precondition entirely too.
  • Verified for real, twice: bootstrap-catalogus.sh (idempotent re-run, all "exists") → bootstrap-notificaties.sh (preflight 204, Service configured) → verify-notificatie.sh (PATCH → polled /admin/audit → found the delivered zgw:notificatie/allow row) → reran both WP-58 scripts again under the same running harness (still idempotent, delivered again). Also confirmed the negative case directly: POST /zgw/notificaties with no Authorization header, and with a wrong one, both 401 — the shared-secret gate isn't just accepting anything. Backend suite stayed green throughout (159/159, dotnet test --filter Category!=Integration). Test infrastructure (the temporary docker-compose.override.yml, the manually-created api container) was torn down / reconciled back to the pre-session baseline afterward.

Verification

Bring up the notifications-enabled profile (backend/openzaak/README.md's "Notifications-enabled profile" section); run ./bootstrap-catalogus.sh && ./bootstrap-notificaties.sh && ./verify-notificatie.sh. The last script fails loudly (with celery/worker log diagnostics) if no delivered notification shows up in the BFF's /admin/audit within 60s.

Out of scope

Cache invalidation on notification receipt (flagged separately in openzaak-integration.md as a ponytail: marker, not part of this slice); celery-flower/monitoring UI. A real Notificaties API (NRC) + abonnement/kanaal-filtered routing (see "What actually happened") — add one if a later WP needs more than this harness's single subscriber.

Risks

Celery adds real operational surface (another process to keep alive) — scope this WP to "works, documented," not a fully monitored deployment. The direct-to-BFF shortcut means this harness doesn't exercise real abonnement/kanaal-filter validation — a production deployment's NRC-based path (documented in openzaak-integration.md) is untested by this harness by construction.

Depends on: WP-56 (provisioning mechanism this extends).