Compare commits

..
18 Commits
Author SHA1 Message Date
ehoandClaude Haiku 4.5 ddec15ccb2 docs(architecture): add diagrams and implementation playbook
Added three new documents with nine Mermaid diagrams to make the strangler
fig strategy visible:

- README: container topology diagram at the start, with the proxy entry point
  and three seams labelled
- docs/architecture.md: five diagrams tracing the exact implementation:
  - The four seams and who holds authority at each boundary
  - How by-id read goes through the resolver, but list-read bypasses it
  - Case lifecycle state machine (the strategy in one picture)
  - Take-ownership sequence with failure windows annotated
  - Write-through error round-trip showing zero validation logic crossed
- docs/playbook.md: how to apply this to a production system:
  - Write-path decision tree (five read/write patterns)
  - Cutover ordering diagram (side-effects-free first, least recoverable last)
  - Seven transferable rules with pointers to the files that demonstrate them
  - Scope diagram of what's proven vs. left as your decisions

Resolved all 13 dangling § citations (to an absent spec doc) by linking to
the actual files or dropping them. Replaced portal-frontend/README.md
boilerplate with accurate content. All diagrams parse and link-check clean.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-01 09:15:39 +02:00
ehoandClaude Haiku 4.5 5f22156e6d feat(ownership): add take-ownership preflight endpoint
Enables dry-run checking before committing to case adoption. The preflight
shares the same side-effect-free checks (steps 1–3) as the real take-ownership
handler, so it cannot drift from what will actually succeed. Returns the same
status codes and error shapes as the real endpoint (200 with wouldSucceed:true,
or 409/404/422 if it would fail).

Portal renders a "Vooraf controleren" button for legacy cases, surfaced through
the existing actions block pattern. Confirmed in smoke.sh with two cases: one
where preflight predicts success (and writes nothing), one where it predicts
a named invariant failure (matching what the real call reproduces).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-01 09:15:18 +02:00
eho 19c010653a test: guard against the /portal redirect dropping the client-facing port
Would have caught the previous commit's bug: checks that GET /portal
(no trailing slash) 301s and that following the redirect actually
lands on a 200 on the same host:port, rather than only checking
/portal/ directly.
2026-07-31 09:35:02 +02:00
eho 46926215af fix(proxy): preserve the client-facing port in the /portal redirect
`return 301 /portal/` let nginx build the Location header from its
own internal `listen 80`, dropping the host's published port (e.g.
:8080) entirely - a browser hitting bare "http://host:8080/portal"
got redirected to "http://host/portal/" (port 80) and failed to
connect. Using $http_host keeps the port the client actually used.
2026-07-31 09:33:49 +02:00
eho ec965976b0 docs: document the Angular portal in README
Notes the /portal path, that it's kept alongside new-frontend rather
than replacing it, and updates the container count and click-through
intro accordingly.
2026-07-31 09:19:04 +02:00
eho a3ba1573e1 test: extend smoke.sh with portal-frontend checks
Container count is now 10; adds checks that /portal/ and a direct
deep link both serve the Angular app (200) through the proxy,
proving both the routing wiring and the container's SPA fallback.
Full smoke run verified green on a fresh stack.
2026-07-31 09:18:12 +02:00
eho 15b6397317 build: wire portal-frontend into proxy and docker-compose
Adds portal-frontend as a new service (no published port, only
reachable via the proxy, same as new-frontend) and two nginx location
blocks for /portal - a bare-path redirect plus the prefix-stripping
proxy_pass. The existing /, /legacy, and /api/ blocks are unchanged.

