feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
+59 -43
View File
@@ -16,24 +16,33 @@ re-registration — "herregistratie").
---
## 1. The big picture: six "contexts", five "layers"
## 1. The big picture: two apps, cross-app libraries, "contexts", "layers"
The code is split first by **business area** (a "bounded context" in DDD terms),
then inside each area by **layer**.
Two Angular projects share one backend and two cross-app libraries (WP-67 — see
ADR-0002's amendment for why this is a monorepo, not two repos). Inside each app (and
each library), the code is split first by **business area** (a "bounded context" in DDD
terms), then inside each area by **layer**.
```
src/app/
shared/ things every context reuses (no business logic of its own)
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every context)
apps/
ssp/src/app/ Zorgverlener self-service (ADR-0002) — this doc's main subject
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every ssp context)
behandelportal/src/app/ Behandelaar backoffice (ADR-0002) — a sibling app, not covered here
auth/ its own login (employee SSO, not DigiD/BSN)
behandeling/ werkvoorraad, beoordeling (WP-64/65)
libs/
shared/src/ things every app reuses (no business logic of its own)
beheer/src/ admin/stamdata — a real bounded context, used identically by both apps
```
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
showing multiple contexts side by side, so it may import any context. Nothing imports
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
`showcase/` is a **sanctioned exception** to the direction rules, scoped to `apps/ssp`:
its whole point is showing multiple ssp contexts side by side, so it may import any of
them. Nothing imports `showcase`. (Enforced per-app in `.dependency-cruiser.<app>.js`;
same precedent as the `debug-state` panel — see below.)
### The atomic-design hierarchy, visualised
@@ -70,25 +79,32 @@ questions:
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
application, application may use domain, everyone may use `shared`. Never the
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced).
application, application may use domain, everyone (in either app) may use `libs/shared`
and `libs/beheer`. Never the other way around — `libs/shared` may not depend on
`libs/beheer` either (it stays the base), and an app may not import the other app's
source. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced,
per app — a single merged tsconfig can't resolve both apps' `@auth/*` alias at once, so
each app is cruised separately against its own `.dependency-cruiser.<app>.js`).
The `domain/` layer imports nothing from Angular, so the business rules are plain
functions you can read and test in isolation.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
`brief → shared` (`showcase` may read every context; see above).
Allowed direction (within `apps/ssp`): `herregistratie → registratie → libs/shared|beheer`,
`auth → libs/shared|beheer`, `brief → libs/shared|beheer` (`showcase` may read every ssp
context; see above). `apps/behandelportal` has its own analogous rule for `behandeling`/`auth`.
### Why the `shared/` kernel is split too
### Why `libs/shared` is split into layers too
- `shared/kernel/` — tiny generic helpers (no Angular).
- `shared/application/` — generic state tools (RemoteData, the store).
- `shared/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
- `shared/layout/` — page chrome (header, footer, shells).
- `shared/infrastructure/` — the demo HTTP interceptor.
- `libs/shared/src/kernel/` — tiny generic helpers (no Angular).
- `libs/shared/src/application/` — generic state tools (RemoteData, the store).
- `libs/shared/src/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
- `libs/shared/src/layout/` — page chrome (header, footer, shells) — takes each app's own nav/copy via `input()`s or an injection token rather than hardcoding one app's content.
- `libs/shared/src/infrastructure/` — the demo HTTP interceptor + the one generated API client both apps import.
Imports use path aliases so they read as direction statements:
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
`@shared/*`, `@beheer/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`
(ssp) — `@shared/*`, `@beheer/*`, `@auth/*`, `@behandeling/*` (behandelportal); each
app's own `tsconfig.json` declares its full alias map.
---
@@ -150,7 +166,7 @@ Three signals = eight combinations, and most are nonsense (loading **and** has
data **and** has an error?). You end up writing defensive `if`s everywhere.
Instead we use **one** value that is _exactly one of_ four shapes
(`shared/application/remote-data.ts`):
(`libs/shared/src/application/remote-data.ts`):
```ts
type RemoteData<E, T> =
@@ -166,7 +182,7 @@ Notice the data lives _on_ the shape — you literally cannot read `.value` unle
you're in the `Success` case, so "loaded but no data" can't be written down.
To use it, you handle every case once. The `<app-async>` component
(`shared/ui/async/async.component.ts`) does this for you: you give it a
(`libs/shared/src/ui/async/async.component.ts`) does this for you: you give it a
`RemoteData` (or a raw `httpResource`) and four templates, and it shows exactly
one. There's also `foldRemote(rd, { loading, empty, failure, success })` for
doing the same in TypeScript — the compiler makes you cover all four.
@@ -277,7 +293,7 @@ function reduce(state, msg) {
```
The component (`herregistratie-wizard.component.ts`) wires it to a signal with
the tiny helper in `shared/application/store.ts`:
the tiny helper in `libs/shared/src/application/store.ts`:
```ts
private store = createStore(initial, reduce);
@@ -341,7 +357,7 @@ A common assumption is "the form saves on blur." It doesn't. **Blur only marks a
_touched_** so validation can show; it never writes the value or hits the network. In the
shared atoms, `(blur)="onTouched()"` is the `ControlValueAccessor` touched callback and
nothing more; the value is pushed on `(input)`, every keystroke
([`text-input.component.ts`](../../../src/app/shared/ui/text-input/text-input.component.ts):
([`text-input.component.ts`](../../../libs/shared/src/ui/text-input/text-input.component.ts):
`(input)` L29 → `onChange` L62, vs `(blur)="onTouched()"` L30).
The real flow has two stages, neither keyed on focus:
@@ -349,20 +365,20 @@ The real flow has two stages, neither keyed on focus:
1. **Keystroke → Model.** A field binds `(ngModelChange)`/`(input)` and dispatches
`{ tag: 'SetField', key, value }`. The pure reducer stores it immediately — so the
Model is always current, on every keystroke, while editing.
([`herregistratie-wizard.component.ts`](../../../src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
L78 → [`herregistratie.machine.ts`](../../../src/app/herregistratie/domain/herregistratie.machine.ts)
([`herregistratie-wizard.component.ts`](../../../apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
L78 → [`herregistratie.machine.ts`](../../../apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts)
L138-142, `setField`.)
2. **Model → backend (600 ms debounce).** A signal `effect` tracks the machine
`snapshot()`; each change resets a 600 ms timer whose callback does I/O **only** (it
never dispatches, so it can't livelock the store). On the first save it lazily creates
the application and stamps `?aanvraag=<id>` into the URL, so a reload resumes the draft.
([`draft-sync.ts`](../../../src/app/registratie/application/draft-sync.ts):
([`draft-sync.ts`](../../../apps/ssp/src/app/registratie/application/draft-sync.ts):
`DEBOUNCE_MS` L34, `effect` L102-108, `flush` L88-98 → `ApplicationsAdapter.syncDraft`.)
The **brief** context uses the same 600 ms idiom in its own store: `edit()` applies the
edit optimistically in the reducer and records an undo step, then `scheduleSave()`
`flushSave()` flips a `saveState` (Saving/Saved/Error) and calls `adapter.save`
([`brief.store.ts`](../../../src/app/brief/application/brief.store.ts) L157-166, L192-209).
([`brief.store.ts`](../../../apps/ssp/src/app/brief/application/brief.store.ts) L157-166, L192-209).
So it _feels_ like save-on-blur only because you usually stop typing when you leave a
field, and the debounce fires ~600 ms later. The trigger is **"stopped changing," not
@@ -370,7 +386,7 @@ field, and the debounce fires ~600 ms later. The trigger is **"stopped changing,
**The last-mile guard (leaving mid-debounce).** A debounce means an edit made in the final
&lt;600 ms before you leave hasn't been written yet. Two seams close that window
([`pending-saves.ts`](../../../src/app/shared/application/pending-saves.ts)): every autosave
([`pending-saves.ts`](../../../libs/shared/src/application/pending-saves.ts)): every autosave
owner (the brief/org-template root stores and each wizard's `draft-sync`) registers in a
`PendingSaves` registry, and
@@ -418,7 +434,7 @@ baked into the type. Same idea for `Uren` and `BigNummer`.
3. **Application.** If there's state to coordinate, add/extend a store
(`providedIn: 'root'` if it must be shared across pages). Model state as a
discriminated union; change it only through a pure `update`/`reduce`.
4. **UI last.** Build the page/organism from `shared/ui` atoms. Render async
4. **UI last.** Build the page/organism from `libs/shared/ui` atoms. Render async
state through `<app-async>`. Send messages; don't mutate.
If you're tempted to add a third boolean to track state — stop and model it as a
@@ -527,7 +543,7 @@ Practical notes, kept lazy:
The sketch above is the _rationale_; the shipped shape has since firmed up. The contract is
no longer hand-written DTOs — it's an **NSwag-generated typed client**
([`api-client.ts`](../../../src/app/shared/infrastructure/api-client.ts), regenerate with
([`api-client.ts`](../../../libs/shared/src/infrastructure/api-client.ts), regenerate with
`npm run gen:api` per [`nswag.json`](../../../nswag.json)) — and the boundary is a
`parse*` returning `Result` rather than `httpResource({ parse })`. End to end:
@@ -541,25 +557,25 @@ no longer hand-written DTOs — it's an **NSwag-generated typed client**
`HttpClient` by `httpClientFetch` — the one place cross-cutting concerns live:
`X-Correlation-Id` on every call, `Idempotency-Key` on non-GETs, a 10 s timeout, and
GET-only retry. Routing through `HttpClient` is exactly what lets the interceptors see API
traffic. ([`api-client.provider.ts`](../../../src/app/shared/infrastructure/api-client.provider.ts):
traffic. ([`api-client.provider.ts`](../../../libs/shared/src/infrastructure/api-client.provider.ts):
`httpClientFetch` L47-82, `provideApiClient` L86-92; registered in
[`app.config.ts`](../../../src/app/app.config.ts) L37.)
[`app.config.ts`](../../../apps/ssp/src/app/app.config.ts) L37.)
- **Interceptors (dev-only, stripped in prod).** `scenario.interceptor.ts` (the `?scenario=`
toggle) and `role.interceptor.ts` (`X-Role` on role-aware endpoints).
**A read (dashboard):** `<app-async [data]="store.profile()">`
[`BigProfileStore`](../../../src/app/registratie/application/big-profile.store.ts) →
[`BigProfileStore`](../../../apps/ssp/src/app/registratie/application/big-profile.store.ts) →
`DashboardViewAdapter.dashboardViewResource()` = `resource({ loader: () =>
client.dashboardView() })`
([`dashboard-view.adapter.ts`](../../../src/app/registratie/infrastructure/dashboard-view.adapter.ts))
([`dashboard-view.adapter.ts`](../../../apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts))
→ GET `/api/v1/dashboard-view``httpClientFetch` → proxy → backend → back through the
`parseDashboardView(json): Result` trust boundary → `RemoteData<DashboardView>` → rendered.
**A write (change address):** `runIfSubmitting()` (§2d) → `createSubmitChangeRequest`
([`submit-change-request.ts`](../../../src/app/registratie/application/submit-change-request.ts))
([`submit-change-request.ts`](../../../apps/ssp/src/app/registratie/application/submit-change-request.ts))
`runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807
ProblemDetails → string ([`submit.ts`](../../../src/app/shared/application/submit.ts)) →
[`change-request.adapter.ts`](../../../src/app/registratie/infrastructure/change-request.adapter.ts)
ProblemDetails → string ([`submit.ts`](../../../libs/shared/src/application/submit.ts)) →
[`change-request.adapter.ts`](../../../apps/ssp/src/app/registratie/infrastructure/change-request.adapter.ts)
→ POST `/api/v1/change-requests``ok(referentie)` / `err(detail)` → dispatch
`SubmitConfirmed` / `SubmitFailed`.