Compare commits
18
Commits
a6a1abbe9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddec15ccb2 | ||
|
|
5f22156e6d | ||
|
|
19c010653a | ||
|
|
46926215af | ||
|
|
ec965976b0 | ||
|
|
a3ba1573e1 | ||
|
|
15b6397317 | ||
|
|
8a192578fa | ||
|
|
c2d0ba9a3d | ||
|
|
3b04c9fae3 | ||
|
|
9e031da724 | ||
|
|
cebbf9f57a | ||
|
|
08e08a0070 | ||
|
|
97c0bea238 | ||
|
|
754086226b | ||
|
|
3ed130de25 | ||
|
|
b8a8d28e7b | ||
|
|
229ca9b7d9 |
@@ -10,9 +10,12 @@ migration strategy can be watched instead of slide-decked.
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open **http://localhost:8080**. That's the only host port published —
|
Then open **http://localhost:8080** (Session 1's placeholder UI) or
|
||||||
`legacy-backend` and `case-framework` are deliberately unreachable from the
|
**http://localhost:8080/portal** (Session 2's Angular portal) — both are
|
||||||
host (see §4 below).
|
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
|
**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
|
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
|
Run this against a **freshly started** stack — it depends on the untouched
|
||||||
seed data (legacy ids 1001–1012, owned ids `REG-2026-0001..0005`).
|
seed data (legacy ids 1001–1012, 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,
|
```mermaid
|
||||||
three databases, one proxy. `new-frontend` in this session is a deliberately
|
flowchart TB
|
||||||
plain, unstyled HTML page (`new-frontend/index.html`) that exercises the same
|
browser(["Browser"])
|
||||||
API a real UI would — it exists to prove the backend before a real Angular
|
browser -->|":8080 · the only published port"| proxy["proxy · nginx"]
|
||||||
portal replaces it in Session 2, not to be a good UI.
|
|
||||||
|
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
|
## 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.
|
`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
|
This isn't decoration — a single shared engine would let an implementer
|
||||||
quietly join across schemas or share a `DbContext`, and the seam would
|
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
|
force the take-ownership step ordering in `TakeOwnershipHandler` to be a real
|
||||||
constraint rather than a stylistic choice (no distributed transaction is
|
constraint rather than a stylistic choice (no distributed transaction is
|
||||||
available across them), and make the legacy type vocabulary
|
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
|
## 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` /
|
17 cases from two databases in one list. Filter `?origin=Legacy` /
|
||||||
`?origin=Owned` to see which is which.
|
`?origin=Owned` to see which is which.
|
||||||
2. **`A-1001`** (`GET /api/worklist/legacy/1001`) — all three write paths
|
2. **`A-1001`** (`GET /api/worklist/legacy/1001`) — all three write paths
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ services:
|
|||||||
new-frontend:
|
new-frontend:
|
||||||
build: ./new-frontend
|
build: ./new-frontend
|
||||||
|
|
||||||
|
portal-frontend:
|
||||||
|
build: ./portal-frontend
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
volumes:
|
volumes:
|
||||||
@@ -91,5 +94,6 @@ services:
|
|||||||
- "8080:80"
|
- "8080:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- new-frontend
|
- new-frontend
|
||||||
|
- portal-frontend
|
||||||
- new-backend
|
- new-backend
|
||||||
- legacy-frontend
|
- legacy-frontend
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ Accepted.
|
|||||||
`case-framework` (seam D, a stand-in for a maintained vendor case-management
|
`case-framework` (seam D, a stand-in for a maintained vendor case-management
|
||||||
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
|
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
|
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
|
The new domain's own rule is different: once an assessment (approve/reject) is
|
||||||
recorded on a `RegistrationApplication`, that decision is legally in effect
|
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,
|
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
|
address, contact) from the new portal, without the new system taking
|
||||||
ownership of that case. The legacy system remains the authority on this data
|
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
|
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
|
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).
|
(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
|
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.
|
patched into the translator.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
- The portal cannot offer a better validation experience than legacy already
|
- The portal cannot offer a better validation experience than legacy already
|
||||||
has for this seam — by design. The `Gevalideerd door het legacy systeem`
|
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
|
knows why: this is the honest version of a seamless UI, not a limitation to
|
||||||
hide.
|
hide.
|
||||||
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
|
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ their keep.
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
Ship now with ownership taken **one legacy case at a time**, via
|
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
|
an explicit user action in the portal. This is the interim mechanism, not the
|
||||||
final one for every process.
|
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
|
- Until bulk tooling exists, full legacy retirement for a process happens
|
||||||
case-by-case, which is slower than a scheduled cutover — accepted as the
|
case-by-case, which is slower than a scheduled cutover — accepted as the
|
||||||
cost of shipping the seam mechanics now rather than waiting.
|
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
|
same reason a bulk reversal would be unsafe absent a sync
|
||||||
(`docs/sync-not-implemented.md`): undoing adoption after edits would
|
(`docs/sync-not-implemented.md`): undoing adoption after edits would
|
||||||
silently discard them.
|
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.
|
— that's future work, not a rejected idea.
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|||||||
@@ -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 1–3 touch nothing, which is what makes a
|
||||||
|
failed adoption free; the preflight endpoint is literally this prefix, stopped
|
||||||
|
early. Steps 4–6 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 1–3 · 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 4–6 · 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`.*
|
||||||
@@ -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 1–3 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.
|
||||||
@@ -21,6 +21,7 @@ consequences, both intentional:
|
|||||||
honest substitute for a sync that does not exist.
|
honest substitute for a sync that does not exist.
|
||||||
2. **Ownership release is blocked once edits exist.** `DELETE
|
2. **Ownership release is blocked once edits exist.** `DELETE
|
||||||
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since
|
/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
|
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.
|
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(
|
private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new(
|
||||||
EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"),
|
EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"),
|
||||||
RecordAssessment: new ActionLink("redirect", $"/legacy/aanvraag/{aanvraagId}/beoordeling"),
|
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(
|
private static CaseDetailActions BuildOwnedActions(Guid registrationApplicationId) => new(
|
||||||
EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"),
|
EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public sealed record CaseDetailActions(
|
|||||||
ActionLink EditApplicantDetails,
|
ActionLink EditApplicantDetails,
|
||||||
ActionLink RecordAssessment,
|
ActionLink RecordAssessment,
|
||||||
ActionLink? TakeOwnership = null,
|
ActionLink? TakeOwnership = null,
|
||||||
|
ActionLink? TakeOwnershipPreflight = null,
|
||||||
ActionLink? ReleaseOwnership = null);
|
ActionLink? ReleaseOwnership = null);
|
||||||
|
|
||||||
public sealed record AddressResponse(string Street, string Number, string PostalCode, string City)
|
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)
|
public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapPost("/api/worklist/legacy/{aanvraagId:int}/take-ownership", TakeOwnershipAsync);
|
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);
|
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)
|
private static async Task<IResult> ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await handler.HandleAsync(registrationApplicationId, 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
|
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
|
||||||
/// README click-through can reference them directly). REG-2026-0002 is
|
/// README click-through can reference them directly). REG-2026-0002 is
|
||||||
/// seeded with an open case-framework task on purpose, so a later closure
|
/// 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>
|
/// </summary>
|
||||||
internal static class OwnedApplicationSeeder
|
internal static class OwnedApplicationSeeder
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,41 +28,13 @@ public sealed class TakeOwnershipHandler(
|
|||||||
|
|
||||||
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
|
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Step 1: guard against double adoption. Checked first and cheaply,
|
var checkResult = await CheckAsync(aanvraagId, ct);
|
||||||
// before touching legacy or case-framework at all.
|
if (checkResult.Result.Kind != TakeOwnershipResultKind.Success || checkResult.Application is null)
|
||||||
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
|
|
||||||
if (existingOwnedId is not null)
|
|
||||||
{
|
{
|
||||||
return TakeOwnershipResult.AlreadyOwned;
|
return checkResult.Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: read the legacy case (seam A).
|
var application = checkResult.Application;
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Step 4: THEN create the case-framework case - done before the local
|
// Step 4: THEN create the case-framework case - done before the local
|
||||||
// transaction because it's an external system with no distributed
|
// transaction because it's an external system with no distributed
|
||||||
@@ -111,4 +83,66 @@ public sealed class TakeOwnershipHandler(
|
|||||||
|
|
||||||
return TakeOwnershipResult.Success(application.RegistrationApplicationId);
|
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;
|
namespace Architecture.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// passes the smoke script but fails these has demonstrated nothing - the
|
||||||
/// seam boundaries are the point, not an implementation detail.
|
/// seam boundaries are the point, not an implementation detail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -70,7 +70,7 @@ public class ArchitectureTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
|
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
|
// only connection string anywhere under New.* is ConnectionStrings:New
|
||||||
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
|
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
|
||||||
// docker-compose.yml only ever injects ConnectionStrings__New into
|
// docker-compose.yml only ever injects ConnectionStrings__New into
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
Vendored
+20
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+42
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7903
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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()
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<header class="app-header">
|
||||||
|
<h1><a routerLink="/">Behandel portaal</a></h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
@@ -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: '' },
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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>
|
||||||
+136
@@ -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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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>
|
||||||
|
}
|
||||||
+111
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -26,6 +26,25 @@ http {
|
|||||||
proxy_set_header Host $host;
|
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 / {
|
location / {
|
||||||
proxy_pass http://new-frontend:80/;
|
proxy_pass http://new-frontend:80/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
+30
-1
@@ -37,7 +37,7 @@ for line in sys.stdin:
|
|||||||
print(f'{svc}: health={health}'); ok = False
|
print(f'{svc}: health={health}'); ok = False
|
||||||
print('ALL_OK' if ok else 'SOME_FAILED')
|
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
|
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)"
|
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"
|
pass "case-framework not reachable from host"
|
||||||
fi
|
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 =="
|
echo "== Seam A: unified read =="
|
||||||
|
|
||||||
WORKLIST=$(curl -sS "$BASE/api/worklist")
|
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']))")
|
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"
|
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 =="
|
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")
|
TAKE=$(curl -sS -o /tmp/take_1002.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/1002/take-ownership")
|
||||||
|
|||||||
Reference in New Issue
Block a user