Files
atomic-design-poc/docs/project/archive/backlog/WP-65-behandelportal-beoordeling.md
T
ehoandClaude Opus 5 12f17d9d73 docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every
WP done) and `docs/project/refactor-backlog-setup/` (the arc before it).
Move both under `docs/project/archive/` with `git mv`, so history stays
intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them,
because it points at the now-archived backlog README.

Add `docs/project/archive/README.md`. It states that these trees are
historical and names the two directories that are still live.

Repoint every inbound reference named in RD-30's Files table: CLAUDE.md,
the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the
`document-feature` and `new-ssp` skills, and the readable-codebase PLAN,
README, and RD-19 ticket. Fix two upward-relative links inside the moved
WP files (WP-68, WP-69) that gained a directory level and would otherwise
break. Repoint `.prettierignore`'s two agent-prompt exclusions to their
new path, so prettier keeps leaving those files' exact wording alone.

Mark RD-30 done and check off its acceptance criteria; flip its README
row to done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:38 +02:00

187 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.