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:
@@ -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
|
||||
<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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user