Compare commits
18
Commits
a6a1abbe9c
...
ddec15ccb2
| 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
|
||||
```
|
||||
|
||||
Then open **http://localhost:8080**. That's the only host port published —
|
||||
`legacy-backend` and `case-framework` are deliberately unreachable from the
|
||||
host (see §4 below).
|
||||
Then open **http://localhost:8080** (Session 1's placeholder UI) or
|
||||
**http://localhost:8080/portal** (Session 2's Angular portal) — both are
|
||||
reachable side by side through the same proxy, for comparison. That's the
|
||||
only host port published — `legacy-backend` and `case-framework` are
|
||||
deliberately unreachable from the host (only `proxy` publishes a port — see
|
||||
`docker-compose.yml`).
|
||||
|
||||
**Memory:** SQL Server (`legacy-db`) needs roughly 2GB of RAM; budget ~6GB
|
||||
total for Docker/Podman. First start takes a minute or two while SQL Server
|
||||
@@ -28,13 +31,65 @@ Verify everything end to end:
|
||||
Run this against a **freshly started** stack — it depends on the untouched
|
||||
seed data (legacy ids 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,
|
||||
three databases, one proxy. `new-frontend` in this session is a deliberately
|
||||
plain, unstyled HTML page (`new-frontend/index.html`) that exercises the same
|
||||
API a real UI would — it exists to prove the backend before a real Angular
|
||||
portal replaces it in Session 2, not to be a good UI.
|
||||
```mermaid
|
||||
flowchart TB
|
||||
browser(["Browser"])
|
||||
browser -->|":8080 · the only published port"| proxy["proxy · nginx"]
|
||||
|
||||
subgraph new_g["NEW — the replacement"]
|
||||
direction TB
|
||||
portal["portal-frontend<br/>Angular"]
|
||||
placeholder["new-frontend<br/>plain HTML"]
|
||||
newapi["new-backend<br/>.NET"]
|
||||
newdb[("new-db<br/>Postgres 16")]
|
||||
newapi --> newdb
|
||||
end
|
||||
|
||||
subgraph legacy_g["LEGACY — being strangled"]
|
||||
direction TB
|
||||
legweb["legacy-frontend<br/>Razor Pages"]
|
||||
legapi["legacy-backend<br/>.NET"]
|
||||
legdb[("legacy-db<br/>SQL Server 2022")]
|
||||
legweb --> legapi --> legdb
|
||||
end
|
||||
|
||||
subgraph cf_g["VENDOR — stays put"]
|
||||
direction TB
|
||||
cf["case-framework<br/>.NET"]
|
||||
cfdb[("case-db<br/>Postgres 16")]
|
||||
cf --> cfdb
|
||||
end
|
||||
|
||||
proxy -->|"/portal/"| portal
|
||||
proxy -->|"/"| placeholder
|
||||
proxy -->|"/api/"| newapi
|
||||
proxy -->|"/legacy"| legweb
|
||||
|
||||
newapi ==>|"seams A + B"| legapi
|
||||
newapi ==>|"seam D"| cf
|
||||
```
|
||||
|
||||
Ten containers; only the proxy publishes a port. The two thick edges are the
|
||||
seams — everything the new system knows about the old one crosses one of them.
|
||||
See [`docs/architecture.md`](docs/architecture.md) for how each seam works, and
|
||||
[`docs/playbook.md`](docs/playbook.md) for applying the pattern to a real system.
|
||||
|
||||
**Session 1 — backend.** Ten containers: three frontends, three backends,
|
||||
three databases, one proxy. `new-frontend` (reachable at `/`) is a
|
||||
deliberately plain, unstyled HTML page (`new-frontend/index.html`) that
|
||||
exercises the same API a real UI would — it exists to prove the backend, not
|
||||
to be a good UI.
|
||||
|
||||
**Session 2 — Angular portal.** `portal-frontend` (reachable at `/portal`) is
|
||||
a real Angular application over the same API, kept alongside `new-frontend`
|
||||
rather than replacing it, so the two can be compared side by side. It covers
|
||||
full functional parity with the placeholder: the worklist, case detail, and
|
||||
all four actions (edit applicant details, record assessment, take/release
|
||||
ownership) — driven entirely off the API's own `actions`/`seams` blocks in
|
||||
each response, never a hardcoded URL. See
|
||||
[`portal-frontend/`](portal-frontend) for the app itself.
|
||||
|
||||
## The seams and write paths
|
||||
|
||||
@@ -65,7 +120,8 @@ cd new && dotnet test tests/Architecture.Tests
|
||||
`legacy-db` is SQL Server 2022; `new-db` and `case-db` are PostgreSQL 16.
|
||||
This isn't decoration — a single shared engine would let an implementer
|
||||
quietly join across schemas or share a `DbContext`, and the seam would
|
||||
evaporate. Two engines force the read ACL to be a real HTTP call (§7.2),
|
||||
evaporate. Two engines force the read ACL to be a real HTTP call
|
||||
([seam A](docs/architecture.md#the-four-seams)),
|
||||
force the take-ownership step ordering in `TakeOwnershipHandler` to be a real
|
||||
constraint rather than a stylistic choice (no distributed transaction is
|
||||
available across them), and make the legacy type vocabulary
|
||||
@@ -100,7 +156,11 @@ work for `LegacyAanvraagMapper` instead of a copy-paste.
|
||||
|
||||
## 10-minute click-through
|
||||
|
||||
1. **Werkvoorraad** — `GET /api/worklist` (or the placeholder page at `/`):
|
||||
Every step below works identically through the raw API, the Session 1
|
||||
placeholder at `/`, or the Session 2 Angular portal at `/portal` — they're
|
||||
three windows onto the same backend.
|
||||
|
||||
1. **Werkvoorraad** — `GET /api/worklist` (or either frontend's root page):
|
||||
17 cases from two databases in one list. Filter `?origin=Legacy` /
|
||||
`?origin=Owned` to see which is which.
|
||||
2. **`A-1001`** (`GET /api/worklist/legacy/1001`) — all three write paths
|
||||
|
||||
@@ -83,6 +83,9 @@ services:
|
||||
new-frontend:
|
||||
build: ./new-frontend
|
||||
|
||||
portal-frontend:
|
||||
build: ./portal-frontend
|
||||
|
||||
proxy:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
@@ -91,5 +94,6 @@ services:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- new-frontend
|
||||
- portal-frontend
|
||||
- new-backend
|
||||
- legacy-frontend
|
||||
|
||||
@@ -7,7 +7,8 @@ Accepted.
|
||||
`case-framework` (seam D, a stand-in for a maintained vendor case-management
|
||||
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
|
||||
while any task on the case is still open. That rule belongs to the framework
|
||||
and is not ours to change — it is a conformist integration by design (§6).
|
||||
and is not ours to change — it is a conformist integration by design
|
||||
(seam D, `New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs`).
|
||||
|
||||
The new domain's own rule is different: once an assessment (approve/reject) is
|
||||
recorded on a `RegistrationApplication`, that decision is legally in effect
|
||||
|
||||
@@ -7,7 +7,7 @@ Accepted.
|
||||
Seam B lets a user edit a **legacy-owned** case's applicant details (name,
|
||||
address, contact) from the new portal, without the new system taking
|
||||
ownership of that case. The legacy system remains the authority on this data
|
||||
until ownership is explicitly taken (§7.5).
|
||||
until ownership is explicitly taken ([ADR-003](ADR-003-ownership-is-taken-per-case.md)).
|
||||
|
||||
It is tempting, once a translation layer exists between the portal's request
|
||||
shape and legacy's `PUT /api/aanvragen/{id}/gegevens` shape, to also smuggle
|
||||
@@ -28,13 +28,14 @@ derived values, no defaulting. It only:
|
||||
(logged as a warning, never dropped or guessed at).
|
||||
|
||||
If a rule needs to be enforced on this data from the new portal, that is a
|
||||
signal the capability should be taken into ownership instead (§7.5), not
|
||||
signal the capability should be taken into ownership instead, not
|
||||
patched into the translator.
|
||||
|
||||
## Consequences
|
||||
- The portal cannot offer a better validation experience than legacy already
|
||||
has for this seam — by design. The `Gevalideerd door het legacy systeem`
|
||||
notice on the write-through form (§8.3) exists specifically so the user
|
||||
notice on the write-through form (`portal-frontend/src/app/case-detail/edit-applicant-details/`)
|
||||
exists specifically so the user
|
||||
knows why: this is the honest version of a seamless UI, not a limitation to
|
||||
hide.
|
||||
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
|
||||
|
||||
@@ -22,7 +22,7 @@ their keep.
|
||||
|
||||
## Decision
|
||||
Ship now with ownership taken **one legacy case at a time**, via
|
||||
`POST /api/worklist/legacy/{aanvraagId}/take-ownership` (§7.5), triggered by
|
||||
`POST /api/worklist/legacy/{aanvraagId}/take-ownership`, triggered by
|
||||
an explicit user action in the portal. This is the interim mechanism, not the
|
||||
final one for every process.
|
||||
|
||||
@@ -43,11 +43,12 @@ This is deliberately the right building block either way:
|
||||
- Until bulk tooling exists, full legacy retirement for a process happens
|
||||
case-by-case, which is slower than a scheduled cutover — accepted as the
|
||||
cost of shipping the seam mechanics now rather than waiting.
|
||||
- Reversal (§7.6) stays per-case and gated on `domain_writes_since` for the
|
||||
- Reversal (`ReleaseOwnershipHandler`) stays per-case and gated on `domain_writes_since` for the
|
||||
same reason a bulk reversal would be unsafe absent a sync
|
||||
(`docs/sync-not-implemented.md`): undoing adoption after edits would
|
||||
silently discard them.
|
||||
- This demo's non-goals (§3) exclude building the bulk migration tool itself
|
||||
- This demo's non-goals (README, "Deliberate substitutions and omissions")
|
||||
exclude building the bulk migration tool itself
|
||||
— that's future work, not a rejected idea.
|
||||
|
||||
## Future work
|
||||
|
||||
@@ -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.
|
||||
2. **Ownership release is blocked once edits exist.** `DELETE
|
||||
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since
|
||||
> 0` (§7.6) — releasing would silently discard those edits, since there is
|
||||
> 0` (`ReleaseOwnershipHandler`) — releasing would silently discard those
|
||||
edits, since there is
|
||||
no sync to have propagated them back to legacy first. The `409` is the cost
|
||||
of the missing sync made visible, rather than a data-loss bug made invisible.
|
||||
|
||||
@@ -45,7 +45,8 @@ internal static class CaseDetailResponseFactory
|
||||
private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new(
|
||||
EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"),
|
||||
RecordAssessment: new ActionLink("redirect", $"/legacy/aanvraag/{aanvraagId}/beoordeling"),
|
||||
TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership"));
|
||||
TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership"),
|
||||
TakeOwnershipPreflight: new ActionLink("query", $"/api/worklist/legacy/{aanvraagId}/take-ownership/preflight"));
|
||||
|
||||
private static CaseDetailActions BuildOwnedActions(Guid registrationApplicationId) => new(
|
||||
EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"),
|
||||
|
||||
@@ -39,6 +39,7 @@ public sealed record CaseDetailActions(
|
||||
ActionLink EditApplicantDetails,
|
||||
ActionLink RecordAssessment,
|
||||
ActionLink? TakeOwnership = null,
|
||||
ActionLink? TakeOwnershipPreflight = null,
|
||||
ActionLink? ReleaseOwnership = null);
|
||||
|
||||
public sealed record AddressResponse(string Street, string Number, string PostalCode, string City)
|
||||
|
||||
@@ -8,6 +8,7 @@ public static class OwnershipEndpoints
|
||||
public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/worklist/legacy/{aanvraagId:int}/take-ownership", TakeOwnershipAsync);
|
||||
app.MapGet("/api/worklist/legacy/{aanvraagId:int}/take-ownership/preflight", PreflightTakeOwnershipAsync);
|
||||
app.MapDelete("/api/worklist/owned/{registrationApplicationId:guid}/ownership", ReleaseOwnershipAsync);
|
||||
}
|
||||
|
||||
@@ -27,6 +28,23 @@ public static class OwnershipEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
// Read-only "would this succeed" check - same result-kind switch as
|
||||
// TakeOwnershipAsync, except Success reports intent rather than creation
|
||||
// (200, not 201; nothing was written).
|
||||
private static async Task<IResult> PreflightTakeOwnershipAsync(int aanvraagId, TakeOwnershipHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.PreflightAsync(aanvraagId, ct);
|
||||
|
||||
return result.Kind switch
|
||||
{
|
||||
TakeOwnershipResultKind.Success => Results.Ok(new { wouldSucceed = true }),
|
||||
TakeOwnershipResultKind.AlreadyOwned => Results.Conflict(new MessageResponse("This aanvraag has already been taken into ownership.")),
|
||||
TakeOwnershipResultKind.LegacyCaseNotFound => Results.NotFound(),
|
||||
TakeOwnershipResultKind.MappingFailed => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)),
|
||||
_ => Results.Problem(statusCode: 500),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct)
|
||||
{
|
||||
var result = await handler.HandleAsync(registrationApplicationId, ct);
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace New.Api.Seeding;
|
||||
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
|
||||
/// README click-through can reference them directly). REG-2026-0002 is
|
||||
/// seeded with an open case-framework task on purpose, so a later closure
|
||||
/// request against it demonstrates the §6 conflict (409, decision stands).
|
||||
/// request against it demonstrates the seam-D conflict (409, decision stands
|
||||
/// - see docs/adr/ADR-001-decision-independent-of-closure.md).
|
||||
/// </summary>
|
||||
internal static class OwnedApplicationSeeder
|
||||
{
|
||||
|
||||
@@ -28,41 +28,13 @@ public sealed class TakeOwnershipHandler(
|
||||
|
||||
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
// Step 1: guard against double adoption. Checked first and cheaply,
|
||||
// before touching legacy or case-framework at all.
|
||||
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
|
||||
if (existingOwnedId is not null)
|
||||
var checkResult = await CheckAsync(aanvraagId, ct);
|
||||
if (checkResult.Result.Kind != TakeOwnershipResultKind.Success || checkResult.Application is null)
|
||||
{
|
||||
return TakeOwnershipResult.AlreadyOwned;
|
||||
return checkResult.Result;
|
||||
}
|
||||
|
||||
// Step 2: read the legacy case (seam A).
|
||||
// Step 3: map it to a RegistrationApplication. The mapper calls the
|
||||
// domain's normal validating constructors/factories, so any domain
|
||||
// exception here means the legacy data doesn't satisfy an invariant
|
||||
// the owned side requires. That must fail as a 422 naming the
|
||||
// failing invariant, and - critically - NOTHING is written anywhere:
|
||||
// no case-framework call, no persistence. FetchAndMapAsync lets the
|
||||
// domain exception surface as a thrown DomainInvariantViolationException,
|
||||
// which we catch here and translate, rather than swallowing it inside
|
||||
// the gateway - that keeps "nothing written on failure" trivially true,
|
||||
// since we simply haven't called anything else yet.
|
||||
LegacyFetchAndMapResult fetchResult;
|
||||
try
|
||||
{
|
||||
fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct);
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message);
|
||||
}
|
||||
|
||||
if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null)
|
||||
{
|
||||
return TakeOwnershipResult.LegacyCaseNotFound;
|
||||
}
|
||||
|
||||
var application = fetchResult.Application;
|
||||
var application = checkResult.Application;
|
||||
|
||||
// Step 4: THEN create the case-framework case - done before the local
|
||||
// transaction because it's an external system with no distributed
|
||||
@@ -111,4 +83,66 @@ public sealed class TakeOwnershipHandler(
|
||||
|
||||
return TakeOwnershipResult.Success(application.RegistrationApplicationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only "would this succeed" check: runs the same dedupe-lookup and
|
||||
/// fetch-and-map steps HandleAsync would, then stops - it never reaches
|
||||
/// case-framework, persistence, or the legacy flag flip. A `Success`
|
||||
/// result here has a null RegistrationApplicationId, since nothing was
|
||||
/// actually created. Used by the take-ownership/preflight endpoint so a
|
||||
/// caller can compare new-vs-legacy behaviour before committing to the
|
||||
/// real cutover.
|
||||
/// </summary>
|
||||
public async Task<TakeOwnershipResult> PreflightAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
var checkResult = await CheckAsync(aanvraagId, ct);
|
||||
return checkResult.Result;
|
||||
}
|
||||
|
||||
// Steps 1-3 of HandleAsync, factored out so PreflightAsync can run the
|
||||
// exact same side-effect-free checks without duplicating them.
|
||||
private async Task<CheckOutcome> CheckAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
// Step 1: guard against double adoption. Checked first and cheaply,
|
||||
// before touching legacy or case-framework at all.
|
||||
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
|
||||
if (existingOwnedId is not null)
|
||||
{
|
||||
return new CheckOutcome(TakeOwnershipResult.AlreadyOwned, Application: null);
|
||||
}
|
||||
|
||||
// Step 2: read the legacy case (seam A).
|
||||
// Step 3: map it to a RegistrationApplication. The mapper calls the
|
||||
// domain's normal validating constructors/factories, so any domain
|
||||
// exception here means the legacy data doesn't satisfy an invariant
|
||||
// the owned side requires. That must fail as a 422 naming the
|
||||
// failing invariant, and - critically - NOTHING is written anywhere:
|
||||
// no case-framework call, no persistence. FetchAndMapAsync lets the
|
||||
// domain exception surface as a thrown DomainInvariantViolationException,
|
||||
// which we catch here and translate, rather than swallowing it inside
|
||||
// the gateway - that keeps "nothing written on failure" trivially true,
|
||||
// since we simply haven't called anything else yet.
|
||||
LegacyFetchAndMapResult fetchResult;
|
||||
try
|
||||
{
|
||||
fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct);
|
||||
}
|
||||
catch (DomainInvariantViolationException ex)
|
||||
{
|
||||
return new CheckOutcome(TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message), Application: null);
|
||||
}
|
||||
|
||||
if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null)
|
||||
{
|
||||
return new CheckOutcome(TakeOwnershipResult.LegacyCaseNotFound, Application: null);
|
||||
}
|
||||
|
||||
// RegistrationApplicationId stays null here - nothing has been created
|
||||
// yet. HandleAsync reads the id off the Application itself once it
|
||||
// proceeds past this point and actually persists it.
|
||||
var successResult = new TakeOwnershipResult(TakeOwnershipResultKind.Success, RegistrationApplicationId: null);
|
||||
return new CheckOutcome(successResult, fetchResult.Application);
|
||||
}
|
||||
|
||||
private sealed record CheckOutcome(TakeOwnershipResult Result, RegistrationApplication? Application);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ using Xunit;
|
||||
namespace Architecture.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes §10's architecture rules as build-failing assertions. A demo that
|
||||
/// Encodes the design's architecture rules as build-failing assertions. A demo that
|
||||
/// passes the smoke script but fails these has demonstrated nothing - the
|
||||
/// seam boundaries are the point, not an implementation detail.
|
||||
/// </summary>
|
||||
@@ -70,7 +70,7 @@ public class ArchitectureTests
|
||||
[Fact]
|
||||
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
|
||||
{
|
||||
// Config-file concern, not code - see §10. Verified by inspection: the
|
||||
// Config-file concern, not code. Verified by inspection: the
|
||||
// only connection string anywhere under New.* is ConnectionStrings:New
|
||||
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
|
||||
// docker-compose.yml only ever injects ConnectionStrings__New into
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
# Session 2's Angular portal, kept alongside the Session 1 placeholder
|
||||
# at "/" for side-by-side comparison. The trailing slash on both the
|
||||
# location and proxy_pass strips the /portal prefix (same shape as the
|
||||
# "/" -> new-frontend block below); the bare-path redirect below only
|
||||
# exists to catch "/portal" (no trailing slash), which wouldn't match
|
||||
# "location /portal/" and would otherwise silently fall through to "/".
|
||||
# $http_host (not $host) is used explicitly so the Location header keeps
|
||||
# the client-facing port (e.g. :8080) - nginx's own implicit redirect
|
||||
# construction uses its internal `listen` port instead, which silently
|
||||
# drops the port the host actually published this container under.
|
||||
location = /portal {
|
||||
return 301 $scheme://$http_host/portal/;
|
||||
}
|
||||
|
||||
location /portal/ {
|
||||
proxy_pass http://portal-frontend:80/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://new-frontend:80/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+30
-1
@@ -37,7 +37,7 @@ for line in sys.stdin:
|
||||
print(f'{svc}: health={health}'); ok = False
|
||||
print('ALL_OK' if ok else 'SOME_FAILED')
|
||||
")
|
||||
if echo "$STATUS" | grep -q ALL_OK; then pass "all 9 containers running/healthy"; else fail "container status: $STATUS"; fi
|
||||
if echo "$STATUS" | grep -q ALL_OK; then pass "all 10 containers running/healthy"; else fail "container status: $STATUS"; fi
|
||||
|
||||
if curl -sS --max-time 2 http://localhost:8081 >/dev/null 2>&1; then
|
||||
fail "legacy-backend must NOT be reachable from the host (port 8081 responded)"
|
||||
@@ -50,6 +50,20 @@ else
|
||||
pass "case-framework not reachable from host"
|
||||
fi
|
||||
|
||||
echo "== Portal frontend (Session 2) =="
|
||||
|
||||
HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal/")
|
||||
check_status "GET /portal/ serves the Angular app" "200" "$HTTP"
|
||||
HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal/legacy/1001")
|
||||
check_status "deep link /portal/legacy/1001 falls back to index.html" "200" "$HTTP"
|
||||
# Regression check: nginx's implicit redirect construction uses its own
|
||||
# internal `listen` port, not the host's published port - a naive
|
||||
# `return 301 /portal/` silently drops :8080 from the Location header.
|
||||
BARE_REDIRECT_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/portal")
|
||||
check_status "GET /portal (no slash) redirects" "301" "$BARE_REDIRECT_STATUS"
|
||||
FOLLOWED=$(curl -sSL -o /dev/null -w '%{http_code}' "$BASE/portal")
|
||||
check_status "following that redirect lands on a 200, on the same host:port" "200" "$FOLLOWED"
|
||||
|
||||
echo "== Seam A: unified read =="
|
||||
|
||||
WORKLIST=$(curl -sS "$BASE/api/worklist")
|
||||
@@ -78,6 +92,21 @@ ERRORS=$(curl -sS -X PUT "$BASE/api/worklist/legacy/1001/details" \
|
||||
ERROR_COUNT=$(echo "$ERRORS" | json_field "['errors'].__len__()" 2>/dev/null || echo "$ERRORS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['errors']))")
|
||||
check_status "3-field-invalid write-through returns 3 mapped errors" "3" "$ERROR_COUNT"
|
||||
|
||||
echo "== Write path 3 preflight: shadow check before take ownership =="
|
||||
|
||||
PREFLIGHT_1004=$(curl -sS -o /tmp/preflight_1004.json -w '%{http_code}' "$BASE/api/worklist/legacy/1004/take-ownership/preflight")
|
||||
check_status "preflight predicts A-1004 would adopt cleanly" "200" "$PREFLIGHT_1004"
|
||||
WOULD_SUCCEED=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1004.json'))['wouldSucceed'])")
|
||||
check_status "preflight body reports wouldSucceed" "True" "$WOULD_SUCCEED"
|
||||
|
||||
SEAM_1004_AFTER_PREFLIGHT=$(curl -sS "$BASE/api/worklist/legacy/1004" | json_field "['seams']['aanvrager']")
|
||||
check_status "preflight on A-1004 wrote nothing (still legacy-backend)" "legacy-backend" "$SEAM_1004_AFTER_PREFLIGHT"
|
||||
|
||||
PREFLIGHT_1005=$(curl -sS -o /tmp/preflight_1005.json -w '%{http_code}' "$BASE/api/worklist/legacy/1005/take-ownership/preflight")
|
||||
check_status "preflight predicts A-1005 would fail adoption" "422" "$PREFLIGHT_1005"
|
||||
PREFLIGHT_INVARIANT=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1005.json'))['invariant'])")
|
||||
check_status "preflight names the invariant the real call below also fails on" "Bsn.ElevenProof" "$PREFLIGHT_INVARIANT"
|
||||
|
||||
echo "== Write path 3: take ownership =="
|
||||
|
||||
TAKE=$(curl -sS -o /tmp/take_1002.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/1002/take-ownership")
|
||||
|
||||
Reference in New Issue
Block a user