CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s
Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.
Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
187 lines
11 KiB
Markdown
187 lines
11 KiB
Markdown
# WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen
|
||
|
||
Status: done (65a — detail read — done; 65b — decision write — done)
|
||
Phase: 11 — Behandelportal
|
||
|
||
## Why
|
||
|
||
The core case-treatment write path — a medewerker opens one aanvraag's detail (including
|
||
its documents) and records a decision (goedkeuren/afwijzen/meer info opvragen), advancing
|
||
the status lifecycle WP-63 published. This is the first genuinely new _write_ capability
|
||
in the system beyond what the citizen SSP already does to itself.
|
||
|
||
## Read first
|
||
|
||
- `mutation-command` skill
|
||
- `form-machine` skill (the decision action is a state-changing form, same idiom as
|
||
everywhere else in this house)
|
||
- [WP-63](WP-63-aanvraag-status-lifecycle.md) (the status field being advanced)
|
||
|
||
## Decisions (pre-made, don't relitigate)
|
||
|
||
- The decision action is modeled as a `*.machine.ts` (Model/Msg/reduce) + a `submit-*`
|
||
command returning `Result`, per this house's one-idiom-for-forms rule — not a
|
||
hand-rolled mutable field.
|
||
- The mutation endpoint is a new BFF-lite write (per `mutation-command` recipe) that
|
||
transitions the status field from WP-63; it validates the transition is legal
|
||
server-side (e.g. can't approve an already-approved case) — the backend remains the
|
||
authority.
|
||
- Runs against `LocalZaakSource` for this slice; wiring the decision into real OpenZaak is
|
||
explicitly WP-66, not bundled here — keeps this slice's surface to app-level behavior
|
||
only.
|
||
|
||
## Files
|
||
|
||
New mutation endpoint + command in `BigRegister.Api`; `behandeling/ui` detail page +
|
||
`behandeling/application` decision machine in the behandelportal app.
|
||
|
||
## Steps
|
||
|
||
1. Backend: mutation endpoint advancing aanvraag status (goedkeuren/afwijzen/meer-info-
|
||
opvragen), validating the transition.
|
||
2. FE: zaak-detail page (documents + current status) + a decision form machine + submit
|
||
command.
|
||
3. Wire the werkvoorraad list (WP-64) to link into this detail page.
|
||
4. Storybook stories + a11y for the new detail/decision UI.
|
||
|
||
## Progress notes (65a — done)
|
||
|
||
This WP's own Risks note offered an explicit escape hatch ("split detail-view (read) from
|
||
decision-recording (write) into two sessions if it feels too big"). Taken: 65a below is done
|
||
and committed; 65b (the mutation + decision machine + form) is the remaining half — see its
|
||
own separate session notes once started. Don't relitigate the split; do relitigate nothing
|
||
else pre-made here.
|
||
|
||
**Blocking gap found and fixed, not in this WP's original scope:** the behandelportal's
|
||
login was still WP-61's copy-pasted citizen/BSN DigiD flow — nothing sent `X-Medewerker`, so
|
||
`StubIdentityProvider` always resolved a zorgverlener, `GET /me` never contained
|
||
`aanvraag:beoordelen`, and WP-64's `WerkvoorraadPage` always rendered its denial alert in a
|
||
real browser. WP-64's own Verification line ("manual: log in as a stub medewerker, see the
|
||
queue populated") could not have passed as written — CI stayed green because none of its
|
||
tests exercise the browser gate. Fixed with a dev-only `medewerkerInterceptor`
|
||
(`apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` +
|
||
`medewerker.ts`), mirroring `@shared/infrastructure/role.ts`'s `?role=` idiom but app-local
|
||
(`?rollen=`, sticky per tab, default `behandelaar`) — real employee-SSO login stays out of
|
||
scope (ADR-0002 §3: the two apps' login flows are expected to diverge). Documented in
|
||
`docs/reference/roles-and-access.md`.
|
||
|
||
**Backend (`GET /beoordeling/{id}`):** reads through `IZaakSource.ListCases` (no new seam
|
||
method — one would force an `OpenZaakZaakSource` get-by-id + mapper, which is WP-66's
|
||
surface), filters to the requested id, 404s a Concept or unknown id. Documents come from
|
||
`DocumentStore.ByIds` (new method, mirrors `ByLocalIds`) — `DocumentStore` directly, not
|
||
`IDocumentSource`, since that seam has no read method and its own header comment already
|
||
says `DocumentStore` stays the record of truth for preview/download/audit regardless of
|
||
config. The owner BSN is masked (`MaskTail`, same helper `/brief`'s BIG-nummer masking
|
||
uses) — WP-64's queue row leaks the full BSN via the same `ApplicationSummaryDto.Owner`
|
||
field; flagging as a follow-up, not fixed here or ssp's `/admin/cases` page moves too.
|
||
The gate that was `Werkvoorraad(ctx, action)` is now `Beoordelen(ctx, resource, action)` —
|
||
one gate for every behandelaar endpoint, `resource` feeding the audit row; the one existing
|
||
`/werkvoorraad` call site was updated to pass `"werkvoorraad"`.
|
||
|
||
**Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to
|
||
65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current
|
||
_computed_ status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable;
|
||
`Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet,
|
||
so this pure rule needed nothing from 65b's eventual migration to be correct today. 65b adds
|
||
the mutation, the `Besluit` enum, and the transition-legality check that reuses this same
|
||
function.
|
||
|
||
**FE:** `BeoordelingStatus` (`domain/beoordeling.ts`) is the five-tag union (all of ssp's
|
||
`AanvraagStatus` minus `Concept`, which 404s) — wider than WP-64's `WerkvoorraadStatus` (two
|
||
tags). `TYPE_LABELS`/`statusLabel` moved from `werkvoorraad-item-view.ts` into a new
|
||
`domain/beoordeling-view.ts` (the file owning the wider union) and are re-exported from the
|
||
old location so no consumer or existing spec needed to change. Detail page composes
|
||
`<app-data-block>`/`<app-data-row>` (mirrors ssp's `aanvraag-detail.page.ts`) plus one new
|
||
organism, `beoordeling-documenten` (plain links to the existing, pre-existing-unauthenticated
|
||
`/uploads/{id}/content` endpoint — not `ApplicationLinkComponent`, whose `to` is a
|
||
`routerLink`, not an external href). The werkvoorraad list's rows now link to
|
||
`/aanvraag/:id`, gated by the same `aanvraag:beoordelen` capability. No `MaskedValueComponent`
|
||
reveal affordance — this screen never reveals the owner, so pulling in that atom's unused
|
||
reveal machinery would be speculative.
|
||
|
||
**Deferred to 65b, deliberately:** the mutation endpoint, the `Aanvraag.BesluitStatus`
|
||
column + EF migration, `Mappers.ToStatusDto`'s "a recorded decision wins" branch, the
|
||
`besluit.machine.ts` + `besluit-form` UI, and re-running the werkvoorraad/ssp-dashboard
|
||
end-to-end smoke this WP's acceptance criteria actually asks for (a decision advancing
|
||
status, illegal transitions rejected). None of WP-65's acceptance criteria are checked off
|
||
yet — 65a is infrastructure the decision screen needs, not a slice of the AC itself.
|
||
|
||
`npm run ci` green (lint, dep:check ×2, both apps' localized builds, both Storybook builds,
|
||
test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift
|
||
step shows the expected pre-commit diff (this WP's own uncommitted endpoint).
|
||
|
||
## Progress notes (65b — done)
|
||
|
||
**Backend (`POST /beoordeling/{id}/besluit`):** runs against `ApplicationStore` directly
|
||
(not the `IZaakSource` seam) — same reasoning as 65a's GET: a new seam method would force
|
||
an `OpenZaakZaakSource` write now, which stays WP-66's surface. A new `Besluit` enum
|
||
(`Goedkeuren | Afwijzen | MeerInfoOpvragen`) backs a nullable `Aanvraag.BesluitStatus` +
|
||
`Aanvraag.BesluitToelichting` column pair (EF migration `BesluitStatus`). Like every other
|
||
enum in this backend, `Besluit` never crosses the wire as a raw C# enum — no
|
||
`JsonStringEnumConverter` is configured, so `RecordBesluitRequest.Besluit` is a plain
|
||
`string`, parsed with `Enum.TryParse` (400 on an unknown name) — the same wire convention
|
||
`AanvraagStatusDto.Tag` already established. The endpoint reuses
|
||
`BeoordelingRules.CanDecide` — the SAME function the read side's `canBesluiten` flag calls
|
||
— as the transition-legality check, so the two can never drift (409 on an illegal
|
||
transition, e.g. deciding an already-`Goedgekeurd` case again). Toelichting is required
|
||
(400) for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced server-side because
|
||
the published `AanvraagStatusDto`'s `Reden` field is non-optional on those two tags (the
|
||
FE's existing `parseBeoordelingStatus` already required it). `Mappers.ToStatusDto` gained
|
||
"a recorded decision wins" between the submit-time `Reden` check and the auto-approve
|
||
computation — the two never collide in practice (a submit-time-rejected case is already
|
||
terminal and never reaches the werkvoorraad/beoordeling screens, so no behandelaar ever
|
||
records a besluit on one). `MeerInfoGevraagd` is not terminal: `CanDecide` still allows a
|
||
further besluit afterwards, so a behandelaar can ask for info, then later approve/reject
|
||
once it arrives — the same `BesluitStatus` column is simply overwritten.
|
||
|
||
**FE:** `besluit.machine.ts` is the same single-step Editing/Submitting/Submitted/Failed
|
||
union as `change-request.machine.ts` (form-machine skill) — `Draft.besluit` stays a raw
|
||
string (parsed into the narrow `BesluitTag` union only in `validate`, "parse, don't
|
||
validate"), so the generic `SetField` reducer case needs no per-field typing gymnastics.
|
||
`besluit-form` (organism) composes `RadioGroupComponent` (the three actions) +
|
||
`FormFieldComponent`/`TextInputComponent` (toelichting, plain single-line — no textarea
|
||
atom exists and this form doesn't justify adding one) — no new shared atom. On a
|
||
successful decision it emits `decided`, and `BeoordelingPage` just calls
|
||
`BeoordelingStore.reload()` — the server is the authority on the new status, the page
|
||
never guesses it. The form only renders when the server's `canBesluiten` flag is true
|
||
(ADR-0001: render the decision, don't recompute the lifecycle).
|
||
|
||
Re-ran the full acceptance-criteria smoke by hand against `LocalZaakSource`: created a
|
||
manual registratie case, opened it via werkvoorraad → beoordeling, recorded Afwijzen with a
|
||
toelichting (status → Afgewezen, reason shown), confirmed a further besluit on that same
|
||
case now 409s. `npm run ci` green (lint, dep:check ×2, format:check, check:tokens, all four
|
||
projects' test suites, both apps' localized `nl`+`en` builds, backend `dotnet format
|
||
--verify-no-changes` + `dotnet test` — 201/201 including this WP's 5 new tests, api-client
|
||
regenerated). One pre-existing, unrelated finding: `format:check` was already red on this
|
||
branch before this session touched anything — `docs/project/backlog/README.md` has a
|
||
long-standing prettier drift (a big markdown table) untouched by this WP; fixed the same
|
||
class of drift in this file's own body (`*computed*` → `_computed_`) since this WP was
|
||
already editing it, left `README.md` alone as out of scope.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [x] A medewerker can view one aanvraag's detail and record a decision that advances its
|
||
status.
|
||
- [x] Illegal transitions are rejected server-side (tested).
|
||
- [x] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back
|
||
in the queue.
|
||
- [x] `npm run ci` (behandelportal app) + `dotnet test` green.
|
||
|
||
## Verification
|
||
|
||
Manual/automated smoke test of the full werkvoorraad → beoordeling → besluit flow against
|
||
`LocalZaakSource`; `npm run ci`; `cd backend && dotnet test`.
|
||
|
||
## Out of scope
|
||
|
||
Writing the decision to real OpenZaak (WP-66).
|
||
|
||
## Risks
|
||
|
||
This is the largest FE slice in Phase 11 — if it feels too big at kickoff, split
|
||
detail-view (read) from decision-recording (write) into two sessions; the WP as scoped
|
||
already keeps them in one slice because a detail view with no decision action isn't
|
||
independently useful for a caseworker.
|
||
|
||
Depends on: WP-64.
|