Compare commits

...
Author SHA1 Message Date
not 43c6c8e0d2 docs: ADR-0023 + demo note + backlog sync for the observability backplane (refs #122)
CI / lint (pull_request) Successful in 1m23s
CI / build (pull_request) Successful in 1m9s
CI / unit (pull_request) Successful in 1m17s
CI / frontend (pull_request) Successful in 2m43s
CI / mutation (pull_request) Successful in 5m56s
CI / verify-stack (pull_request) Failing after 8m6s
ADR-0023 records the Grafana-native stack (Tempo, no collector, config baked into
images); demo-script and BACKLOG describe S-16a and the S-16 split.

refs #122
2026-07-23 12:48:02 +02:00
not f29a8598fd feat(infra): Tempo + Prometheus + Grafana observability backplane in compose (refs #122)
Three built images on the cg network with config baked in: Tempo (OTLP ingest,
ports 4317/4318), Prometheus, and Grafana with both datasources auto-provisioned.
Config lives in infra/observability/. The verify-observability step runs early in
CI verify-stack; the three containers are added to the failure log-dump list.
Tempo's Grafana plugin has no datasource /health method, so the check proves
Tempo reachability through Grafana's datasource proxy instead.

refs #122
2026-07-23 12:46:54 +02:00
not f12e9ce746 test(infra): verify-observability asserts Grafana + Tempo/Prometheus datasources are live (refs #122)
Polls Grafana's datasource-health endpoints so it proves Grafana can actually
reach Tempo and Prometheus over the cg network, not merely that the containers
started. Fails until the backplane is wired (next commit).

refs #122
2026-07-23 12:41:58 +02:00
not 4fe9915816 feat(domain): herregistratie reminder sweep on a Quartz cron (S-17, closes #18) (#121)
CI / verify-stack (push) Successful in 8m14s
CI / lint (push) Successful in 1m20s
CI / build (push) Successful in 59s
CI / unit (push) Successful in 1m16s
CI / frontend (push) Successful in 2m38s
CI / mutation (push) Successful in 5m53s
## What & why

S-17: a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. This adds a **daily herregistratie reminder sweep**.

- **Domain:** `Approve(ingeschrevenOp)` now stamps the inscription moment; `HerregistratieVoor` derives the deadline (inscription + 5-year validity); `HerregistratieReminderDue(asOf)` is the single rule (inside the 90-day window, inscribed, not yet reminded); `MarkHerregistratieReminderVerstuurd()` is idempotent.
- **Store:** `FindDueForHerregistratieReminderAsync(asOf)` — the sweep's candidate set, filtered on the aggregate's own rule (no duplicated policy).
- **Application:** `HerregistratieReminderSweep` — pure over the store + an injected `TimeProvider`; flags + persists each due inscription, returns the reminded ids.
- **Infra/API:** `HerregistratieReminderJob` (Quartz `IJob`) fires the sweep on a daily cron (03:00, overridable via `Quartz__Cron`) and logs the count. `GET /registrations/{id}` surfaces `herregistratieVoor` + `herregistratieReminderVerstuurd`.

**Decisions (both raised with you before coding):** use Quartz.NET as the PRD names it — a genuine cron concern, distinct from the queue-draining pumps, which stay as-is (**ADR-0022**, proposal #120); and the reminder's observable effect is a flag on the aggregate + a log line (no outbound notification infra in v1). No coupling rule (§8) is touched — Quartz is internal to the Domain Service.

Closes #18
Closes #120

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer: domain rule, store query, sweep).
- [x] Implementation makes the test pass; refactor commit for the 90-day knob.
- [x] Conventional Commits referencing the issue (`refs #18`).
- [ ] CI green — awaiting Gitea Actions.
- [ ] `docker compose up` reaches green health checks within 3 minutes — API boots locally with Quartz initialised; verified in CI compose smoke.
- [x] Docs updated — ADR-0022, demo-script, BACKLOG.
- [x] ADR added — `docs/architecture/adr-0022-quartz-scheduler.md`.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Ripple:** `Approve()` gained the inscription moment, so the two approving handlers (`ApproveRegistration`, `BeoordeelRegistratie`) now take an injected `TimeProvider`; existing tests pass a fixed clock. All three `IRegistrationStore` implementers (prod, unit fake, acceptance) got the new query.
- **Calibration knobs:** validity (5y) and reminder lead time (90d) are domain constants marked with `ponytail:` comments; promotion path to beheer config (S-15) noted in the ADR.
- **Mutation:** the Quartz job shell is excluded from Stryker, mirroring the pumps; all rule/sweep/query logic is covered.
- Local: 152 domain unit tests green; API boots with the Quartz scheduler and `/health` green.

Reviewed-on: #121
2026-07-23 10:31:56 +00:00
not 5f8ab4dbcd feat: self-service resume of an existing registration after refresh (S-26, closes #111) (#119)
CI / lint (push) Successful in 1m19s
CI / build (push) Successful in 1m7s
CI / unit (push) Successful in 1m16s
CI / frontend (push) Successful in 2m41s
CI / mutation (push) Successful in 5m57s
CI / verify-stack (push) Successful in 8m24s
## What & why

After submitting, the self-service portal held the registration only in in-memory signals, so a **page refresh stranded an in-flight registration** — the reference and its "Documenten aanleveren" / "Trek aanvraag in" actions were lost, with no way back (the reference wasn't in the URL and there was no read endpoint). This is the gap a citizen hit in testing.

Now the portal **resumes on load**:
- **Domain:** `IRegistrationStore.FindOpenByBsnAsync` (the citizen's non-terminal INGEDIEND/IN_BEHANDELING registration) + `GET /registrations/current?bsn=`.
- **BFF:** owner-scoped `GET /self-service/registrations` (bsn from the DigiD token) → the current registration, or **204** when none. Regenerated `services/bff/openapi.json`.
- **Frontend:** `registration-page` calls it on init and restores the submitted view (reference + actions); 204 shows the submit form as before. api-client regenerated (orval).

Closes #111

## Definition of Done

- [x] Linked issue (#111).
- [x] TDD — store `FindOpenByBsnAsync` tests, BFF endpoint tests, an Angular component test (resume-on-load), a Playwright e2e (submit → reload → restored).
- [x] Conventional Commits referencing #111.
- [ ] CI green — validated locally (below); runner CI running.
- [x] `docker compose up` reaches green health — fresh stack + full e2e (3 specs) green.
- [x] Docs — `docs/synthetic-data.md` (new e2e users).
- [ ] ADR — N/A (follows existing BFF/domain patterns; no boundary change).
- [ ] Demo note — the flow is unchanged for the demo; no new demo-script section (happy to add one if wanted).

## Verified locally

- Unit: Big 141 (+7 store tests), Bff 36 (+3 endpoint tests), all suites green.
- Frontend: 12 self-service component tests (incl. resume-on-load); lint + build green.
- **e2e (fresh CI stack): all 3 specs pass** — `registration`, `resume`, `withdrawal` (29.5s, single worker).
- Mutation: domain **91.04%**, bff **100%** (break 90%). `make lint` clean.

## Notes for reviewers

- **Shared-stack isolation:** resume-on-load restores any open registration for the logged-in bsn, so the self-service e2e specs can no longer share `jan-burger` (the verify-* API checks submit as `jan-burger`/`123456782` before the e2e). Each spec now has its own DigiD citizen (`emma`/`sanne`/`lars`-burger); `jan-burger` stays the documented citizen for the verify checks. This is the fix for the two intermittent e2e failures seen during development.
- **Scope:** resumes the current **in-flight** registration only (terminal ones aren't resumed), per the issue's out-of-scope note.

Reviewed-on: #119
2026-07-23 07:22:08 +00:00
48 changed files with 1131 additions and 46 deletions
+3 -1
View File
@@ -142,6 +142,8 @@ jobs:
# reaches green health" smoke (it replaces the old compose-smoke job).
- name: Bring up the full stack & wait for health
run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
run: OBS_TIMEOUT=180 make verify-observability
- name: ACL ↔ OpenZaak integration tests
run: make verify-acl
- name: OpenZaak → NRC notification delivery
@@ -157,7 +159,7 @@ jobs:
# 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 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 tempo prometheus grafana 2>&1 || true
- name: Tear down
if: always()
run: make down
+9 -3
View File
@@ -253,13 +253,19 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
**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.
### S-16 · OpenTelemetry traces + Grafana dashboard
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
Split into independently deployable sub-slices (CLAUDE.md §13):
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
- **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; nginx `traceparent` passthrough). 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.
---
+6 -1
View File
@@ -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-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-observability 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
## 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).
@@ -170,6 +170,11 @@ 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: 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.
@@ -21,16 +21,20 @@ 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)),
) {
return {
post,
withdraw,
provideDocuments,
getCurrent,
providers: [
{ provide: AuthService, useClass: FakeAuth },
{
provide: BffApiV1Service,
useValue: {
getSelfServiceRegistrations: getCurrent,
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments,
@@ -56,6 +60,21 @@ 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 });
@@ -1,5 +1,5 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
@@ -8,13 +8,16 @@ import { UtrechtComponentsModule } from 'ui';
* 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).
*/
@Component({
selector: 'app-registration-page',
imports: [UtrechtComponentsModule],
templateUrl: './registration-page.html',
})
export class RegistrationPage {
export class RegistrationPage implements OnInit {
private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service);
@@ -31,6 +34,23 @@ export class RegistrationPage {
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);
this.failed.set(false);
@@ -0,0 +1,79 @@
# 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.
@@ -0,0 +1,74 @@
# 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.
+54
View File
@@ -5,6 +5,60 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## 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
+1
View File
@@ -14,6 +14,7 @@ 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` |
+39
View File
@@ -524,6 +524,45 @@ services:
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"]
networks: [cg]
prometheus:
build:
context: ./observability/prometheus
image: register-referentie/prometheus:dev
ports:
- "9090:9090"
networks: [cg]
grafana:
build:
context: ./observability/grafana
image: register-referentie/grafana:dev
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:
+30
View File
@@ -38,6 +38,36 @@
"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"] }
}
]
}
+4
View File
@@ -0,0 +1,4 @@
# Grafana with datasources baked in via provisioning (S-16a, ADR-0023).
# Dashboards (S-16c, #124) are added under provisioning/dashboards later.
FROM grafana/grafana:11.3.0
COPY provisioning/ /etc/grafana/provisioning/
@@ -0,0 +1,17 @@
# 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
@@ -0,0 +1,2 @@
FROM prom/prometheus:v2.55.1
COPY prometheus.yml /etc/prometheus/prometheus.yml
@@ -0,0 +1,10 @@
# Prometheus scrape config (S-16a, ADR-0023). For the backplane slice it scrapes
# only itself; the .NET services' /metrics scrape targets are added in S-16c
# (#124) when the services expose metrics.
global:
scrape_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
+4
View File
@@ -0,0 +1,4 @@
# 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
+27
View File
@@ -0,0 +1,27 @@
# 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
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# S-16a (#122): assert the observability backplane is live against the running stack.
# Not just "the containers started" — it asks Grafana to health-check its provisioned
# Tempo and Prometheus datasources, which proves Grafana can actually reach both over
# the `cg` network and that provisioning landed. Polls, so it tolerates a cold Grafana.
#
# Usage: run-observability-check.sh (override GRAFANA_URL / GRAFANA_AUTH / OBS_TIMEOUT)
set -euo pipefail
GRAFANA="${GRAFANA_URL:-http://localhost:3000}"
AUTH="${GRAFANA_AUTH:-admin:admin}"
TIMEOUT="${OBS_TIMEOUT:-60}"
# poll <description> <url> <python-bool-expr over `d` (parsed JSON)>
# Passes when the expression prints True within TIMEOUT. Tempo's Grafana plugin
# doesn't implement the datasource /health method, so instead of the Prometheus-
# style health probe we prove reachability through Grafana's datasource proxy.
poll() {
local desc="$1" url="$2" expr="$3" got deadline
deadline=$(( $(date +%s) + TIMEOUT ))
while :; do
got="$(curl -fsS -u "$AUTH" "$url" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print($expr)" 2>/dev/null || true)"
[ "$got" = "True" ] && { echo " ✓ $desc"; return 0; }
if [ "$(date +%s)" -ge "$deadline" ]; then
echo " ✗ $desc — check failed ($url)" >&2
return 1
fi
sleep 2
done
}
echo "Checking observability backplane at $GRAFANA ..."
poll "Grafana is healthy" \
"$GRAFANA/api/health" "d['database'] == 'ok'"
poll "Prometheus datasource reachable" \
"$GRAFANA/api/datasources/uid/prometheus/health" "d['status'] == 'OK'"
poll "Tempo datasource reachable (via Grafana proxy)" \
"$GRAFANA/api/datasources/proxy/uid/tempo/api/status/buildinfo" "bool(d.get('version'))"
echo "Observability backplane OK."
@@ -24,6 +24,11 @@ import {
Observable
} from 'rxjs';
export interface CurrentRegistration {
registrationId: string;
status: string;
}
export interface DecideRequest {
besluit: string;
}
@@ -200,6 +205,37 @@ 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>>;
+20
View File
@@ -5,6 +5,10 @@ namespace Bff.Api;
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>The caller's current open registration, for resuming the self-service portal after a
/// refresh (S-26): the reference (registration id) + its status.</summary>
public sealed record CurrentRegistration(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
@@ -22,6 +26,10 @@ public interface IDomainClient
{
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>The caller's current open registration (resume after refresh, S-26), or <c>null</c>
/// when they have none in flight. Owner-scoped by <paramref name="bsn"/>.</summary>
Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
@@ -58,6 +66,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status);
}
public async Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
using var response = await http.GetAsync($"registrations/current?bsn={Uri.EscapeDataString(bsn)}", ct);
// The domain 404s when the citizen has no open registration — that's "none", not an error.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
return new CurrentRegistration(dto.RegistrationId, dto.Status);
}
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
+18
View File
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the
// portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token;
// 204 when the citizen has none in flight (so the portal shows the submit form).
app.MapGet("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var current = await domain.GetCurrentRegistrationAsync(bsn, ct);
return current is null ? Results.NoContent() : Results.Ok(current);
})
.RequireAuthorization()
.Produces<CurrentRegistration>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
+12
View File
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result);
}
public string? CurrentQueriedBsn { get; private set; }
/// <summary>The current open registration the fake domain returns (null → the citizen has none in
/// flight, so the BFF replies 204). Tests set this to exercise resume.</summary>
public CurrentRegistration? Current { get; set; }
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
CurrentQueriedBsn = bsn;
return Task.FromResult(Current);
}
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
@@ -168,5 +169,52 @@ public class SelfServiceEndpointTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private static HttpRequestMessage Current(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_current_registration_lookup_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Current(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Returns_no_content_when_the_caller_has_no_open_registration()
{
using var factory = new BffFactory();
factory.Domain.Current = null;
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
}
[Fact]
public async Task Returns_the_callers_current_registration_when_one_is_open()
{
using var factory = new BffFactory();
factory.Domain.Current = new CurrentRegistration("reg-77", "Ingediend");
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
var body = await response.Content.ReadFromJsonAsync<CurrentRegistrationDto>();
Assert.Equal("reg-77", body!.RegistrationId);
Assert.Equal("Ingediend", body.Status);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
}
+41
View File
@@ -28,6 +28,32 @@
"description": "Unauthorized"
}
}
},
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentRegistration"
}
}
}
},
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/self-service/registrations/{id}/withdraw": {
@@ -205,6 +231,21 @@
},
"components": {
"schemas": {
"CurrentRegistration": {
"required": [
"registrationId",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"DecideRequest": {
"required": [
"besluit"
+4
View File
@@ -5,6 +5,10 @@
<ProjectReference Include="..\Big.Infrastructure\Big.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
+42 -2
View File
@@ -1,6 +1,7 @@
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
using Quartz;
var builder = WebApplication.CreateBuilder(args);
@@ -15,6 +16,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
// The system clock, injected wherever a use case needs "now" (e.g. stamping the inscription moment
// on approval, S-17). Injected as TimeProvider so tests can substitute a fixed clock.
builder.Services.AddSingleton(TimeProvider.System);
// The Workflow Client is one type behind two ports (start side + worker side); both resolve to the
// same HttpClient-backed implementation — the only code that talks to Flowable (§8.2).
builder.Services.AddHttpClient<FlowableWorkflowClient>();
@@ -36,6 +41,7 @@ builder.Services.AddScoped<OpenZaakJobProcessor>();
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
builder.Services.AddScoped<ExpireRegistrationWorker>();
builder.Services.AddScoped<RegistratieVerlopenProcessor>();
builder.Services.AddScoped<HerregistratieReminderSweep>();
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
builder.Services.AddHostedService<OpenZaakJobPump>();
@@ -46,6 +52,19 @@ builder.Services.AddHostedService<BeoordelingEscalatiePump>();
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
builder.Services.AddHostedService<RegistratieVerlopenPump>();
// The herregistratie reminder sweep runs on a daily cron via Quartz.NET (S-17, ADR-0022) — a
// time-triggered fleet sweep, deliberately a different mechanism from the queue-draining pumps above.
// The cron is overridable with Quartz__Cron; it defaults to 03:00 daily.
builder.Services.AddQuartz(q =>
{
var jobKey = new JobKey("herregistratie-reminder");
q.AddJob<HerregistratieReminderJob>(jobKey);
q.AddTrigger(t => t
.ForJob(jobKey)
.WithCronSchedule(builder.Configuration["Quartz:Cron"] ?? "0 0 3 * * ?"));
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
var app = builder.Build();
app.MapGet("/health", () => "Healthy");
@@ -141,6 +160,21 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
Results.Ok(await werkbak.GetAsync(ct)));
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
// The citizen's current open registration, looked up by bsn — lets the self-service portal resume
// after a refresh (S-26). The BFF forwards the bsn from the DigiD token; the domain trusts its
// callers (§8.3). 404 when the citizen has none in flight.
app.MapGet("/registrations/current", async (string bsn, IRegistrationStore store, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("A bsn is required.");
var registration = await store.FindOpenByBsnAsync(bsn, ct);
return registration is null
? Results.NotFound()
: Results.Ok(new RegistrationResponse(
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
});
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
@@ -150,7 +184,8 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca
return registration is null
? Results.NotFound()
: Results.Ok(new RegistrationResponse(
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString(),
registration.HerregistratieVoor?.ToString("O"), registration.HerregistratieReminderVerstuurd));
});
await app.RunAsync();
@@ -163,6 +198,11 @@ public sealed record WithdrawRequest(string Bsn);
public sealed record ProvideDocumentsRequest(string Bsn, string ContentBase64, string? FileName = null, string? ContentType = null);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public sealed record RegistrationResponse(
string RegistrationId,
string Status,
string? ZaakUrl,
string? HerregistratieVoor = null,
bool HerregistratieReminderVerstuurd = false);
public partial class Program;
@@ -12,7 +12,7 @@ public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId);
/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
/// keeps the domain's own view consistent.
/// </summary>
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl)
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl, TimeProvider clock)
{
public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default)
{
@@ -30,7 +30,7 @@ public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve();
registration.Approve(clock.GetUtcNow());
await store.SaveAsync(registration, ct);
}
}
@@ -25,7 +25,7 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId,
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
/// state is a no-op, so the ACL is not called and the task not completed twice.
/// </summary>
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks, TimeProvider clock)
{
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
{
@@ -44,7 +44,7 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac
throw new InvalidOperationException(
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve();
registration.Approve(clock.GetUtcNow());
break;
case BeoordelingsBesluit.Afwijzen:
@@ -0,0 +1,30 @@
using Big.Domain;
namespace Big.Application;
/// <summary>
/// The herregistratie reminder sweep (S-17): find the inscriptions whose herregistratie deadline is
/// within the reminder window and have not yet been reminded, mark each reminded, and persist it. Pure
/// application logic over ports — it knows nothing of Quartz; the scheduled job that fires it on a cron
/// lives in Infrastructure (mirroring how the pumps' processors are pure and the pump is the shell).
/// Idempotent: <see cref="Registration.MarkHerregistratieReminderVerstuurd"/> drops an inscription from
/// the next sweep's candidate set, so a re-fire reminds no one twice. Returns the reminded ids so the
/// caller can observe the sweep's effect — the reminder itself is the flag persisted on the aggregate.
/// </summary>
public sealed class HerregistratieReminderSweep(IRegistrationStore store, TimeProvider clock)
{
public async Task<IReadOnlyList<RegistrationId>> SweepAsync(CancellationToken ct = default)
{
var due = await store.FindDueForHerregistratieReminderAsync(clock.GetUtcNow(), ct);
var reminded = new List<RegistrationId>(due.Count);
foreach (var registration in due)
{
registration.MarkHerregistratieReminderVerstuurd();
await store.SaveAsync(registration, ct);
reminded.Add(registration.Id);
}
return reminded;
}
}
+12
View File
@@ -102,6 +102,18 @@ public interface IRegistrationStore
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
/// <summary>The citizen's current <em>open</em> (non-terminal: INGEDIEND/IN_BEHANDELING)
/// registration, or <c>null</c> if they have none in flight. Lets the self-service portal resume
/// an existing registration after a refresh (S-26); terminal registrations are not resumed.</summary>
Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default);
/// <summary>The inscriptions whose herregistratie reminder is due as of <paramref name="asOf"/> and
/// not yet sent — the herregistratie reminder sweep's candidate set (S-17). The predicate is the
/// aggregate's own <see cref="Registration.HerregistratieReminderDue"/> rule, so the store never
/// duplicates the herregistratie policy.</summary>
Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
DateTimeOffset asOf, CancellationToken ct = default);
}
/// <summary>
+53 -4
View File
@@ -92,11 +92,12 @@ public sealed class Registration
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
/// has already been decided cannot be approved again.
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/> and
/// records <paramref name="ingeschrevenOp"/> as the moment of inscription, which starts the
/// herregistratie clock (S-17). Requires an opened zaak (the approval sets that zaak's status via
/// the ACL); a registration that has already been decided cannot be approved again.
/// </summary>
public void Approve()
public void Approve(DateTimeOffset ingeschrevenOp)
{
if (ZaakUrl is null)
throw new InvalidOperationException(
@@ -104,6 +105,54 @@ public sealed class Registration
RequireOpenForDecision(nameof(Approve));
Status = RegistrationStatus.Ingeschreven;
IngeschrevenOp = ingeschrevenOp;
}
// --- Herregistratie (S-17) — RED stubs, implemented in the green commit ---------------------
/// <summary>How long a BIG inscription stays valid before herregistratie is required.</summary>
// ponytail: fixed 5-year term — a calibration knob, not a config surface. If a demo needs it
// per-catalogus, promote it to policy passed in from the beheer config (S-15).
public static readonly TimeSpan HerregistratieGeldigheid = TimeSpan.FromDays(365 * 5);
/// <summary>How long before the deadline the herregistratie reminder is sent (S-17: 90 days).</summary>
// ponytail: fixed 90-day lead time — calibration knob; same promotion path as HerregistratieGeldigheid.
public static readonly TimeSpan Herinneringstermijn = TimeSpan.FromDays(90);
/// <summary>When the registration was entered in the register, once approved; the start of its
/// herregistratie clock. Null until it is <see cref="RegistrationStatus.Ingeschreven"/>.</summary>
public DateTimeOffset? IngeschrevenOp { get; private set; }
/// <summary>The date by which herregistratie must happen: inscription + validity. Null until
/// inscribed.</summary>
public DateTimeOffset? HerregistratieVoor =>
IngeschrevenOp is DateTimeOffset ingeschrevenOp ? ingeschrevenOp + HerregistratieGeldigheid : null;
/// <summary>Whether the herregistratie reminder has been sent for this inscription (S-17).</summary>
public bool HerregistratieReminderVerstuurd { get; private set; }
/// <summary>Whether, as of <paramref name="asOf"/>, this registration is due a herregistratie
/// reminder: it is inscribed, the reminder window before its deadline has opened, and it has not
/// already been reminded. Once inside the window it stays due until reminded (an overdue inscription
/// is still due). This is the single rule the store query and the sweep both build on.</summary>
public bool HerregistratieReminderDue(DateTimeOffset asOf) =>
Status == RegistrationStatus.Ingeschreven
&& !HerregistratieReminderVerstuurd
&& IngeschrevenOp is DateTimeOffset ingeschrevenOp
&& asOf >= ingeschrevenOp + HerregistratieGeldigheid - Herinneringstermijn;
/// <summary>Record that the herregistratie reminder has been sent. Idempotent — a re-sweep is a
/// no-op (§8.6); only an inscribed registration can be reminded.</summary>
public void MarkHerregistratieReminderVerstuurd()
{
if (HerregistratieReminderVerstuurd)
return;
if (Status != RegistrationStatus.Ingeschreven)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGESCHREVEN registration can be sent a herregistratie reminder.");
HerregistratieReminderVerstuurd = true;
}
/// <summary>
@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.18.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,26 @@
using Big.Application;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Big.Infrastructure;
/// <summary>
/// The Quartz job that fires the herregistratie reminder sweep on a cron schedule (S-17, ADR-0022).
/// A deliberately thin shell — it resolves the pure <see cref="HerregistratieReminderSweep"/> (Quartz's
/// MS-DI job factory gives each fire its own scope) and logs how many reminders went out; all the
/// sweep logic is unit-tested in the application layer. Quartz drives this — rather than a
/// BackgroundService poll loop like the pumps — because it is a time-triggered fleet sweep, not a
/// queue to drain (the distinction recorded in ADR-0022). <see cref="DisallowConcurrentExecutionAttribute"/>
/// stops a slow sweep overlapping the next fire against the shared store.
/// </summary>
[DisallowConcurrentExecution]
public sealed class HerregistratieReminderJob(
HerregistratieReminderSweep sweep, ILogger<HerregistratieReminderJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var reminded = await sweep.SweepAsync(context.CancellationToken);
logger.LogInformation(
"Herregistratie-sweep voltooid: {Count} herinnering(en) verstuurd.", reminded.Count);
}
}
@@ -22,4 +22,13 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
DateTimeOffset asOf, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Registration>>(
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
}
@@ -19,7 +19,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
@@ -36,7 +36,7 @@ public class ApproveRegistrationTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
@@ -47,7 +47,7 @@ public class ApproveRegistrationTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
@@ -62,7 +62,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
@@ -77,7 +77,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
@@ -29,7 +29,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
store.Seed(registration);
var tasks = TaskFor(registration);
var handler = new BeoordeelRegistratie(store, acl, tasks);
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -50,7 +50,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
store.Seed(registration);
var tasks = TaskFor(registration);
var handler = new BeoordeelRegistratie(store, acl, tasks);
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
@@ -69,7 +69,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
registration.TakeIntoBehandeling();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -81,7 +81,7 @@ public class BeoordeelRegistratieTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
@@ -93,7 +93,7 @@ public class BeoordeelRegistratieTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
@@ -108,7 +108,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
@@ -123,7 +123,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -139,7 +139,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
@@ -158,7 +158,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
store.Seed(registration);
var tasks = new FakeUserTaskClient([]); // no open task for this registration
var handler = new BeoordeelRegistratie(store, acl, tasks);
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
+16
View File
@@ -22,6 +22,15 @@ internal sealed class FakeRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
DateTimeOffset asOf, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Registration>>(
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
public void Seed(Registration registration) => _byId[registration.Id] = registration;
}
@@ -81,6 +90,13 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
}
}
/// <summary>A <see cref="TimeProvider"/> pinned to a fixed instant, so time-based use cases (the
/// herregistratie sweep, S-17) are deterministic without the TimeProvider.Testing package.</summary>
internal sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
/// fixed zaak URL.</summary>
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
@@ -0,0 +1,67 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
// S-17 (#18): the sweep behind the Quartz job. It reminds every inscription whose herregistratie
// reminder is due, marks each so a re-fire is a no-op (§8.6), and returns the reminded ids. Pure over
// the store + an injected clock — no Quartz here.
public class HerregistratieReminderSweepTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
private static Registration Inscribed(string bsn, DateTimeOffset ingeschrevenOp)
{
var registration = Registration.Submit(bsn);
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.Approve(ingeschrevenOp);
return registration;
}
// Inscribed exactly (geldigheid - herinneringstermijn) before Now: the reminder window is open.
private static Registration Due(string bsn)
=> Inscribed(bsn, Now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn);
[Fact]
public async Task Reminds_and_persists_every_due_inscription_and_returns_their_ids()
{
var store = new FakeRegistrationStore();
var a = Due("123456782");
var b = Due("111111110");
var freshlyInscribed = Inscribed("222222222", Now); // not yet in the window
store.Seed(a);
store.Seed(b);
store.Seed(freshlyInscribed);
var reminded = await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync();
Assert.Equal(new HashSet<RegistrationId> { a.Id, b.Id }, reminded.ToHashSet());
Assert.True((await store.GetAsync(a.Id))!.HerregistratieReminderVerstuurd);
Assert.True((await store.GetAsync(b.Id))!.HerregistratieReminderVerstuurd);
Assert.False((await store.GetAsync(freshlyInscribed.Id))!.HerregistratieReminderVerstuurd);
Assert.Equal(2, store.SaveCount);
}
[Fact]
public async Task A_second_sweep_reminds_no_one_again()
{
var store = new FakeRegistrationStore();
store.Seed(Due("123456782"));
var sweep = new HerregistratieReminderSweep(store, new FixedClock(Now));
await sweep.SweepAsync();
var second = await sweep.SweepAsync();
Assert.Empty(second);
Assert.Equal(1, store.SaveCount); // only the first sweep persisted anything
}
[Fact]
public async Task Reminds_no_one_when_nothing_is_due()
{
var store = new FakeRegistrationStore();
store.Seed(Inscribed("123456782", Now)); // freshly inscribed — deadline is 5 years off
Assert.Empty(await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync());
}
}
@@ -41,4 +41,82 @@ public class InMemoryRegistrationStoreTests
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
}
[Fact]
public async Task Finds_the_open_registration_for_a_bsn()
{
var store = new InMemoryRegistrationStore();
var open = Registration.Submit("123456782");
await store.SaveAsync(open);
var found = await store.FindOpenByBsnAsync("123456782");
Assert.NotNull(found);
Assert.Equal(open.Id, found.Id);
}
[Fact]
public async Task An_in_behandeling_registration_is_still_open()
{
var store = new InMemoryRegistrationStore();
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
await store.SaveAsync(registration);
Assert.NotNull(await store.FindOpenByBsnAsync("123456782"));
}
[Theory]
[InlineData(nameof(Registration.Withdraw))]
[InlineData(nameof(Registration.Approve))]
[InlineData(nameof(Registration.Reject))]
[InlineData(nameof(Registration.Expire))]
public async Task A_terminal_registration_is_not_returned_as_open(string transition)
{
var store = new InMemoryRegistrationStore();
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); // Approve requires an opened zaak
switch (transition)
{
case nameof(Registration.Withdraw): registration.Withdraw(); break;
case nameof(Registration.Approve): registration.Approve(DateTimeOffset.UtcNow); break;
case nameof(Registration.Reject): registration.Reject(); break;
case nameof(Registration.Expire): registration.Expire(); break;
}
await store.SaveAsync(registration);
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
}
[Fact]
public async Task Does_not_return_another_bsns_registration_or_an_unknown_bsn()
{
var store = new InMemoryRegistrationStore();
await store.SaveAsync(Registration.Submit("111111110"));
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
}
[Fact]
public async Task Finds_only_the_inscriptions_due_for_a_herregistratie_reminder()
{
var now = new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
var store = new InMemoryRegistrationStore();
var due = Registration.Submit("123456782");
due.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
due.Approve(now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn);
await store.SaveAsync(due);
var freshlyInscribed = Registration.Submit("111111110");
freshlyInscribed.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/def"));
freshlyInscribed.Approve(now);
await store.SaveAsync(freshlyInscribed);
await store.SaveAsync(Registration.Submit("222222222")); // still INGEDIEND — never inscribed
var result = await store.FindDueForHerregistratieReminderAsync(now);
Assert.Equal([due.Id], result.Select(r => r.Id).ToArray());
}
}
@@ -0,0 +1,90 @@
using Big.Domain;
namespace Big.Tests;
// S-17 (#18): a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must
// herregistreren. The aggregate records when it was inscribed, derives the herregistratie deadline, and
// answers whether a reminder is due as of a given moment — the single rule the Quartz sweep and the
// store query both build on. All arithmetic is against an explicit "now" so it is wall-clock-free.
public class RegistrationHerregistratieTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
// The moment the reminder window opens: inscribed exactly (geldigheid - herinneringstermijn) ago.
private static DateTimeOffset InscribedSoDueAt(DateTimeOffset asOf)
=> asOf - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn;
private static Registration Inscribed(DateTimeOffset ingeschrevenOp)
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.Approve(ingeschrevenOp);
return registration;
}
[Fact]
public void Approving_records_the_inscription_moment_and_the_herregistratie_deadline()
{
var registration = Inscribed(Now);
Assert.Equal(Now, registration.IngeschrevenOp);
Assert.Equal(Now + Registration.HerregistratieGeldigheid, registration.HerregistratieVoor);
}
[Fact]
public void A_reminder_is_due_the_moment_the_window_before_the_deadline_opens()
{
var registration = Inscribed(InscribedSoDueAt(Now));
Assert.True(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void A_reminder_is_not_yet_due_one_day_before_the_window_opens()
{
var registration = Inscribed(InscribedSoDueAt(Now) + TimeSpan.FromDays(1));
Assert.False(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void A_registration_that_is_not_ingeschreven_is_never_due_and_has_no_deadline()
{
var registration = Registration.Submit("123456782"); // INGEDIEND, never inscribed
Assert.Null(registration.IngeschrevenOp);
Assert.Null(registration.HerregistratieVoor);
Assert.False(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void A_reminded_registration_is_no_longer_due()
{
var registration = Inscribed(InscribedSoDueAt(Now));
registration.MarkHerregistratieReminderVerstuurd();
Assert.True(registration.HerregistratieReminderVerstuurd);
Assert.False(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void Marking_the_reminder_sent_twice_is_idempotent()
{
var registration = Inscribed(InscribedSoDueAt(Now));
registration.MarkHerregistratieReminderVerstuurd();
registration.MarkHerregistratieReminderVerstuurd();
Assert.True(registration.HerregistratieReminderVerstuurd);
}
[Fact]
public void Marking_a_reminder_on_a_registration_that_is_not_ingeschreven_is_rejected()
{
var registration = Registration.Submit("123456782");
var ex = Assert.Throws<InvalidOperationException>(() => registration.MarkHerregistratieReminderVerstuurd());
Assert.Contains("INGESCHREVEN", ex.Message);
}
}
+12 -9
View File
@@ -4,6 +4,9 @@ namespace Big.Tests;
public class RegistrationTests
{
// A fixed inscription moment for the approval tests; its exact value is irrelevant to them.
private static readonly DateTimeOffset Ingeschreven = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Submitting_a_registration_starts_in_ingediend()
{
@@ -103,7 +106,7 @@ public class RegistrationTests
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
registration.Approve(Ingeschreven);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
@@ -113,7 +116,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve(Ingeschreven));
Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
@@ -124,9 +127,9 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
registration.Approve(Ingeschreven);
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve(Ingeschreven));
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
@@ -157,7 +160,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
registration.Approve(Ingeschreven);
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
Assert.Contains("only an INGEDIEND", ex.Message);
@@ -171,7 +174,7 @@ public class RegistrationTests
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.TakeIntoBehandeling();
registration.Approve();
registration.Approve(Ingeschreven);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
@@ -218,7 +221,7 @@ public class RegistrationTests
var approveEx = Assert.Throws<InvalidOperationException>(() =>
{
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
registration.Approve(Ingeschreven);
});
Assert.Contains("IN_BEHANDELING", approveEx.Message);
@@ -277,7 +280,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
registration.Approve(Ingeschreven);
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
@@ -336,7 +339,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
registration.Approve(Ingeschreven);
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
Assert.Contains("only an INGEDIEND", ex.Message);
+2 -1
View File
@@ -6,7 +6,8 @@
"mutate": [
"!**/OpenZaakJobPump.cs",
"!**/BeoordelingEscalatiePump.cs",
"!**/RegistratieVerlopenPump.cs"
"!**/RegistratieVerlopenPump.cs",
"!**/HerregistratieReminderJob.cs"
],
"thresholds": {
"high": 95,
@@ -40,7 +40,7 @@ public sealed class EenRegistratieBeoordelenSteps
[When("the behandelaar decides \"(.*)\"")]
public async Task WhenTheBehandelaarDecides(string besluit)
=> await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync(
=> await new BeoordeelRegistratie(_store, _acl, _tasks, TimeProvider.System).HandleAsync(
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
[Then("the registration has status \"(.*)\"")]
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
}
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult<CurrentRegistration?>(null);
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true);
@@ -217,4 +217,13 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
DateTimeOffset asOf, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Registration>>(
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
}
+5 -2
View File
@@ -13,8 +13,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
// Keycloak's default login form (stable ids across themes).
await page.locator('#username').fill('jan-burger');
// Keycloak's default login form (stable ids across themes). Its own DigiD user: the verify-* API
// checks submit as jan-burger (bsn 123456782) before the e2e runs on the shared stack, and
// resume-on-load (S-26) would otherwise restore one of those on login — so each self-service spec
// uses a dedicated citizen no other actor touches.
await page.locator('#username').fill('emma-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
// S-26: a zorgprofessional submits, then reloads the self-service portal. On load the portal asks the
// BFF for the caller's current open registration (owner-scoped by the DigiD token's bsn) and restores
// the submitted view — so a refresh no longer strands the in-flight registration and its actions.
test('DigiD submit → reload → self-service restores the existing registration', async ({ page }) => {
await page.goto('/');
// Its own DigiD user (like every self-service spec): on the shared verify stack, resume-on-load
// (S-26) restores any open registration for the bsn, so each spec uses a dedicated citizen that no
// other spec or verify-* check touches. This one in particular leaves an open registration.
await page.locator('#username').fill('sanne-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
await page.getByRole('button', { name: /indienen/i }).click();
const confirmation = page.getByText(/ontvangen/i);
await expect(confirmation).toBeVisible();
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
// Reload: the component's in-memory submitted state is gone, but the DigiD session persists and the
// portal resumes from the BFF instead of dropping back to the blank submit form.
await page.reload();
await expect(page.getByText(/ontvangen/i)).toBeVisible();
// The same reference the citizen saw before the reload is restored...
await expect(page.getByText(new RegExp(reference!))).toBeVisible();
// ...and its actions are reachable again (e.g. "trek aanvraag in").
await expect(page.getByRole('button', { name: /trek aanvraag in/i })).toBeVisible();
});
+3 -1
View File
@@ -8,7 +8,9 @@ test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken',
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
await page.locator('#username').fill('jan-burger');
// Its own DigiD user — isolated from the verify-* checks (jan-burger/123456782) so resume-on-load
// (S-26) can't restore someone else's registration on the shared stack.
await page.locator('#username').fill('lars-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();