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:
@@ -14,20 +14,34 @@ typed client. The FE renders the backend's decisions. Reference data mimicking
|
||||
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
||||
persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`.
|
||||
|
||||
**Monorepo (WP-67):** two Angular projects share one backend + one shared library —
|
||||
`apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal`
|
||||
(Behandelaar backoffice, ADR-0002). Both import `libs/shared` (design system + kernel +
|
||||
generated API client) and `libs/beheer` (the admin/stamdata context, used identically by
|
||||
both). `backend/` is unowned by either — a genuinely shared dependency.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # ng serve (proxies /api → backend) → http://localhost:4200
|
||||
npm test # vitest
|
||||
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
|
||||
npm run build # ng build (must stay green)
|
||||
npm run storybook # component library by atomic layer
|
||||
npm run gen:api # regenerate the typed client from the backend OpenAPI doc
|
||||
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
|
||||
docker compose up # run FE + backend together (Swagger at :5000/swagger)
|
||||
cd backend && dotnet test # backend rule + endpoint tests
|
||||
npm start # ng serve ssp (proxies /api → backend) → http://localhost:4200
|
||||
npm run start:behandelportal # ng serve behandelportal → http://localhost:4201
|
||||
npm test # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer)
|
||||
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
|
||||
npm run build # ng build ssp && ng build behandelportal (must stay green)
|
||||
npm run storybook # ssp's component library by atomic layer
|
||||
npm run storybook:behandelportal # behandelportal's own instance (see "Monorepo" note below)
|
||||
npm run gen:api # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc
|
||||
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
|
||||
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
|
||||
cd backend && dotnet test # backend rule + endpoint tests
|
||||
```
|
||||
|
||||
**Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own
|
||||
`auth` context at the same `@auth/*` alias pointing at different physical directories — a single
|
||||
merged tsconfig can't resolve both at once, so `.storybook-ssp/` and `.storybook-behandelportal/`
|
||||
are separate config dirs (`npm run storybook[:behandelportal]` / `build-storybook[:behandelportal]`),
|
||||
each globbing its own app's stories + both shared libraries'.
|
||||
|
||||
**Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs
|
||||
Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend
|
||||
format+test, api-client drift), so a red build is caught locally. Two ways to make it
|
||||
@@ -65,9 +79,17 @@ session protocol is the worked example of this in practice.
|
||||
|
||||
### 1. DDD: contexts then layers, dependencies point inward
|
||||
|
||||
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
|
||||
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
|
||||
page, not a feature; **sanctioned** to read every context — nothing imports it).
|
||||
`apps/<app>/src/app/<context>/<layer>/` for an app-local context; `libs/<lib>/src/<layer>/`
|
||||
for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service —
|
||||
contexts `auth`, `registratie`, `herregistratie`, `brief` (letter-composition teaching
|
||||
slice), `showcase` (teaching page, not a feature; **sanctioned** to read every context in
|
||||
its own app — nothing imports it)) and `apps/behandelportal` (Behandelaar backoffice,
|
||||
ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the
|
||||
design system + kernel + generated API client — no business logic) and `libs/beheer` (the
|
||||
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
|
||||
duplicate copy back into one). `auth` is deliberately **not** shared even though today it's
|
||||
near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different
|
||||
`Principal` variants with different login flows; the two copies are expected to diverge.
|
||||
|
||||
| Layer | Job | Angular allowed? |
|
||||
| ----------------- | ----------------------------------------- | -------------------------------- |
|
||||
@@ -77,17 +99,28 @@ page, not a feature; **sanctioned** to read every context — nothing imports it
|
||||
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
||||
| `ui/` | how it looks (components, pages) | yes |
|
||||
|
||||
**Dependencies only point inward**: `ui → application → domain`; everyone may use
|
||||
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly
|
||||
(reach data through an application store/command) — lint-enforced. Cross-context only
|
||||
`herregistratie → registratie → shared`, `auth → shared`, `brief → shared`. Imports use
|
||||
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/*
|
||||
@brief/*`. `domain/` imports nothing from Angular.
|
||||
**Dependencies only point inward**: `ui → application → domain`; every context in either
|
||||
app may use `libs/shared` and `libs/beheer`; never the reverse (`libs/shared` may not
|
||||
depend on `libs/beheer` either — it stays the base). `ui`/`layout` never import
|
||||
`infrastructure` directly (reach data through an application store/command) —
|
||||
lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
|
||||
`.dependency-cruiser.base.js` + one thin `.dependency-cruiser.<app>.js` per app). An app
|
||||
may not import the other app's source directly. Cross-context only
|
||||
`herregistratie → registratie → libs/shared|beheer`, `auth → libs/shared|beheer`,
|
||||
`brief → libs/shared|beheer` (ssp); `behandeling → libs/shared|beheer`, `auth →
|
||||
libs/shared|beheer` (behandelportal). Imports use aliases as direction statements:
|
||||
`@shared/* @beheer/* @auth/* @registratie/* @herregistratie/* @brief/*` (ssp) —
|
||||
`@shared/* @beheer/* @auth/* @behandeling/*` (behandelportal); each app's own
|
||||
`tsconfig.json` declares its full map (the root `tsconfig.json` intentionally has no
|
||||
`paths` — see its comment). `domain/` imports nothing from Angular.
|
||||
|
||||
### 2. Atomic design: folder = layer
|
||||
|
||||
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`,
|
||||
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page
|
||||
`libs/shared/ui` atoms → molecules → organisms; `libs/shared/layout` templates (`shell`,
|
||||
`page-shell`); each app's own context `ui/` pages. Each level only uses levels below,
|
||||
and a shared component takes nav/copy as `input()`s or an injection token (e.g.
|
||||
`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`, `DEBUG_PANEL` in `shell.component.ts`) rather
|
||||
than hardcoding one app's content — the two apps' primary nav genuinely differs. A new page
|
||||
should be **composition of existing blocks** — adding building blocks is the
|
||||
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
|
||||
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
|
||||
@@ -97,11 +130,11 @@ small hand-rolled surface built from the token bridge; see ADR-0003.)
|
||||
### 3. State: make illegal states unrepresentable
|
||||
|
||||
Default reflex — **if you're about to add a second/third boolean to track state,
|
||||
model a discriminated union instead.** Three tools, all in `shared/application`:
|
||||
model a discriminated union instead.** Three tools, all in `libs/shared/src/application`:
|
||||
|
||||
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
||||
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
||||
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
|
||||
Render it via the `<app-async>` molecule (`libs/shared/src/ui/async`) — one of four
|
||||
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
||||
is delay-gated (~250ms) so fast connections don't flash.
|
||||
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
||||
@@ -116,8 +149,9 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
|
||||
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
||||
composition site.
|
||||
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
||||
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
|
||||
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
|
||||
branded type only via a parser returning `Result` (ssp's
|
||||
`registratie/domain/value-objects/`: `Postcode`, `Uren`, `BigNummer`). Once you hold
|
||||
the type, never re-check it.
|
||||
|
||||
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are
|
||||
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
|
||||
@@ -158,12 +192,15 @@ fails CI, never prod) — never runtime-editable. Org-templates are the delibera
|
||||
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
||||
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
||||
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
|
||||
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
|
||||
**Story titles mirror the sidebar's Design System/Domein split** (see
|
||||
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
|
||||
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
|
||||
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
|
||||
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
||||
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests —
|
||||
each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-behandelportal/`,
|
||||
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
|
||||
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
|
||||
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
|
||||
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
|
||||
a component in an app context's `ui/` is titled `Domein/<Context>/<Name>` — full stop,
|
||||
regardless of which atomic layer it is (a context organism doesn't get its own
|
||||
`Organisms/` bucket).
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -181,7 +218,7 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
||||
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
|
||||
translation file, not a code change (the seam). Shared/English components must **not**
|
||||
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
|
||||
supplies the text (see `shared/ui/async`). Format-validation messages in
|
||||
supplies the text (see `libs/shared/src/ui/async`). Format-validation messages in
|
||||
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
|
||||
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
|
||||
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
||||
@@ -190,12 +227,13 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
||||
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
|
||||
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
|
||||
(a domain function, a `$localize` string) uses the one hand-written
|
||||
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
|
||||
`formatDatumNl` (`libs/shared/src/kernel/datum.ts`). Never a third hand-rolled
|
||||
`toLocaleDateString` call.
|
||||
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
||||
[authGuard]` on protected routes (`app.routes.ts`).
|
||||
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent (`libs/shared`),
|
||||
`canActivate: [authGuard]` on protected routes (each app's own `app.routes.ts`).
|
||||
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
||||
`public/cibg-huisstijl/` and loaded via a `<link>` in `index.html`; `src/styles.scss` holds a
|
||||
`public/cibg-huisstijl/` and loaded via a `<link>` in each app's `index.html`;
|
||||
`libs/shared/styles.scss` (one copy, both apps' `angular.json` point at it — WP-67) holds a
|
||||
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
|
||||
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
|
||||
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
|
||||
@@ -209,18 +247,22 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
||||
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
|
||||
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
||||
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
||||
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
|
||||
on `any` and on illegal imports — `domain/` importing Angular, or a context importing
|
||||
"upward" (the `herregistratie → registratie → shared`, `auth → shared` direction).
|
||||
CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend
|
||||
`dotnet test`, and an API-client drift check.
|
||||
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to
|
||||
`{apps,libs}/**`) fails the build on `any`; `npm run dep:check`
|
||||
(`.dependency-cruiser.base.js` + one `.dependency-cruiser.<app>.js` per app, WP-67) fails
|
||||
on illegal imports — `domain/` importing Angular, a context importing "upward" (the
|
||||
`herregistratie → registratie → shared`, `auth → shared` direction), an app importing the
|
||||
other app's source, or `libs/shared` depending on `libs/beheer`. CI
|
||||
(`.github/workflows/ci.yml`) runs lint + `dep:check` + `check:tokens` + test (both apps +
|
||||
both libraries) + build (both apps), backend `dotnet test`, and an API-client drift check
|
||||
(one generated client, `libs/shared/src/infrastructure/api-client.ts`).
|
||||
|
||||
## Adding a feature (recipe)
|
||||
|
||||
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
|
||||
`httpResource` or command returning `Result`) → application (store if shared state;
|
||||
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
|
||||
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
|
||||
union + pure reduce) → UI last (compose `libs/shared/ui` atoms, wrap async in
|
||||
`<app-async>`, dispatch messages). Worked example: the SSP's intake wizard (`herregistratie/`).
|
||||
|
||||
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
|
||||
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
|
||||
|
||||
Reference in New Issue
Block a user