Verified end to end: /portal/, a deep link, and / all return 200
through the single published proxy port, and /api/worklist still
returns the full 17-item worklist.
2026-07-31 09:15:49 +02:00
eho 8a192578fa build(portal-frontend): add Dockerfile and SPA nginx config
Multi-stage: node build with --base-href=/portal/, then nginx:alpine
serving the compiled dist with try_files SPA fallback for client-side
routes (e.g. a direct hit on /legacy/1001). Verified standalone: root
and a deep link both 200, base href correct.
2026-07-31 09:13:08 +02:00
eho c2d0ba9a3d feat(portal-frontend): implement take-ownership and release-ownership actions
Take-ownership keeps the placeholder's confirm() dialog before this
one-way per-case migration step, then navigates straight to the new
owned detail on success (201). Release-ownership navigates back to
the worklist on success (204), no confirm dialog. Both surface 422
InvariantViolationResponse / 409 MessageResponse verbatim in a banner
rather than a curated client-side message.
2026-07-31 09:10:37 +02:00
eho 3b04c9fae3 feat(portal-frontend): implement record-assessment form and closurePending display
Owned-mode form embedded in case-actions where the redirect link
isn't used. verifiedItems is comma-split client-side for convenience
only; the server re-validates everything regardless. closurePending
renders as a neutral success note (ADR-001), never as an error. 422
InvariantViolationResponse surfaces invariant+message verbatim in a
banner, matching the edit-details form's approach.
2026-07-31 09:08:22 +02:00
eho 9e031da724 feat(portal-frontend): implement edit-applicant-details form with write-through disclaimer
Shared form for writeThrough and owned modes. Shows the ADR-002
disclaimer only in writeThrough mode (no client-side validation
stricter than legacy provides). Maps 400 ErrorsResponse to per-field
inline errors, 422/409 to a banner with the server's own text -
never a client-side switch over invariant names. Embedded in
case-detail with reload() on save so the actions block refreshes.
2026-07-31 08:59:55 +02:00
eho cebbf9f57a feat(portal-frontend): implement case-detail read view
Resolves legacy/:id vs owned/:id off the matched route's static path
segment and fetches accordingly. Renders applicant, diploma, and
assessment fields, a seams panel (which backend serves each section),
and embeds app-case-actions. Read-only - write forms land next.
2026-07-31 08:53:17 +02:00
eho 08e08a0070 feat(portal-frontend): implement worklist-list component
Table of WorklistService results with bucket/origin/search filters
(FormsModule ngModel); row click navigates to legacy/:id or owned/:id
based on origin, matching the placeholder's key scheme.
2026-07-31 08:50:56 +02:00
eho 97c0bea238 feat(portal-frontend): implement case-actions component
Renders record-assessment as a link only in redirect mode (otherwise
a form-trigger slot, filled in once record-assessment-form exists),
and take-ownership/release-ownership buttons purely off presence of
their optional action key. Emits requests upward rather than calling
the API itself - the actual take/release-ownership handling lands in
a later commit.
2026-07-31 08:49:29 +02:00
eho 754086226b test(portal-frontend): add failing spec for case-actions mode-driven rendering
Red: case-actions has no template yet. Encodes the legacy-vs-owned
actions contract from CaseDetailResponseFactory - record-assessment
renders as a link only in redirect mode, take/release-ownership
buttons follow presence of their optional action key.
2026-07-31 08:48:41 +02:00
eho 3ed130de25 feat(portal-frontend): add router with worklist and case-detail routes
Empty placeholder components for worklist-list and case-detail,
wired into app.routes.ts (legacy/:id and owned/:id share one page
component since the rendered shape is identical). Replaces the CLI
welcome template with a minimal shell rendering <router-outlet>.
2026-07-31 08:47:13 +02:00
eho b8a8d28e7b feat(portal-frontend): add worklist and case-detail typed interfaces and API services
Types mirror New.Api.Contracts verbatim; all write calls use the href
given in a case's actions block rather than a client-built URL.
2026-07-31 08:45:27 +02:00
eho 229ca9b7d9 chore(portal-frontend): scaffold Angular workspace
Raw `ng new` output for the Session-2 Angular portal, reachable at
/portal alongside the existing new-frontend placeholder.
2026-07-31 08:44:03 +02:00
66 changed files with 10431 additions and 56 deletions
+71 -11
View File
@@ -10,9 +10,12 @@ migration strategy can be watched instead of slide-decked.
docker compose up -d --build
```
Then open **http://localhost:8080**. That's the only host port published —
`legacy-backend` and `case-framework` are deliberately unreachable from the
host (see §4 below).
Then open **http://localhost:8080** (Session 1's placeholder UI) or
**http://localhost:8080/portal** (Session 2's Angular portal) — both are
reachable side by side through the same proxy, for comparison. That's the
only host port published — `legacy-backend` and `case-framework` are
deliberately unreachable from the host (only `proxy` publishes a port — see
`docker-compose.yml`).
**Memory:** SQL Server (`legacy-db`) needs roughly 2GB of RAM; budget ~6GB
total for Docker/Podman. First start takes a minute or two while SQL Server
@@ -28,13 +31,65 @@ Verify everything end to end:
Run this against a **freshly started** stack — it depends on the untouched
seed data (legacy ids 10011012, owned ids `REG-2026-0001..0005`).
## What's built so far (Session 1 — backend)
## What's built so far
Nine containers: two frontends (one placeholder, see below), three backends,
three databases, one proxy. `new-frontend` in this session is a deliberately
plain, unstyled HTML page (`new-frontend/index.html`) that exercises the same
API a real UI would — it exists to prove the backend before a real Angular
portal replaces it in Session 2, not to be a good UI.
```mermaid
flowchart TB
browser(["Browser"])
browser -->|":8080 · the only published port"| proxy["proxy · nginx"]
subgraph new_g["NEW — the replacement"]
direction TB
portal["portal-frontend<br/>Angular"]
placeholder["new-frontend<br/>plain HTML"]
newapi["new-backend<br/>.NET"]
newdb[("new-db<br/>Postgres 16")]
newapi --> newdb
end
subgraph legacy_g["LEGACY — being strangled"]
direction TB
legweb["legacy-frontend<br/>Razor Pages"]
legapi["legacy-backend<br/>.NET"]
legdb[("legacy-db<br/>SQL Server 2022")]
legweb --> legapi --> legdb
end
subgraph cf_g["VENDOR — stays put"]
direction TB
cf["case-framework<br/>.NET"]
cfdb[("case-db<br/>Postgres 16")]
cf --> cfdb
end
proxy -->|"/portal/"| portal
proxy -->|"/"| placeholder
proxy -->|"/api/"| newapi
proxy -->|"/legacy"| legweb
newapi ==>|"seams A + B"| legapi
newapi ==>|"seam D"| cf
```
Ten containers; only the proxy publishes a port. The two thick edges are the
seams — everything the new system knows about the old one crosses one of them.
See [`docs/architecture.md`](docs/architecture.md) for how each seam works, and
[`docs/playbook.md`](docs/playbook.md) for applying the pattern to a real system.
**Session 1 — backend.** Ten containers: three frontends, three backends,
three databases, one proxy. `new-frontend` (reachable at `/`) is a
deliberately plain, unstyled HTML page (`new-frontend/index.html`) that
exercises the same API a real UI would — it exists to prove the backend, not
to be a good UI.
**Session 2 — Angular portal.** `portal-frontend` (reachable at `/portal`) is
a real Angular application over the same API, kept alongside `new-frontend`
rather than replacing it, so the two can be compared side by side. It covers
full functional parity with the placeholder: the worklist, case detail, and
all four actions (edit applicant details, record assessment, take/release
ownership) — driven entirely off the API's own `actions`/`seams` blocks in
each response, never a hardcoded URL. See
[`portal-frontend/`](portal-frontend) for the app itself.
## The seams and write paths
@@ -65,7 +120,8 @@ cd new && dotnet test tests/Architecture.Tests
`legacy-db` is SQL Server 2022; `new-db` and `case-db` are PostgreSQL 16.
This isn't decoration — a single shared engine would let an implementer
quietly join across schemas or share a `DbContext`, and the seam would
evaporate. Two engines force the read ACL to be a real HTTP call (§7.2),
evaporate. Two engines force the read ACL to be a real HTTP call
([seam A](docs/architecture.md#the-four-seams)),
force the take-ownership step ordering in `TakeOwnershipHandler` to be a real
constraint rather than a stylistic choice (no distributed transaction is
available across them), and make the legacy type vocabulary
@@ -100,7 +156,11 @@ work for `LegacyAanvraagMapper` instead of a copy-paste.
## 10-minute click-through
1. **Werkvoorraad**`GET /api/worklist` (or the placeholder page at `/`):
Every step below works identically through the raw API, the Session 1
placeholder at `/`, or the Session 2 Angular portal at `/portal` — they're
three windows onto the same backend.
1. **Werkvoorraad**`GET /api/worklist` (or either frontend's root page):
17 cases from two databases in one list. Filter `?origin=Legacy` /
`?origin=Owned` to see which is which.
2. **`A-1001`** (`GET /api/worklist/legacy/1001`) — all three write paths
+4
View File
@@ -83,6 +83,9 @@ services:
new-frontend:
build: ./new-frontend
portal-frontend:
build: ./portal-frontend
proxy:
image: nginx:alpine
volumes:
@@ -91,5 +94,6 @@ services:
- "8080:80"
depends_on:
- new-frontend
- portal-frontend
- new-backend
- legacy-frontend
@@ -7,7 +7,8 @@ Accepted.
`case-framework` (seam D, a stand-in for a maintained vendor case-management
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
while any task on the case is still open. That rule belongs to the framework
and is not ours to change — it is a conformist integration by design (§6).
and is not ours to change — it is a conformist integration by design
(seam D, `New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs`).
The new domain's own rule is different: once an assessment (approve/reject) is
recorded on a `RegistrationApplication`, that decision is legally in effect
@@ -7,7 +7,7 @@ Accepted.
Seam B lets a user edit a **legacy-owned** case's applicant details (name,
address, contact) from the new portal, without the new system taking
ownership of that case. The legacy system remains the authority on this data
until ownership is explicitly taken (§7.5).
until ownership is explicitly taken ([ADR-003](ADR-003-ownership-is-taken-per-case.md)).
It is tempting, once a translation layer exists between the portal's request
shape and legacy's `PUT /api/aanvragen/{id}/gegevens` shape, to also smuggle
@@ -28,13 +28,14 @@ derived values, no defaulting. It only:
(logged as a warning, never dropped or guessed at).
If a rule needs to be enforced on this data from the new portal, that is a
signal the capability should be taken into ownership instead (§7.5), not
signal the capability should be taken into ownership instead, not
patched into the translator.
## Consequences
- The portal cannot offer a better validation experience than legacy already
has for this seam — by design. The `Gevalideerd door het legacy systeem`
notice on the write-through form (§8.3) exists specifically so the user
notice on the write-through form (`portal-frontend/src/app/case-detail/edit-applicant-details/`)
exists specifically so the user
knows why: this is the honest version of a seamless UI, not a limitation to
hide.
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
@@ -22,7 +22,7 @@ their keep.
## Decision
Ship now with ownership taken **one legacy case at a time**, via
`POST /api/worklist/legacy/{aanvraagId}/take-ownership` (§7.5), triggered by
`POST /api/worklist/legacy/{aanvraagId}/take-ownership`, triggered by
an explicit user action in the portal. This is the interim mechanism, not the
final one for every process.
@@ -43,11 +43,12 @@ This is deliberately the right building block either way:
- Until bulk tooling exists, full legacy retirement for a process happens
case-by-case, which is slower than a scheduled cutover — accepted as the
cost of shipping the seam mechanics now rather than waiting.
- Reversal (§7.6) stays per-case and gated on `domain_writes_since` for the
- Reversal (`ReleaseOwnershipHandler`) stays per-case and gated on `domain_writes_since` for the
same reason a bulk reversal would be unsafe absent a sync
(`docs/sync-not-implemented.md`): undoing adoption after edits would
silently discard them.
- This demo's non-goals (§3) exclude building the bulk migration tool itself
- This demo's non-goals (README, "Deliberate substitutions and omissions")
exclude building the bulk migration tool itself
— that's future work, not a rejected idea.
## Future work
+178
View File
@@ -0,0 +1,178 @@
# How the seams work
Diagrams only. The prose arguments live in the [README](../README.md) and the
[ADRs](adr/); each diagram below names the file it was traced from, so a
reader can check it against the code rather than trust it.
## The four seams
Who holds authority at each boundary. Seam C is the odd one out: it is not an
HTTP redirect, it is an `ActionLink` with `mode: "redirect"` in the JSON
`actions` block — the **browser** navigates when the user clicks it.
```mermaid
flowchart LR
portal["portal-frontend<br/>Angular"]
api["new-backend<br/>New.Api"]
legapi["legacy-backend<br/>Legacy.Api"]
legweb["legacy-frontend<br/>Beoordeling.cshtml"]
cf["case-framework<br/>vendor"]
portal --> api
api -- "A · read ACL<br/>GET /api/aanvragen<br/><b>legacy owns the data</b>" --> legapi
api -- "B · write-through<br/>PUT .../gegevens<br/><b>legacy owns the rules</b>" --> legapi
api -- "D · conformist<br/>POST /cases<br/><b>vendor owns the rules</b>" --> cf
portal -. "C · redirect — browser navigates<br/><b>legacy owns the workflow</b>" .-> legweb
```
*Traced from `New.Infrastructure.Legacy/LegacyCaseSource.cs`,
`LegacyDetailsWriteThroughTranslator.cs`,
`New.Api/Contracts/CaseDetailResponseFactory.cs`,
`New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs`.*
## Reading a case: one id, two sources
`ApplicationSourceResolver` is the only type in the solution that references
both sources (Architecture.Tests rule 7). A legacy id keeps working after
adoption because this resolver — and only this resolver — checks the ownership
registry first.
The **list** endpoint deliberately does *not* go through it: it takes two
separate reader ports and merges in memory, dropping any legacy row whose
`Migrated` flag is set so adopted cases don't appear twice.
```mermaid
flowchart TB
subgraph byid["GET /api/worklist/legacy/{id} — via the resolver"]
r{"legacy_ownership<br/>has a row?"}
r -->|no| ra["LegacyCaseSource<br/>HTTP → legacy-backend"]
r -->|yes| rb["OwnedApplicationSource<br/>in-process → new-db"]
end
subgraph list["GET /api/worklist — bypasses the resolver"]
l1["ILegacyWorklistReader<br/>HTTP → legacy-backend"]
l2["IOwnedWorklistReader<br/>in-process → new-db"]
m["owned ++ legacy.Where(!Migrated)<br/>filter · sort · page in memory"]
l1 --> m
l2 --> m
end
```
*Traced from `New.Api/Resolution/ApplicationSourceResolver.cs:26` and
`New.Api/Endpoints/WorklistEndpoints.cs:18`.*
## The life of a case
This is the strategy in one picture. Every edge is a real endpoint with a real
guard.
```mermaid
stateDiagram-v2
[*] --> Legacy
Legacy --> Owned: POST take-ownership → 201
Owned --> Legacy: DELETE ownership → 204
Owned --> OwnedDirty: owned edit or assessment
Legacy --> Legacy: preflight — read-only
Legacy --> Legacy: write-through edit — legacy validates
Legacy --> Legacy: take-ownership 422 — nothing written
OwnedDirty --> OwnedDirty: further owned writes
note right of Legacy
no legacy_ownership row
legacy.Migrated = false
legacy is the authority
end note
note right of Owned
legacy_ownership row exists
legacy.Migrated = true
domain_writes_since = 0
still reversible
end note
note right of OwnedDirty
domain_writes_since greater than 0
Release refused with 409: no sync
exists to push these edits back
to legacy first.
end note
```
Not drawn as a state, because it is a failure condition rather than a
lifecycle stage: **split-brain** — a row in `legacy_ownership` while legacy's
`Migrated` is still `false`, left behind when step 6 below fails. Detected by
reconciling the two, not prevented.
*Traced from `New.Application/Ownership/TakeOwnershipHandler.cs`,
`ReleaseOwnershipHandler.cs`, and
`New.Infrastructure.Persistence/Entities/LegacyOwnershipRow.cs`.*
## Take ownership — the strangler step
The step order is load-bearing. Steps 13 touch nothing, which is what makes a
failed adoption free; the preflight endpoint is literally this prefix, stopped
early. Steps 46 are ordered so the least recoverable action happens last, and
each remaining failure window is *detectable* rather than pretended away.
```mermaid
sequenceDiagram
participant P as Portal
participant A as new-backend<br/>TakeOwnershipHandler
participant N as new-db
participant L as legacy-backend
participant C as case-framework
Note over P,C: Steps 13 · CheckAsync() · nothing is written<br/>PreflightAsync() runs exactly this much, then stops
P->>A: POST .../take-ownership
A->>N: 1 · LookupOwnedIdAsync
N-->>A: row exists → 409 AlreadyOwned
A->>L: 2 · GET /api/aanvragen/{id}
L-->>A: legacy row (absent → 404)
A->>A: 3 · map to RegistrationApplication
Note over A: domain invariant fails → 422 naming it,<br/>and nothing has been written anywhere
Note over P,C: Steps 46 · writes begin
A->>C: 4 · POST /cases
C-->>A: caseId
Note over C: failure after this point leaves an orphaned<br/>framework case — no compensating delete exists,<br/>so find it by externalReference
A->>N: 5 · aggregate + legacy_ownership<br/>in ONE transaction
A->>L: 6 · PUT .../migratie-vlag true
Note over L: failure here is swallowed and logged → split-brain:<br/>owned locally, still writable in legacy.<br/>Reconcile legacy_ownership vs legacy.migrated
A-->>P: 201 { registrationApplicationId }
```
*Traced from `New.Application/Ownership/TakeOwnershipHandler.cs` — the numbered
comments there are the source of truth for this diagram.*
## Write-through: whose rules run
The effort argument in one exchange. Three bad fields go in; three field
errors come back, produced entirely by legacy's own validator. No validation
logic crossed the seam.
```mermaid
sequenceDiagram
participant P as Portal
participant A as new-backend
participant T as LegacyDetailsWrite<br/>ThroughTranslator
participant L as legacy-backend<br/>GegevensValidator
P->>A: PUT .../legacy/1001/details<br/>blank surname · no house number · bad postcode
A->>T: ToLegacyRequest — reshape only
T->>L: PUT /api/aanvragen/1001/gegevens
L->>L: every rule runs HERE
L-->>T: 400 · NAAM_VERPLICHT<br/>HUISNR_VERPLICHT · POSTCODE_ONGELDIG
T->>T: ToPortalErrors — veld → field path
T-->>A: 3 field errors, messages verbatim
A-->>P: 400 · surname<br/>address.number · address.postalCode
Note over T: An unrecognized veld is logged and passed<br/>through, never dropped or guessed at
```
The translator carries no business rules at all — see
[ADR-002](adr/ADR-002-write-through-has-no-business-rules.md), which is also
honest that this is enforced by code review, not by a test.
*Traced from `New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs`
and `legacy/src/Legacy.Api/Endpoints/GegevensValidator.cs`.*
+95
View File
@@ -0,0 +1,95 @@
# Applying this to a production system
The demo shows *that* the seams hold. This page is the transferable part: how
to pick a path for each capability, and the order that keeps a cutover cheap
to get wrong.
## Which write path for which capability
Ask this per capability, not per system. Most systems end up running all five
answers at once — that is the point of the pattern, not a sign of a messy
migration.
```mermaid
flowchart TD
S(["Pick one capability<br/>in the legacy system"]) --> Q1
Q1{"Does the new UI<br/>only need to read it?"}
Q1 -->|yes| A["<b>Read ACL</b><br/>translate at the boundary<br/>legacy stays authoritative"]
Q1 -->|no| Q2{"Is it owned by a system<br/>you don't control?"}
Q2 -->|yes| D["<b>Conformist</b><br/>surface its rules as-is<br/>don't fight or hide them"]
Q2 -->|no| Q3{"Have you rebuilt the<br/>domain rules yet?"}
Q3 -->|yes| E["<b>Take ownership</b><br/>new system becomes<br/>the authority"]
Q3 -->|"no — and it's<br/>a whole workflow"| C["<b>Redirect</b><br/>send the user back out<br/>to the legacy screen"]
Q3 -->|"no — but it's<br/>a simple edit"| B["<b>Write-through</b><br/>legacy still validates<br/>translator gets zero rules"]
```
Read ACL and write-through are the cheap ones and where most capabilities
should sit for most of the migration. Take-ownership is the only path that
moves authority, so it is the only one that needs a rollback story.
## Ordering a cutover so failures stay cheap
The generalisable rule from `TakeOwnershipHandler`: **free checks first,
external systems before your local transaction, least-recoverable action
last** — and for whatever remains unrecoverable, write down how you would
*detect* it instead of pretending it rolls back.
```mermaid
flowchart LR
subgraph free["Costs nothing to fail"]
direction TB
S1["1 · cheap guard<br/>already migrated?"] --> S2["2 · read the source"] --> S3["3 · map it<br/>invariants run here"]
end
subgraph writes["Each failure leaves a trace"]
direction TB
S4["4 · external system"] --> S5["5 · your local tx<br/>atomic"] --> S6["6 · flip the old flag"]
end
free ==>|"a failure up to here<br/>means nothing happened"| writes
S4 -.->|"fails after?"| F1["orphaned external record<br/><i>find by external reference</i>"]
S6 -.->|"fails?"| F2["split-brain<br/><i>reconcile the two flags</i>"]
```
Steps 13 doubling as a dry-run endpoint is what makes the rehearsal
trustworthy: it *is* the real cutover's own check code, so it cannot drift
away from what the real call will do.
## Seven rules worth stealing
| # | Rule | Why | Demonstrated by |
|---|---|---|---|
| 1 | Migrate the smallest unit that already exists in the domain | Here it's one case. Per-unit cutover means a failure is one bad row, not a bad weekend | [ADR-003](adr/ADR-003-ownership-is-taken-per-case.md) |
| 2 | The translator carries **no** business rules | The urge to "just check the postcode here too" is the signal to migrate that capability instead | [ADR-002](adr/ADR-002-write-through-has-no-business-rules.md) |
| 3 | Give yourself a dry run built from the real thing | A rehearsal that shares code with the performance cannot go stale | `GET .../take-ownership/preflight` |
| 4 | Let the old system keep saying no | Every legacy error surfaces verbatim; none is invented or hidden | `LegacyDetailsWriteThroughTranslator` |
| 5 | Make the boundary fail the build | Exactly one type may know both sources exist; a reviewer will eventually miss that, a test won't | `tests/Architecture.Tests` (11 rules) |
| 6 | Write down what you deliberately did **not** build | An omission you named is a decision; an omission you didn't is a bug waiting | [sync-not-implemented.md](sync-not-implemented.md) |
| 7 | Reversibility expires — say so out loud | Release works until the new side holds authoritative writes; after that the honest answer is a `409`, not a silent discard | `ReleaseOwnershipHandler` |
## What this demo deliberately leaves for you
None of these are hard to add; all of them are decisions a real migration has
to make explicitly, so the demo declines to make them for you.
```mermaid
flowchart TB
subgraph shown["Proven here"]
direction LR
P1["4 seams"] ~~~ P2["3 write paths"] ~~~ P3["per-case cutover<br/>+ dry run"] ~~~ P4["reversal, while<br/>still reversible"]
end
subgraph yours["Yours to decide"]
direction LR
Y1["bulk / scheduled<br/>cutover"] ~~~ Y2["new → old sync"] ~~~ Y3["authn / authz"] ~~~ Y4["metrics, alerting,<br/>reconciliation jobs"]
end
shown --> yours
```
Two of these are already argued in writing rather than left blank:
[ADR-003](adr/ADR-003-ownership-is-taken-per-case.md) on why bulk migration is
a separate later capability, and
[sync-not-implemented.md](sync-not-implemented.md) on the two visible
consequences of having no sync.
+2 -1
View File
@@ -21,6 +21,7 @@ consequences, both intentional:
honest substitute for a sync that does not exist.
2. **Ownership release is blocked once edits exist.** `DELETE
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since
> 0` (§7.6) — releasing would silently discard those edits, since there is
> 0` (`ReleaseOwnershipHandler`) — releasing would silently discard those
edits, since there is
no sync to have propagated them back to legacy first. The `409` is the cost
of the missing sync made visible, rather than a data-loss bug made invisible.
@@ -45,7 +45,8 @@ internal static class CaseDetailResponseFactory
private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new(
EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"),
RecordAssessment: new ActionLink("redirect", $"/legacy/aanvraag/{aanvraagId}/beoordeling"),
TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership"));
TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership"),
TakeOwnershipPreflight: new ActionLink("query", $"/api/worklist/legacy/{aanvraagId}/take-ownership/preflight"));
private static CaseDetailActions BuildOwnedActions(Guid registrationApplicationId) => new(
EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"),
@@ -39,6 +39,7 @@ public sealed record CaseDetailActions(
ActionLink EditApplicantDetails,
ActionLink RecordAssessment,
ActionLink? TakeOwnership = null,
ActionLink? TakeOwnershipPreflight = null,
ActionLink? ReleaseOwnership = null);
public sealed record AddressResponse(string Street, string Number, string PostalCode, string City)
@@ -8,6 +8,7 @@ public static class OwnershipEndpoints
public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/worklist/legacy/{aanvraagId:int}/take-ownership", TakeOwnershipAsync);
app.MapGet("/api/worklist/legacy/{aanvraagId:int}/take-ownership/preflight", PreflightTakeOwnershipAsync);
app.MapDelete("/api/worklist/owned/{registrationApplicationId:guid}/ownership", ReleaseOwnershipAsync);
}
@@ -27,6 +28,23 @@ public static class OwnershipEndpoints
};
}
// Read-only "would this succeed" check - same result-kind switch as
// TakeOwnershipAsync, except Success reports intent rather than creation
// (200, not 201; nothing was written).
private static async Task<IResult> PreflightTakeOwnershipAsync(int aanvraagId, TakeOwnershipHandler handler, CancellationToken ct)
{
var result = await handler.PreflightAsync(aanvraagId, ct);
return result.Kind switch
{
TakeOwnershipResultKind.Success => Results.Ok(new { wouldSucceed = true }),
TakeOwnershipResultKind.AlreadyOwned => Results.Conflict(new MessageResponse("This aanvraag has already been taken into ownership.")),
TakeOwnershipResultKind.LegacyCaseNotFound => Results.NotFound(),
TakeOwnershipResultKind.MappingFailed => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
_ => Results.Problem(statusCode: 500),
};
}
private static async Task<IResult> ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct)
{
var result = await handler.HandleAsync(registrationApplicationId, ct);
@@ -11,7 +11,8 @@ namespace New.Api.Seeding;
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
/// README click-through can reference them directly). REG-2026-0002 is
/// seeded with an open case-framework task on purpose, so a later closure
/// request against it demonstrates the §6 conflict (409, decision stands).
/// request against it demonstrates the seam-D conflict (409, decision stands
/// - see docs/adr/ADR-001-decision-independent-of-closure.md).
/// </summary>
internal static class OwnedApplicationSeeder
{
@@ -28,41 +28,13 @@ public sealed class TakeOwnershipHandler(
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
{
// Step 1: guard against double adoption. Checked first and cheaply,
// before touching legacy or case-framework at all.
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
if (existingOwnedId is not null)
var checkResult = await CheckAsync(aanvraagId, ct);
if (checkResult.Result.Kind != TakeOwnershipResultKind.Success || checkResult.Application is null)
{
return TakeOwnershipResult.AlreadyOwned;
return checkResult.Result;
}
// Step 2: read the legacy case (seam A).
// Step 3: map it to a RegistrationApplication. The mapper calls the
// domain's normal validating constructors/factories, so any domain
// exception here means the legacy data doesn't satisfy an invariant
// the owned side requires. That must fail as a 422 naming the
// failing invariant, and - critically - NOTHING is written anywhere:
// no case-framework call, no persistence. FetchAndMapAsync lets the
// domain exception surface as a thrown DomainInvariantViolationException,
// which we catch here and translate, rather than swallowing it inside
// the gateway - that keeps "nothing written on failure" trivially true,
// since we simply haven't called anything else yet.
LegacyFetchAndMapResult fetchResult;
try
{
fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct);
}
catch (DomainInvariantViolationException ex)
{
return TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message);
}
if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null)
{
return TakeOwnershipResult.LegacyCaseNotFound;
}
var application = fetchResult.Application;
var application = checkResult.Application;
// Step 4: THEN create the case-framework case - done before the local
// transaction because it's an external system with no distributed
@@ -111,4 +83,66 @@ public sealed class TakeOwnershipHandler(
return TakeOwnershipResult.Success(application.RegistrationApplicationId);
}
/// <summary>
/// Read-only "would this succeed" check: runs the same dedupe-lookup and
/// fetch-and-map steps HandleAsync would, then stops - it never reaches
/// case-framework, persistence, or the legacy flag flip. A `Success`
/// result here has a null RegistrationApplicationId, since nothing was
/// actually created. Used by the take-ownership/preflight endpoint so a
/// caller can compare new-vs-legacy behaviour before committing to the
/// real cutover.
/// </summary>
public async Task<TakeOwnershipResult> PreflightAsync(int aanvraagId, CancellationToken ct)
{
var checkResult = await CheckAsync(aanvraagId, ct);
return checkResult.Result;
}
// Steps 1-3 of HandleAsync, factored out so PreflightAsync can run the
// exact same side-effect-free checks without duplicating them.
private async Task<CheckOutcome> CheckAsync(int aanvraagId, CancellationToken ct)
{
// Step 1: guard against double adoption. Checked first and cheaply,
// before touching legacy or case-framework at all.
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
if (existingOwnedId is not null)
{
return new CheckOutcome(TakeOwnershipResult.AlreadyOwned, Application: null);
}
// Step 2: read the legacy case (seam A).
// Step 3: map it to a RegistrationApplication. The mapper calls the
// domain's normal validating constructors/factories, so any domain
// exception here means the legacy data doesn't satisfy an invariant
// the owned side requires. That must fail as a 422 naming the
// failing invariant, and - critically - NOTHING is written anywhere:
// no case-framework call, no persistence. FetchAndMapAsync lets the
// domain exception surface as a thrown DomainInvariantViolationException,
// which we catch here and translate, rather than swallowing it inside
// the gateway - that keeps "nothing written on failure" trivially true,
// since we simply haven't called anything else yet.
LegacyFetchAndMapResult fetchResult;
try
{
fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct);
}
catch (DomainInvariantViolationException ex)
{
return new CheckOutcome(TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message), Application: null);
}
if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null)
{
return new CheckOutcome(TakeOwnershipResult.LegacyCaseNotFound, Application: null);
}
// RegistrationApplicationId stays null here - nothing has been created
// yet. HandleAsync reads the id off the Application itself once it
// proceeds past this point and actually persists it.
var successResult = new TakeOwnershipResult(TakeOwnershipResultKind.Success, RegistrationApplicationId: null);
return new CheckOutcome(successResult, fetchResult.Application);
}
private sealed record CheckOutcome(TakeOwnershipResult Result, RegistrationApplication? Application);
}
@@ -9,7 +9,7 @@ using Xunit;
namespace Architecture.Tests;
/// <summary>
/// Encodes §10's architecture rules as build-failing assertions. A demo that
/// Encodes the design's architecture rules as build-failing assertions. A demo that
/// passes the smoke script but fails these has demonstrated nothing - the
/// seam boundaries are the point, not an implementation detail.
/// </summary>
@@ -70,7 +70,7 @@ public class ArchitectureTests
[Fact]
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
{
// Config-file concern, not code - see §10. Verified by inspection: the
// Config-file concern, not code. Verified by inspection: the
// only connection string anywhere under New.* is ConnectionStrings:New
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
// docker-compose.yml only ever injects ConnectionStrings__New into
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx ng build --configuration production --base-href=/portal/
FROM nginx:alpine AS runtime
COPY --from=build /src/dist/portal-frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+20
View File
@@ -0,0 +1,20 @@
# portal-frontend
Session 2's Angular portal — the real UI over the new backend, reachable at
**http://localhost:8080/portal** once the stack is up (`docker compose up -d`
from the repo root). It is served by its own nginx container behind the shared
proxy, *not* by `ng serve`.
The whole app is driven off the API's `actions` and `seams` blocks: it renders
whatever write path each case advertises (`writeThrough`, `redirect`, `owned`,
`transition`, `query`) and never builds an endpoint URL from an id. That is
what makes the same screens work unchanged for a legacy-owned case and an
adopted one — see [`docs/architecture.md`](../docs/architecture.md).
```
npm test # unit tests (vitest, via ng test)
npm run build # production build
```
This app is **zoneless**: state written from a `subscribe` callback must land
in a signal, or the DOM will not update.
+73
View File
@@ -0,0 +1,73 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"portal-frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "portal-frontend:build:production"
},
"development": {
"buildTarget": "portal-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
}
+7903
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "portal-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.12.1",
"dependencies": {
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.1.2",
"@angular/cli": "^22.1.2",
"@angular/compiler-cli": "^22.1.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+13
View File
@@ -0,0 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient()
]
};
View File
+6
View File
@@ -0,0 +1,6 @@
<header class="app-header">
<h1><a routerLink="/">Behandel portaal</a></h1>
</header>
<main>
<router-outlet />
</main>
+11
View File
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { CaseDetailPage } from './case-detail/case-detail/case-detail';
import { WorklistList } from './worklist/worklist-list/worklist-list';
export const routes: Routes = [
{ path: '', component: WorklistList },
{ path: 'legacy/:id', component: CaseDetailPage },
{ path: 'owned/:id', component: CaseDetailPage },
{ path: '**', redirectTo: '' },
];
+26
View File
@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render the portal title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Behandel portaal');
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {}
@@ -0,0 +1,11 @@
.case-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--rhs-space-3);
}
.preflight-result {
font-size: 0.9em;
color: var(--rhs-color-text-muted, inherit);
}
@@ -0,0 +1,49 @@
<div class="case-actions">
@if (actions().recordAssessment.mode === 'redirect') {
<a class="btn btn--secondary" data-testid="record-assessment-link" [href]="actions().recordAssessment.href">
Beoordeling vastleggen (in legacy) <span aria-hidden="true"></span>
</a>
} @else {
<app-record-assessment-form
data-testid="record-assessment-form"
[actionLink]="actions().recordAssessment"
(saved)="savedRequested.emit()"
/>
}
@if (actions().takeOwnership; as takeOwnership) {
@if (actions().takeOwnershipPreflight; as preflight) {
<button
type="button"
class="btn btn--secondary"
data-testid="take-ownership-preflight-button"
[disabled]="preflightChecking()"
(click)="checkTakeOwnership(preflight)"
>
Vooraf controleren
</button>
@if (preflightResult(); as result) {
<p class="preflight-result" data-testid="take-ownership-preflight-result">{{ result }}</p>
}
}
<button
type="button"
class="btn btn--primary"
data-testid="take-ownership-button"
(click)="takeOwnershipRequested.emit(takeOwnership)"
>
In eigen beheer nemen
</button>
}
@if (actions().releaseOwnership; as releaseOwnership) {
<button
type="button"
class="btn btn--danger-outline"
data-testid="release-ownership-button"
(click)="releaseOwnershipRequested.emit(releaseOwnership)"
>
Eigenaarschap teruggeven
</button>
}
</div>
@@ -0,0 +1,99 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service';
import { CaseDetailActions } from '../case-detail.types';
import { CaseActions } from './case-actions';
describe('CaseActions', () => {
let fixture: ComponentFixture<CaseActions>;
let preflightTakeOwnership: ReturnType<typeof vi.fn>;
function render(actions: CaseDetailActions): HTMLElement {
fixture = TestBed.createComponent(CaseActions);
fixture.componentRef.setInput('actions', actions);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
beforeEach(async () => {
preflightTakeOwnership = vi.fn();
await TestBed.configureTestingModule({
imports: [CaseActions],
providers: [
{
provide: CaseDetailService,
useValue: { recordAssessment: () => { throw new Error('not stubbed'); }, preflightTakeOwnership },
},
],
}).compileComponents();
});
const legacyActions: CaseDetailActions = {
editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' },
recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' },
takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' },
takeOwnershipPreflight: { mode: 'query', href: '/api/worklist/legacy/1001/take-ownership/preflight' },
};
const ownedActions: CaseDetailActions = {
editApplicantDetails: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/details' },
recordAssessment: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' },
releaseOwnership: { mode: 'transition', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership' },
};
it('given a legacy-origin case, renders record-assessment as a link, shows take-ownership, hides release-ownership', () => {
const el = render(legacyActions);
const link = el.querySelector<HTMLAnchorElement>('[data-testid="record-assessment-link"]');
expect(link).toBeTruthy();
expect(link!.getAttribute('href')).toBe('/legacy/aanvraag/1001/beoordeling');
expect(el.querySelector('[data-testid="record-assessment-form"]')).toBeFalsy();
expect(el.querySelector('[data-testid="take-ownership-button"]')).toBeTruthy();
expect(el.querySelector('[data-testid="release-ownership-button"]')).toBeFalsy();
});
it('given an owned-origin case, renders record-assessment as a form trigger, shows release-ownership, hides take-ownership', () => {
const el = render(ownedActions);
expect(el.querySelector('[data-testid="record-assessment-form"]')).toBeTruthy();
expect(el.querySelector('[data-testid="record-assessment-link"]')).toBeFalsy();
expect(el.querySelector('[data-testid="release-ownership-button"]')).toBeTruthy();
expect(el.querySelector('[data-testid="take-ownership-button"]')).toBeFalsy();
});
it('given no takeOwnershipPreflight link (owned case), hides the preflight check button', () => {
const el = render(ownedActions);
expect(el.querySelector('[data-testid="take-ownership-preflight-button"]')).toBeFalsy();
});
it('checking take-ownership beforehand calls preflightTakeOwnership with the link href and shows a would-succeed result', () => {
preflightTakeOwnership.mockReturnValue(of({ wouldSucceed: true }));
const el = render(legacyActions);
el.querySelector<HTMLButtonElement>('[data-testid="take-ownership-preflight-button"]')!.click();
fixture.detectChanges();
expect(preflightTakeOwnership).toHaveBeenCalledWith('/api/worklist/legacy/1001/take-ownership/preflight');
expect(el.querySelector('[data-testid="take-ownership-preflight-result"]')?.textContent).toContain('Zou slagen');
expect(el.querySelector<HTMLButtonElement>('[data-testid="take-ownership-button"]')!.disabled).toBe(false);
});
it('checking take-ownership beforehand shows the named invariant on a 422, without disabling the real action', () => {
preflightTakeOwnership.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Bsn.ElevenProof', message: 'BSN failed the eleven-proof check.' } })),
);
const el = render(legacyActions);
el.querySelector<HTMLButtonElement>('[data-testid="take-ownership-preflight-button"]')!.click();
fixture.detectChanges();
const result = el.querySelector('[data-testid="take-ownership-preflight-result"]')?.textContent;
expect(result).toContain('Bsn.ElevenProof');
expect(result).toContain('BSN failed the eleven-proof check.');
expect(el.querySelector<HTMLButtonElement>('[data-testid="take-ownership-button"]')!.disabled).toBe(false);
});
});
@@ -0,0 +1,55 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, input, output, signal } from '@angular/core';
import { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, CaseDetailActions } from '../case-detail.types';
import { RecordAssessmentForm } from '../record-assessment-form/record-assessment-form';
@Component({
selector: 'app-case-actions',
imports: [RecordAssessmentForm],
templateUrl: './case-actions.html',
styleUrl: './case-actions.css',
})
export class CaseActions {
private readonly caseDetailService = inject(CaseDetailService);
readonly actions = input.required<CaseDetailActions>();
readonly takeOwnershipRequested = output<ActionLink>();
readonly releaseOwnershipRequested = output<ActionLink>();
readonly savedRequested = output<void>();
// Advisory only - never disables the real take-ownership button. Legacy
// data can change between a check and the real call, so that call stays
// the source of truth regardless of what this reports.
readonly preflightChecking = signal(false);
readonly preflightResult = signal<string | null>(null);
checkTakeOwnership(link: ActionLink): void {
this.preflightChecking.set(true);
this.preflightResult.set(null);
this.caseDetailService.preflightTakeOwnership(link.href).subscribe({
next: () => {
this.preflightChecking.set(false);
this.preflightResult.set('Zou slagen: dit dossier kan in eigen beheer worden genomen.');
},
error: (error: HttpErrorResponse) => {
this.preflightChecking.set(false);
this.preflightResult.set(`Zou mislukken — ${this.describePreflightError(error)}`);
},
});
}
private describePreflightError(error: HttpErrorResponse): string {
if (error.status === 422) {
const body = error.error as InvariantViolationResponse;
return `${body.invariant}: ${body.message}`;
}
if (error.status === 409) {
return (error.error as MessageResponse).message;
}
return 'Onbekende fout.';
}
}
@@ -0,0 +1,50 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
ApplicantDetailsRequest,
CaseDetail,
RecordAssessmentRequest,
RecordAssessmentResult,
TakeOwnershipPreflightResult,
TakeOwnershipResult,
} from './case-detail.types';
@Injectable({ providedIn: 'root' })
export class CaseDetailService {
private readonly http = inject(HttpClient);
getLegacyDetail(aanvraagId: string): Observable<CaseDetail> {
return this.http.get<CaseDetail>(`/api/worklist/legacy/${aanvraagId}`);
}
getOwnedDetail(registrationApplicationId: string): Observable<CaseDetail> {
return this.http.get<CaseDetail>(`/api/worklist/owned/${registrationApplicationId}`);
}
// Every write below is issued against an `href` taken directly from the
// detail response's `actions` block, never a URL built from the id -
// that's the point of the HATEOAS-style contract (see ADR-002/003).
updateDetails(href: string, body: ApplicantDetailsRequest): Observable<void> {
return this.http.put<void>(href, body);
}
takeOwnership(href: string): Observable<TakeOwnershipResult> {
return this.http.post<TakeOwnershipResult>(href, null);
}
// Read-only: reports what takeOwnership would do right now, without
// calling it. A 4xx (409/404/422) means it would fail the same way.
preflightTakeOwnership(href: string): Observable<TakeOwnershipPreflightResult> {
return this.http.get<TakeOwnershipPreflightResult>(href);
}
releaseOwnership(href: string): Observable<void> {
return this.http.delete<void>(href);
}
recordAssessment(href: string, body: RecordAssessmentRequest): Observable<RecordAssessmentResult> {
return this.http.post<RecordAssessmentResult>(href, body);
}
}
@@ -0,0 +1,105 @@
// Mirrors New.Api.Contracts.CaseDetailResponse and its request contracts
// (ApplicantDetailsRequest, RecordAssessmentRequest) verbatim. `actions`
// links (href) are always used as-is, never rebuilt client-side.
export interface ActionLink {
mode: string;
href: string;
}
export interface CaseDetailActions {
editApplicantDetails: ActionLink;
recordAssessment: ActionLink;
takeOwnership?: ActionLink;
takeOwnershipPreflight?: ActionLink;
releaseOwnership?: ActionLink;
}
export interface Address {
street: string;
number: string;
postalCode: string;
city: string;
}
export interface Assessment {
outcome: string;
motivation: string;
verifiedItems: string[];
exceptionReason: string | null;
rejectionCategory: string | null;
decidedOn: string;
}
export interface CaseDetail {
origin: 'Legacy' | 'Owned';
legacyAanvraagId: number | null;
registrationApplicationId: string | null;
surname: string;
initials: string;
bsn: string;
address: Address | null;
email: string | null;
phone: string | null;
preferredChannel: string;
diplomaCode: string;
diplomaCountryOfIssue: string;
diplomaIssuedOn: string;
receivedOn: string;
assessment: Assessment | null;
processStatus: string | null;
lastModifiedAt: string | null;
actions: CaseDetailActions;
seams: Record<string, string | null>;
}
export interface ApplicantDetailsRequest {
surname: string;
initials: string;
address: Address | null;
email: string | null;
phone: string | null;
preferredChannel: string;
}
/** Maps a `seams` entry to which system it came from, for the provenance accent on info cards. */
export function sourceAccentClass(source: string | null): 'card--source-legacy' | 'card--source-owned' {
return source === 'legacy-backend' ? 'card--source-legacy' : 'card--source-owned';
}
/** The human-facing case reference shown in headers/breadcrumbs. */
export function referenceOf(detail: CaseDetail): string {
return detail.origin === 'Legacy' ? `A-${detail.legacyAanvraagId}` : detail.registrationApplicationId!;
}
export function toApplicantDetailsRequest(detail: CaseDetail): ApplicantDetailsRequest {
return {
surname: detail.surname,
initials: detail.initials,
address: detail.address,
email: detail.email,
phone: detail.phone,
preferredChannel: detail.preferredChannel,
};
}
export interface RecordAssessmentRequest {
verifiedItems: string[];
exceptionReason: string | null;
outcome: 'Approved' | 'Rejected';
rejectionCategory: string | null;
motivation: string;
}
export interface RecordAssessmentResult {
closurePending: boolean;
}
export interface TakeOwnershipResult {
registrationApplicationId: string;
}
/** 200 body from GET .../take-ownership/preflight - a 4xx means it wouldn't. */
export interface TakeOwnershipPreflightResult {
wouldSucceed: true;
}
@@ -0,0 +1,62 @@
.breadcrumb {
font-size: 0.875rem;
color: var(--rhs-ink-muted);
margin-bottom: var(--rhs-space-3);
}
.breadcrumb a {
color: var(--rhs-primary-dark);
}
.case-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--rhs-space-3);
margin-bottom: var(--rhs-space-5);
}
.case-header h2 {
margin-bottom: 0;
}
.case-header__ref {
color: var(--rhs-ink-muted);
margin: var(--rhs-space-1) 0 0;
}
.case-header__badges {
display: flex;
gap: var(--rhs-space-2);
flex-shrink: 0;
}
.case-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
gap: var(--rhs-space-4);
align-items: start;
}
.card--actions {
box-shadow: 0 1px 3px rgb(0 0 0 / 8%);
}
.card--diagnostic {
background: var(--rhs-surface);
}
.card--diagnostic .seam-note {
font-size: 0.875rem;
color: var(--rhs-ink-muted);
}
.loading {
color: var(--rhs-ink-muted);
}
@media (max-width: 45rem) {
.case-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,114 @@
@if (detail(); as d) {
<nav class="breadcrumb" aria-label="Kruimelpad">
<a routerLink="/">Werkvoorraad</a>
<span aria-hidden="true"> / </span>
<span class="mono">{{ referenceOf(d) }}</span>
</nav>
<div class="case-header">
<div>
<h2>{{ d.surname }}, {{ d.initials }}</h2>
<p class="mono case-header__ref">{{ referenceOf(d) }}</p>
</div>
<div class="case-header__badges">
<span class="badge" [class.badge--legacy]="d.origin === 'Legacy'" [class.badge--owned]="d.origin === 'Owned'">{{ d.origin }}</span>
@if (d.processStatus) {
<span class="badge badge--status">{{ d.processStatus }}</span>
}
</div>
</div>
<div class="case-grid">
<div class="case-grid__main">
<section class="card" [class]="sourceAccentClass(d.seams['aanvrager'])">
<h3>Aanvrager</h3>
<p class="card__source">bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}</p>
<dl>
<dt>BSN</dt>
<dd class="mono">{{ d.bsn }}</dd>
<dt>E-mail</dt>
<dd>{{ d.email ?? 'n.v.t.' }}</dd>
<dt>Telefoon</dt>
<dd>{{ d.phone ?? 'n.v.t.' }}</dd>
<dt>Voorkeurskanaal</dt>
<dd>{{ d.preferredChannel }}</dd>
@if (d.address; as address) {
<dt>Adres</dt>
<dd>{{ address.street }} {{ address.number }}, {{ address.postalCode }} {{ address.city }}</dd>
}
</dl>
</section>
<section class="card" [class]="sourceAccentClass(d.seams['aanvrager'])">
<h3>Diploma</h3>
<p class="card__source">bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}</p>
<dl>
<dt>Code</dt>
<dd class="mono">{{ d.diplomaCode }}</dd>
<dt>Land van uitgifte</dt>
<dd>{{ d.diplomaCountryOfIssue }}</dd>
<dt>Uitgegeven op</dt>
<dd>{{ d.diplomaIssuedOn }}</dd>
<dt>Ontvangen op</dt>
<dd>{{ d.receivedOn }}</dd>
<dt>Processtatus</dt>
<dd>{{ d.processStatus ?? 'n.v.t.' }}</dd>
</dl>
</section>
@if (d.assessment; as assessment) {
<section class="card" [class]="sourceAccentClass(d.seams['procestijdlijn'])">
<h3>Beoordeling</h3>
<p class="card__source">bron: {{ d.seams['procestijdlijn'] ?? 'onbekend' }}</p>
<dl>
<dt>Uitkomst</dt>
<dd>{{ assessment.outcome }}</dd>
<dt>Motivatie</dt>
<dd>{{ assessment.motivation }}</dd>
<dt>Gecontroleerde stukken</dt>
<dd>{{ assessment.verifiedItems.join(', ') || 'geen' }}</dd>
<dt>Beslist op</dt>
<dd>{{ assessment.decidedOn }}</dd>
</dl>
</section>
}
<section class="card">
<h3>Gegevens wijzigen</h3>
<app-edit-applicant-details
[actionLink]="d.actions.editApplicantDetails"
[initial]="toApplicantDetailsRequest(d)"
(saved)="reload()"
/>
</section>
</div>
<div class="case-grid__side">
<section class="card card--actions">
<h3>Acties</h3>
@if (banner(); as message) {
<p class="banner banner--error" data-testid="ownership-banner">{{ message }}</p>
}
<app-case-actions
[actions]="d.actions"
(savedRequested)="reload()"
(takeOwnershipRequested)="onTakeOwnership($event)"
(releaseOwnershipRequested)="onReleaseOwnership($event)"
/>
</section>
<section class="card card--diagnostic">
<h3>Herkomst</h3>
<p class="seam-note">Toont welk backend elk onderdeel van deze pagina levert.</p>
<dl>
@for (seam of d.seams | keyvalue; track seam.key) {
<dt>{{ seam.key }}</dt>
<dd class="mono">{{ seam.value ?? 'n.v.t.' }}</dd>
}
</dl>
</section>
</div>
</div>
} @else {
<p class="loading">Laden...</p>
}
@@ -0,0 +1,188 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service';
import { CaseDetail } from '../case-detail.types';
import { CaseDetailPage } from './case-detail';
const legacyDetail: CaseDetail = {
origin: 'Legacy',
legacyAanvraagId: 1002,
registrationApplicationId: null,
surname: 'de Vries',
initials: 'A.',
bsn: '123456782',
address: { street: 'Kerkweg', number: '12', postalCode: '3512JK', city: 'Utrecht' },
email: 'anna@example.nl',
phone: null,
preferredChannel: 'Post',
diplomaCode: 'X',
diplomaCountryOfIssue: 'NL',
diplomaIssuedOn: '2020-01-01',
receivedOn: '2026-01-01',
assessment: null,
processStatus: null,
lastModifiedAt: null,
actions: {
editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' },
recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1002/beoordeling' },
takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1002/take-ownership' },
},
seams: { aanvrager: 'legacy-backend', procestijdlijn: null },
};
const ownedDetail: CaseDetail = {
...legacyDetail,
origin: 'Owned',
legacyAanvraagId: null,
registrationApplicationId: '00000000-0000-0000-0000-000000000002',
actions: {
editApplicantDetails: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/details' },
recordAssessment: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' },
releaseOwnership: { mode: 'transition', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership' },
},
seams: { aanvrager: 'owned', procestijdlijn: 'case-framework-timeline' },
};
function configure(routeConfigPath: string, paramMap: Record<string, string>, serviceOverrides: Record<string, unknown> = {}) {
const service = {
getLegacyDetail: vi.fn().mockReturnValue(of(legacyDetail)),
getOwnedDetail: vi.fn().mockReturnValue(of(ownedDetail)),
updateDetails: vi.fn(),
recordAssessment: vi.fn(),
takeOwnership: vi.fn(),
releaseOwnership: vi.fn(),
...serviceOverrides,
};
return {
service,
ready: TestBed.configureTestingModule({
imports: [CaseDetailPage],
providers: [
provideRouter([]),
{ provide: CaseDetailService, useValue: service },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap(paramMap)),
snapshot: { routeConfig: { path: routeConfigPath } },
},
},
],
}).compileComponents(),
};
}
describe('CaseDetailPage', () => {
let fixture: ComponentFixture<CaseDetailPage>;
it('given a legacy/:id route, fetches via getLegacyDetail and renders the case', async () => {
const { service, ready } = configure('legacy/:id', { id: '1002' });
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
expect(service.getLegacyDetail).toHaveBeenCalledWith('1002');
expect(service.getOwnedDetail).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('de Vries');
});
it('given an owned/:id route, fetches via getOwnedDetail', async () => {
const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' });
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
expect(service.getOwnedDetail).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000002');
expect(service.getLegacyDetail).not.toHaveBeenCalled();
});
describe('take ownership', () => {
afterEach(() => vi.restoreAllMocks());
it('does nothing when the user declines the confirm dialog', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const { service, ready } = configure('legacy/:id', { id: '1002' });
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
expect(service.takeOwnership).not.toHaveBeenCalled();
});
it('on confirm, calls takeOwnership and navigates to the new owned detail', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const { service, ready } = configure('legacy/:id', { id: '1002' }, {
takeOwnership: vi.fn().mockReturnValue(of({ registrationApplicationId: '00000000-0000-0000-0000-000000000002' })),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
expect(service.takeOwnership).toHaveBeenCalledWith('/api/worklist/legacy/1002/take-ownership');
expect(navigate).toHaveBeenCalledWith(['owned', '00000000-0000-0000-0000-000000000002']);
});
it('on a 422 InvariantViolationResponse, shows a banner instead of navigating', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const { ready } = configure('legacy/:id', { id: '1002' }, {
takeOwnership: vi
.fn()
.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Bsn.ElevenProof', message: 'BSN fails the eleven-proof.' } }))),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('[data-testid="ownership-banner"]');
expect(banner?.textContent).toContain('Bsn.ElevenProof');
expect(banner?.textContent).toContain('BSN fails the eleven-proof.');
});
});
describe('release ownership', () => {
it('calls releaseOwnership and navigates to the worklist on success', async () => {
const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, {
releaseOwnership: vi.fn().mockReturnValue(of(undefined)),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click();
expect(service.releaseOwnership).toHaveBeenCalledWith('/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership');
expect(navigate).toHaveBeenCalledWith(['/']);
});
it('on a 409 MessageResponse, shows the server message as a banner', async () => {
const { ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, {
releaseOwnership: vi
.fn()
.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409, error: { message: 'Domain writes exist since adoption.' } }))),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="ownership-banner"]')?.textContent).toContain('Domain writes exist since adoption.');
});
});
});
@@ -0,0 +1,78 @@
import { HttpErrorResponse } from '@angular/common/http';
import { KeyValuePipe } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
import { CaseActions } from '../case-actions/case-actions';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, CaseDetail, referenceOf, sourceAccentClass, toApplicantDetailsRequest } from '../case-detail.types';
import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details';
@Component({
selector: 'app-case-detail',
imports: [CaseActions, EditApplicantDetails, KeyValuePipe, RouterLink],
templateUrl: './case-detail.html',
styleUrl: './case-detail.css',
})
export class CaseDetailPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly caseDetailService = inject(CaseDetailService);
readonly detail = signal<CaseDetail | null>(null);
readonly banner = signal<string | null>(null);
readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
readonly sourceAccentClass = sourceAccentClass;
readonly referenceOf = referenceOf;
constructor() {
this.route.paramMap.subscribe((params) => {
const id = params.get('id')!;
const isLegacy = this.route.snapshot.routeConfig?.path?.startsWith('legacy') ?? false;
const source$ = isLegacy ? this.caseDetailService.getLegacyDetail(id) : this.caseDetailService.getOwnedDetail(id);
source$.subscribe((detail) => this.detail.set(detail));
});
}
reload(): void {
const current = this.detail();
if (!current) return;
const source$ =
current.origin === 'Legacy'
? this.caseDetailService.getLegacyDetail(String(current.legacyAanvraagId))
: this.caseDetailService.getOwnedDetail(current.registrationApplicationId!);
source$.subscribe((updated) => this.detail.set(updated));
}
// Matches the placeholder's own confirm() dialog before this one-way,
// per-case migration step (ADR-003).
onTakeOwnership(link: ActionLink): void {
if (!confirm('Dit dossier in eigen beheer nemen?')) return;
this.banner.set(null);
this.caseDetailService.takeOwnership(link.href).subscribe({
next: (result) => this.router.navigate(['owned', result.registrationApplicationId]),
error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)),
});
}
onReleaseOwnership(link: ActionLink): void {
this.banner.set(null);
this.caseDetailService.releaseOwnership(link.href).subscribe({
next: () => this.router.navigate(['/']),
error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)),
});
}
private describeError(error: HttpErrorResponse): string {
if (error.status === 422) {
const body = error.error as InvariantViolationResponse;
return `${body.invariant}: ${body.message}`;
}
if (error.status === 409) {
const body = error.error as MessageResponse;
return body.message;
}
return 'Onbekende fout.';
}
}
@@ -0,0 +1,75 @@
<form (ngSubmit)="submit()">
@if (isWriteThrough) {
<p class="banner banner--warning" data-testid="write-through-disclaimer">
Gevalideerd door het legacy systeem — Angular voegt hier geen eigen validatie toe.
</p>
}
@if (banner) {
<p class="banner banner--error" data-testid="edit-details-banner">{{ banner }}</p>
}
<label>
Achternaam
<input type="text" name="surname" required [(ngModel)]="surname" />
</label>
@if (fieldErrors['surname']; as error) {
<p class="field-error" data-testid="field-error-surname">{{ error }}</p>
}
<label>
Voorletters
<input type="text" name="initials" required [(ngModel)]="initials" />
</label>
<label>
Straat
<input type="text" name="street" [(ngModel)]="street" />
</label>
<label>
Huisnummer
<input type="text" name="number" [(ngModel)]="number" />
</label>
@if (fieldErrors['address.number']; as error) {
<p class="field-error" data-testid="field-error-address.number">{{ error }}</p>
}
<label>
Postcode
<input type="text" name="postalCode" [(ngModel)]="postalCode" />
</label>
@if (fieldErrors['address.postalCode']; as error) {
<p class="field-error" data-testid="field-error-address.postalCode">{{ error }}</p>
}
<label>
Plaats
<input type="text" name="city" [(ngModel)]="city" />
</label>
<label>
E-mail
<input type="email" name="email" [(ngModel)]="email" />
</label>
@if (fieldErrors['email']; as error) {
<p class="field-error" data-testid="field-error-email">{{ error }}</p>
}
<label>
Telefoon
<input type="text" name="phone" [(ngModel)]="phone" />
</label>
@if (fieldErrors['phone']; as error) {
<p class="field-error" data-testid="field-error-phone">{{ error }}</p>
}
<label>
Voorkeurskanaal
<input type="text" name="preferredChannel" required [(ngModel)]="preferredChannel" />
</label>
<button type="submit" class="btn btn--primary" data-testid="save-details-submit" [disabled]="saving()">
{{ saving() ? 'Bezig met opslaan…' : 'Opslaan' }}
</button>
</form>
@@ -0,0 +1,136 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Subject, of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, ApplicantDetailsRequest } from '../case-detail.types';
import { EditApplicantDetails } from './edit-applicant-details';
const initial: ApplicantDetailsRequest = {
surname: 'de Vries',
initials: 'A.',
address: { street: 'Kerkweg', number: '12', postalCode: '3512JK', city: 'Utrecht' },
email: 'anna@example.nl',
phone: null,
preferredChannel: 'Post',
};
describe('EditApplicantDetails', () => {
let fixture: ComponentFixture<EditApplicantDetails>;
let updateDetails: ReturnType<typeof vi.fn>;
function render(actionLink: ActionLink): HTMLElement {
fixture = TestBed.createComponent(EditApplicantDetails);
fixture.componentRef.setInput('actionLink', actionLink);
fixture.componentRef.setInput('initial', initial);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
function submitForm(el: HTMLElement): void {
el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
fixture.detectChanges();
}
beforeEach(async () => {
updateDetails = vi.fn();
await TestBed.configureTestingModule({
imports: [EditApplicantDetails],
providers: [{ provide: CaseDetailService, useValue: { updateDetails } }],
}).compileComponents();
});
it('shows the write-through disclaimer only in writeThrough mode', () => {
const writeThroughEl = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' });
expect(writeThroughEl.querySelector('[data-testid="write-through-disclaimer"]')).toBeTruthy();
const ownedEl = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
expect(ownedEl.querySelector('[data-testid="write-through-disclaimer"]')).toBeFalsy();
});
it('submits the form body to the given href and emits saved on success', () => {
updateDetails.mockReturnValue(of(undefined));
const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
const savedSpy = vi.fn();
fixture.componentInstance.saved.subscribe(savedSpy);
submitForm(el);
expect(updateDetails).toHaveBeenCalledWith('/api/worklist/owned/abc/details', {
surname: 'de Vries',
initials: 'A.',
address: { street: 'Kerkweg', number: '12', postalCode: '3512JK', city: 'Utrecht' },
email: 'anna@example.nl',
phone: null,
preferredChannel: 'Post',
});
expect(savedSpy).toHaveBeenCalled();
});
it('disables the submit button while the request is in flight', async () => {
const subject = new Subject<void>();
updateDetails.mockReturnValue(subject);
const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
const button = el.querySelector<HTMLButtonElement>('[data-testid="save-details-submit"]')!;
submitForm(el);
expect(button.disabled).toBe(true);
expect(button.textContent).toContain('Bezig met opslaan');
subject.next();
subject.complete();
fixture.detectChanges();
await fixture.whenStable();
expect(button.disabled).toBe(false);
});
it('maps a 400 ErrorsResponse to per-field inline errors', () => {
updateDetails.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 400,
error: {
errors: [
{ field: 'surname', message: 'Achternaam is verplicht.' },
{ field: 'address.number', message: 'Huisnummer is verplicht.' },
{ field: 'address.postalCode', message: 'Ongeldige postcode.' },
],
},
}),
),
);
const el = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' });
submitForm(el);
expect(el.querySelector('[data-testid="field-error-surname"]')?.textContent).toContain('Achternaam is verplicht.');
expect(el.querySelector('[data-testid="field-error-address.number"]')?.textContent).toContain('Huisnummer is verplicht.');
expect(el.querySelector('[data-testid="field-error-address.postalCode"]')?.textContent).toContain('Ongeldige postcode.');
});
it('shows a 422 InvariantViolationResponse as an invariant + message banner', () => {
updateDetails.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Address.AllPartsRequired', message: 'All address parts are required.' } })),
);
const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
submitForm(el);
expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('Address.AllPartsRequired');
expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('All address parts are required.');
});
it('shows a 409 MessageResponse as a banner', () => {
updateDetails.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 409, error: { message: 'This aanvraag has already been migrated.' } })),
);
const el = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' });
submitForm(el);
expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('This aanvraag has already been migrated.');
});
});
@@ -0,0 +1,100 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, EventEmitter, Input, OnInit, Output, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ErrorsResponse, InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, ApplicantDetailsRequest } from '../case-detail.types';
@Component({
selector: 'app-edit-applicant-details',
imports: [FormsModule],
templateUrl: './edit-applicant-details.html',
styleUrl: './edit-applicant-details.css',
})
export class EditApplicantDetails implements OnInit {
private readonly caseDetailService = inject(CaseDetailService);
@Input({ required: true }) actionLink!: ActionLink;
@Input({ required: true }) initial!: ApplicantDetailsRequest;
@Output() readonly saved = new EventEmitter<void>();
surname = '';
initials = '';
street = '';
number = '';
postalCode = '';
city = '';
email = '';
phone = '';
preferredChannel = '';
fieldErrors: Record<string, string> = {};
banner: string | null = null;
readonly saving = signal(false);
get isWriteThrough(): boolean {
return this.actionLink.mode === 'writeThrough';
}
ngOnInit(): void {
this.surname = this.initial.surname;
this.initials = this.initial.initials;
this.street = this.initial.address?.street ?? '';
this.number = this.initial.address?.number ?? '';
this.postalCode = this.initial.address?.postalCode ?? '';
this.city = this.initial.address?.city ?? '';
this.email = this.initial.email ?? '';
this.phone = this.initial.phone ?? '';
this.preferredChannel = this.initial.preferredChannel;
}
submit(): void {
this.fieldErrors = {};
this.banner = null;
this.saving.set(true);
const body: ApplicantDetailsRequest = {
surname: this.surname,
initials: this.initials,
address: this.street ? { street: this.street, number: this.number, postalCode: this.postalCode, city: this.city } : null,
email: this.email || null,
phone: this.phone || null,
preferredChannel: this.preferredChannel,
};
this.caseDetailService.updateDetails(this.actionLink.href, body).subscribe({
next: () => {
this.saving.set(false);
this.saved.emit();
},
error: (error: HttpErrorResponse) => {
this.saving.set(false);
this.handleError(error);
},
});
}
private handleError(error: HttpErrorResponse): void {
switch (error.status) {
case 400: {
const body = error.error as ErrorsResponse;
this.fieldErrors = Object.fromEntries(body.errors.map((fieldError) => [fieldError.field, fieldError.message]));
break;
}
case 422: {
const body = error.error as InvariantViolationResponse;
this.banner = `${body.invariant}: ${body.message}`;
break;
}
case 409: {
const body = error.error as MessageResponse;
this.banner = body.message;
break;
}
default:
this.banner = 'Onbekende fout bij opslaan.';
}
}
}
@@ -0,0 +1,49 @@
@if (recorded()) {
<p data-testid="assessment-recorded" class="banner banner--success">
Beoordeling vastgelegd.
@if (closurePending()) {
<br />
<span data-testid="closure-pending-note">Administratieve afsluiting in behandeling.</span>
}
</p>
} @else {
<form (ngSubmit)="submit()">
@if (banner()) {
<p class="banner banner--error" data-testid="assessment-banner">{{ banner() }}</p>
}
<label>
Gecontroleerde stukken (kommagescheiden)
<input type="text" name="verifiedItems" [(ngModel)]="verifiedItemsText" />
</label>
<label>
Uitzonderingsreden (alleen relevant zonder gecontroleerde stukken)
<input type="text" name="exceptionReason" [(ngModel)]="exceptionReason" />
</label>
<label>
Uitkomst
<select name="outcome" [(ngModel)]="outcome">
<option value="Approved">Approved</option>
<option value="Rejected">Rejected</option>
</select>
</label>
@if (outcome === 'Rejected') {
<label>
Afwijzingscategorie
<input type="text" name="rejectionCategory" [(ngModel)]="rejectionCategory" />
</label>
}
<label>
Motivatie
<textarea name="motivation" [(ngModel)]="motivation"></textarea>
</label>
<button type="submit" class="btn btn--primary" data-testid="record-assessment-submit" [disabled]="saving()">
{{ saving() ? 'Bezig met vastleggen…' : 'Beoordeling vastleggen' }}
</button>
</form>
}
@@ -0,0 +1,111 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Subject, of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service';
import { RecordAssessmentForm } from './record-assessment-form';
describe('RecordAssessmentForm', () => {
let fixture: ComponentFixture<RecordAssessmentForm>;
let recordAssessment: ReturnType<typeof vi.fn>;
async function render(): Promise<HTMLElement> {
fixture = TestBed.createComponent(RecordAssessmentForm);
fixture.componentRef.setInput('actionLink', { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' });
fixture.detectChanges();
// NgModel/NgForm finish registering their controls on a microtask after
// the first detectChanges() - without this, the first simulated input
// event lands before the control is wired up and gets silently dropped.
await fixture.whenStable();
return fixture.nativeElement as HTMLElement;
}
async function fillAndSubmit(el: HTMLElement, values: { verifiedItems?: string; motivation?: string }) {
const setValue = async (name: string, value: string) => {
const input = el.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`)!;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
fixture.detectChanges();
await fixture.whenStable();
};
if (values.verifiedItems !== undefined) await setValue('verifiedItems', values.verifiedItems);
if (values.motivation !== undefined) await setValue('motivation', values.motivation);
el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(async () => {
recordAssessment = vi.fn();
await TestBed.configureTestingModule({
imports: [RecordAssessmentForm],
providers: [{ provide: CaseDetailService, useValue: { recordAssessment } }],
}).compileComponents();
});
it('submits verifiedItems split on commas and trimmed', async () => {
recordAssessment.mockReturnValue(of({ closurePending: false }));
const el = await render();
await fillAndSubmit(el, { verifiedItems: 'document, land , datum', motivation: 'Alles gecontroleerd.' });
expect(recordAssessment).toHaveBeenCalledWith('/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment', {
verifiedItems: ['document', 'land', 'datum'],
exceptionReason: null,
outcome: 'Approved',
rejectionCategory: null,
motivation: 'Alles gecontroleerd.',
});
});
it('shows closurePending as a neutral note, not an error, on success', async () => {
recordAssessment.mockReturnValue(of({ closurePending: true }));
const el = await render();
await fillAndSubmit(el, { verifiedItems: 'document', motivation: 'Alles gecontroleerd.' });
expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeTruthy();
expect(el.querySelector('[data-testid="closure-pending-note"]')?.textContent).toContain('Administratieve afsluiting in behandeling.');
expect(el.querySelector('[data-testid="assessment-banner"]')).toBeFalsy();
});
it('disables the submit button while the request is in flight', async () => {
const subject = new Subject<{ closurePending: boolean }>();
recordAssessment.mockReturnValue(subject);
const el = await render();
const input = el.querySelector<HTMLInputElement>('[name="verifiedItems"]')!;
input.value = 'document';
input.dispatchEvent(new Event('input', { bubbles: true }));
fixture.detectChanges();
await fixture.whenStable();
el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
fixture.detectChanges();
const button = el.querySelector<HTMLButtonElement>('[data-testid="record-assessment-submit"]')!;
expect(button.disabled).toBe(true);
expect(button.textContent).toContain('Bezig met vastleggen');
subject.next({ closurePending: false });
subject.complete();
fixture.detectChanges();
await fixture.whenStable();
expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeTruthy();
});
it('shows a 422 InvariantViolationResponse as a banner, not a curated message', async () => {
recordAssessment.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Assessment.MotivationTooShort', message: 'Motivation is too short.' } })),
);
const el = await render();
await fillAndSubmit(el, { verifiedItems: 'document', motivation: 'te kort' });
expect(el.querySelector('[data-testid="assessment-banner"]')?.textContent).toContain('Assessment.MotivationTooShort');
expect(el.querySelector('[data-testid="assessment-banner"]')?.textContent).toContain('Motivation is too short.');
expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeFalsy();
});
});
@@ -0,0 +1,63 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, EventEmitter, Input, Output, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { InvariantViolationResponse } from '../../shared/api-error.types';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, RecordAssessmentRequest } from '../case-detail.types';
@Component({
selector: 'app-record-assessment-form',
imports: [FormsModule],
templateUrl: './record-assessment-form.html',
styleUrl: './record-assessment-form.css',
})
export class RecordAssessmentForm {
private readonly caseDetailService = inject(CaseDetailService);
@Input({ required: true }) actionLink!: ActionLink;
@Output() readonly saved = new EventEmitter<void>();
verifiedItemsText = '';
exceptionReason = '';
outcome: 'Approved' | 'Rejected' = 'Approved';
rejectionCategory = '';
motivation = '';
readonly banner = signal<string | null>(null);
readonly recorded = signal(false);
readonly closurePending = signal(false);
readonly saving = signal(false);
submit(): void {
this.banner.set(null);
this.saving.set(true);
const verifiedItems = this.verifiedItemsText
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
const body: RecordAssessmentRequest = {
verifiedItems,
exceptionReason: verifiedItems.length === 0 ? this.exceptionReason || null : null,
outcome: this.outcome,
rejectionCategory: this.outcome === 'Rejected' ? this.rejectionCategory || null : null,
motivation: this.motivation,
};
this.caseDetailService.recordAssessment(this.actionLink.href, body).subscribe({
next: (result) => {
this.saving.set(false);
this.recorded.set(true);
this.closurePending.set(result.closurePending);
this.saved.emit();
},
error: (error: HttpErrorResponse) => {
this.saving.set(false);
const invariant = error.error as InvariantViolationResponse;
this.banner.set(`${invariant.invariant}: ${invariant.message}`);
},
});
}
}
@@ -0,0 +1,25 @@
// Mirrors New.Api.Contracts error shapes verbatim.
export interface FieldError {
field: string;
message: string;
detail?: string | null;
}
/** 400 - legacy write-through validation failures only (seam B). */
export interface ErrorsResponse {
errors: FieldError[];
}
/** 422 - a single named domain-invariant failure. Render `invariant` and
* `message` as given; the set of invariants isn't fixed client-side
* knowledge (ADR-003), so no switch/enum maps over it. */
export interface InvariantViolationResponse {
invariant: string;
message: string;
}
/** 409 - a conflict, e.g. release-ownership blocked by prior domain writes. */
export interface MessageResponse {
message: string;
}
@@ -0,0 +1,43 @@
<h2>Werkvoorraad</h2>
<div class="filters">
<label>
Bucket
<input type="text" name="bucket" [(ngModel)]="bucket" (ngModelChange)="onFilterChange()" />
</label>
<label>
Origin
<input type="text" name="origin" [(ngModel)]="origin" (ngModelChange)="onFilterChange()" />
</label>
<label>
Zoeken
<input type="text" name="search" [(ngModel)]="search" (ngModelChange)="onFilterChange()" />
</label>
</div>
<table>
<thead>
<tr>
<th>Origin</th>
<th>Referentie</th>
<th>Naam</th>
<th>BSN</th>
<th>Ontvangen</th>
<th>Uitkomst</th>
<th>Processtatus</th>
</tr>
</thead>
<tbody>
@for (item of items(); track keyOf(item).id) {
<tr data-testid="worklist-row" (click)="openDetail(item)">
<td>{{ item.origin }}</td>
<td>{{ keyOf(item).id }}</td>
<td>{{ item.surname }}, {{ item.initials }}</td>
<td>{{ item.bsn }}</td>
<td>{{ item.receivedOn }}</td>
<td>{{ item.assessmentOutcome ?? 'n.v.t.' }}</td>
<td>{{ item.processStatus ?? 'n.v.t.' }}</td>
</tr>
}
</tbody>
</table>
@@ -0,0 +1,91 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WorklistService } from '../worklist.service';
import { WorklistItem, WorklistPage, WorklistQuery } from '../worklist.types';
import { WorklistList } from './worklist-list';
const legacyItem: WorklistItem = {
origin: 'Legacy',
legacyAanvraagId: 1001,
registrationApplicationId: null,
surname: 'de Vries',
initials: 'A.',
bsn: '123456782',
receivedOn: '2026-01-01',
bucket: 'ToBeAssessed',
assessmentOutcome: null,
processStatus: null,
};
const ownedItem: WorklistItem = {
...legacyItem,
origin: 'Owned',
legacyAanvraagId: null,
registrationApplicationId: '00000000-0000-0000-0000-000000000002',
};
function pageOf(items: WorklistItem[]): WorklistPage {
return { items, page: 1, pageSize: 10, totalCount: items.length };
}
describe('WorklistList', () => {
let fixture: ComponentFixture<WorklistList>;
let getWorklist: ReturnType<typeof vi.fn>;
let router: Router;
function createComponent() {
fixture = TestBed.createComponent(WorklistList);
fixture.detectChanges();
router = TestBed.inject(Router);
}
beforeEach(async () => {
getWorklist = vi.fn().mockReturnValue(of(pageOf([legacyItem, ownedItem])));
await TestBed.configureTestingModule({
imports: [WorklistList],
providers: [provideRouter([]), { provide: WorklistService, useValue: { getWorklist } }],
}).compileComponents();
});
it('loads and renders the worklist on init', () => {
createComponent();
expect(getWorklist).toHaveBeenCalledWith({ bucket: undefined, origin: undefined, search: undefined });
const rows = fixture.nativeElement.querySelectorAll('[data-testid="worklist-row"]');
expect(rows.length).toBe(2);
});
it('given a legacy-origin row, navigates to legacy/:id on click', () => {
createComponent();
const navigate = vi.spyOn(router, 'navigate');
const rows = fixture.nativeElement.querySelectorAll('[data-testid="worklist-row"]');
rows[0].click();
expect(navigate).toHaveBeenCalledWith(['legacy', 1001]);
});
it('given an owned-origin row, navigates to owned/:id on click', () => {
createComponent();
const navigate = vi.spyOn(router, 'navigate');
const rows = fixture.nativeElement.querySelectorAll('[data-testid="worklist-row"]');
rows[1].click();
expect(navigate).toHaveBeenCalledWith(['owned', '00000000-0000-0000-0000-000000000002']);
});
it('reloads with the new query when a filter changes', () => {
createComponent();
getWorklist.mockClear();
fixture.componentInstance.bucket = 'ToBeAssessed';
fixture.componentInstance.onFilterChange();
expect(getWorklist).toHaveBeenCalledWith({ bucket: 'ToBeAssessed', origin: undefined, search: undefined } satisfies WorklistQuery);
});
});
@@ -0,0 +1,51 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { WorklistService } from '../worklist.service';
import { WorklistItem, WorklistQuery } from '../worklist.types';
@Component({
selector: 'app-worklist-list',
imports: [FormsModule],
templateUrl: './worklist-list.html',
styleUrl: './worklist-list.css',
})
export class WorklistList {
private readonly worklistService = inject(WorklistService);
private readonly router = inject(Router);
readonly items = signal<WorklistItem[]>([]);
bucket = '';
origin = '';
search = '';
constructor() {
this.load();
}
onFilterChange(): void {
this.load();
}
keyOf(item: WorklistItem): { segment: 'legacy' | 'owned'; id: number | string } {
return item.origin === 'Legacy'
? { segment: 'legacy', id: item.legacyAanvraagId! }
: { segment: 'owned', id: item.registrationApplicationId! };
}
openDetail(item: WorklistItem): void {
const { segment, id } = this.keyOf(item);
this.router.navigate([segment, id]);
}
private load(): void {
const query: WorklistQuery = {
bucket: this.bucket || undefined,
origin: this.origin || undefined,
search: this.search || undefined,
};
this.worklistService.getWorklist(query).subscribe((page) => this.items.set(page.items));
}
}
@@ -0,0 +1,18 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { WorklistPage, WorklistQuery } from './worklist.types';
@Injectable({ providedIn: 'root' })
export class WorklistService {
private readonly http = inject(HttpClient);
getWorklist(query: WorklistQuery = {}): Observable<WorklistPage> {
let params = new HttpParams();
if (query.bucket) params = params.set('bucket', query.bucket);
if (query.origin) params = params.set('origin', query.origin);
if (query.search) params = params.set('search', query.search);
return this.http.get<WorklistPage>('/api/worklist', { params });
}
}
@@ -0,0 +1,27 @@
// Mirrors New.Api.Contracts.WorklistItemResponse / WorklistPageResponse verbatim.
export interface WorklistItem {
origin: 'Legacy' | 'Owned';
legacyAanvraagId: number | null;
registrationApplicationId: string | null;
surname: string;
initials: string;
bsn: string;
receivedOn: string;
bucket: string;
assessmentOutcome: string | null;
processStatus: string | null;
}
export interface WorklistPage {
items: WorklistItem[];
page: number;
pageSize: number;
totalCount: number;
}
export interface WorklistQuery {
bucket?: string;
origin?: string;
search?: string;
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PortalFrontend</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+1
View File
@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}
+19
View File
@@ -26,6 +26,25 @@ http {
proxy_set_header Host $host;
}
# Session 2's Angular portal, kept alongside the Session 1 placeholder
# at "/" for side-by-side comparison. The trailing slash on both the
# location and proxy_pass strips the /portal prefix (same shape as the
# "/" -> new-frontend block below); the bare-path redirect below only
# exists to catch "/portal" (no trailing slash), which wouldn't match
# "location /portal/" and would otherwise silently fall through to "/".
# $http_host (not $host) is used explicitly so the Location header keeps
# the client-facing port (e.g. :8080) - nginx's own implicit redirect
# construction uses its internal `listen` port instead, which silently
# drops the port the host actually published this container under.
location = /portal {
return 301 $scheme://$http_host/portal/;
}
location /portal/ {
proxy_pass http://portal-frontend:80/;
proxy_set_header Host $host;
}
location / {
proxy_pass http://new-frontend:80/;
proxy_set_header Host $host;
+30 -1
View File
@@ -37,7 +37,7 @@ for line in sys.stdin:
print(f'{svc}: health={health}'); ok = False
print('ALL_OK' if ok else 'SOME_FAILED')
")
if echo "$STATUS" | grep -q ALL_OK; then pass "all 9 containers running/healthy"; else fail "container status: $STATUS"; fi
if echo "$STATUS" | grep -q ALL_OK; then pass "all 10 containers running/healthy"; else fail "container status: $STATUS"; fi
if curl -sS --max-time 2 http://localhost:8081 >/dev/null 2>&1; then
fail "legacy-backend must NOT be reachable from the host (port 8081 responded)"
@@ -50,6 +50,20 @@ else
pass "case-framework not reachable from host"
fi
echo "== Portal frontend (Session 2) =="
HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal/")
check_status "GET /portal/ serves the Angular app" "200" "$HTTP"
HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal/legacy/1001")
check_status "deep link /portal/legacy/1001 falls back to index.html" "200" "$HTTP"
# Regression check: nginx's implicit redirect construction uses its own
# internal `listen` port, not the host's published port - a naive
# `return 301 /portal/` silently drops :8080 from the Location header.
BARE_REDIRECT_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal")
check_status "GET /portal (no slash) redirects" "301" "$BARE_REDIRECT_STATUS"
FOLLOWED=$(curl -sSL -o /dev/null -w '%{http_code}' "$BASE/portal")
check_status "following that redirect lands on a 200, on the same host:port" "200" "$FOLLOWED"
echo "== Seam A: unified read =="
WORKLIST=$(curl -sS "$BASE/api/worklist")
@@ -78,6 +92,21 @@ ERRORS=$(curl -sS -X PUT "$BASE/api/worklist/legacy/1001/details" \
ERROR_COUNT=$(echo "$ERRORS" | json_field "['errors'].__len__()" 2>/dev/null || echo "$ERRORS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['errors']))")
check_status "3-field-invalid write-through returns 3 mapped errors" "3" "$ERROR_COUNT"
echo "== Write path 3 preflight: shadow check before take ownership =="
PREFLIGHT_1004=$(curl -sS -o /tmp/preflight_1004.json -w '%{http_code}' "$BASE/api/worklist/legacy/1004/take-ownership/preflight")
check_status "preflight predicts A-1004 would adopt cleanly" "200" "$PREFLIGHT_1004"
WOULD_SUCCEED=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1004.json'))['wouldSucceed'])")
check_status "preflight body reports wouldSucceed" "True" "$WOULD_SUCCEED"
SEAM_1004_AFTER_PREFLIGHT=$(curl -sS "$BASE/api/worklist/legacy/1004" | json_field "['seams']['aanvrager']")
check_status "preflight on A-1004 wrote nothing (still legacy-backend)" "legacy-backend" "$SEAM_1004_AFTER_PREFLIGHT"
PREFLIGHT_1005=$(curl -sS -o /tmp/preflight_1005.json -w '%{http_code}' "$BASE/api/worklist/legacy/1005/take-ownership/preflight")
check_status "preflight predicts A-1005 would fail adoption" "422" "$PREFLIGHT_1005"
PREFLIGHT_INVARIANT=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1005.json'))['invariant'])")
check_status "preflight names the invariant the real call below also fails on" "Bsn.ElevenProof" "$PREFLIGHT_INVARIANT"
echo "== Write path 3: take ownership =="
TAKE=$(curl -sS -o /tmp/take_1002.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/1002/take-ownership")