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>
This commit is contained in:
eho
2026-09-08 23:00:38 +02:00
co-authored by Claude Opus 5
parent 097e8468e0
commit 12f17d9d73
161 changed files with 154 additions and 24 deletions
@@ -0,0 +1,186 @@
# 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.