CI / changes (push) Successful in 16s
CI / lint (push) Successful in 3m0s
CI / frontend (push) Failing after 3m36s
CI / backend (push) Successful in 2m38s
CI / e2e (push) Failing after 4m14s
CI / storybook-a11y (push) Failing after 7m50s
CI / semgrep (push) Successful in 1m18s
CI / api-client-drift (push) Successful in 1m55s
`task` with no arguments lists every runnable command. The Taskfile calls the npm scripts, dotnet and docker compose. It does not duplicate their logic. CI does not need `task`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 lines
22 KiB
Markdown
318 lines
22 KiB
Markdown
# CLAUDE.md
|
|
|
|
Agent guide for this repo. The _why_ lives in `docs/reference/architecture/ARCHITECTURE.md`,
|
|
`docs/reference/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide
|
|
`docs/reference/fp-tea-atomic-design.md` (FP + The Elm Architecture + atomic design); this
|
|
file is the _rules_. When a decision below and those docs disagree, the docs win —
|
|
update this file.
|
|
|
|
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
|
view their registration, apply for re-registration). Angular 22, standalone,
|
|
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
|
|
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
|
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/archive/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 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
|
|
task # list every task (a thin facade over the commands above)
|
|
```
|
|
|
|
**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
|
|
automatic: `npm run ci` by hand, or enable the opt-in hook with
|
|
`git config core.hooksPath scripts/githooks` (runs it on `git push`; bypass once with
|
|
`--no-verify`). The e2e + storybook-a11y jobs need a browser/servers — run `--full` for
|
|
storybook-a11y; e2e separately (the script prints how).
|
|
|
|
**Second-locale gate:** `messages.en.xlf` is a hand-maintained translation of every
|
|
`$localize` id; `ng build --localize` fails (via `i18nMissingTranslation: error`) if any id
|
|
lacks an English `<target>`. Add one whenever you add a `$localize` string — `npm run ci`
|
|
catches a miss before CI does.
|
|
|
|
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
|
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
|
|
advisories are pinned via `package.json` `overrides`; the shipped bundle audits clean.
|
|
|
|
## Model routing for agent delegation
|
|
|
|
Three custom agents in `.claude/agents/` pin the model to the step, not the whole session —
|
|
so this doesn't depend on a human remembering to run `/model` at the right moment:
|
|
|
|
- **`planner`** (Opus) — design/approach work: a WP's Decisions block, an ambiguous bug's
|
|
root cause, sequencing a multi-file change. No Edit/Write access; hands back a plan.
|
|
- **`developer`** (Sonnet) — implementation once the approach is settled: routine code
|
|
against a pre-made plan, ending green (`npm run ci`).
|
|
- **`task-runner`** (Haiku) — simple, read-only, mechanical checks: running a test suite,
|
|
`git status`/`grep`, verifying a file exists. No Edit/Write access.
|
|
|
|
Delegate to the matching agent only when the _current_ session isn't already on that
|
|
model — don't add indirection for its own sake. `docs/project/archive/backlog/README.md`'s
|
|
session protocol is the worked example of this in practice.
|
|
|
|
## The decisions (non-negotiable working agreements)
|
|
|
|
### 1. DDD: contexts then layers, dependencies point inward
|
|
|
|
`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`, `overzicht` (the portal home; composes `registratie`'s dashboard sections
|
|
plus its own cross-context nav sections), `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? |
|
|
| ----------------- | ----------------------------------------- | -------------------------------- |
|
|
| `domain/` | business rules + data types | **No — pure TS.** Has `.spec.ts` |
|
|
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
|
|
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
|
|
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
|
| `ui/` | how it looks (components, pages) | yes |
|
|
|
|
**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
|
|
`overzicht → registratie → libs/shared|beheer`, `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/* @overzicht/* @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
|
|
|
|
`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,
|
|
the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
|
|
`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a
|
|
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
|
|
vendored `.feedback feedback-*` classes.)
|
|
|
|
**The step-component contract.** A wizard step follows the same rule as
|
|
`address-fields.component.ts`: values in, events out, no internal state. Three clauses:
|
|
|
|
1. **Inputs down.** A step reads its data only from `input()`s the container passes it.
|
|
2. **One narrow output up.** A step emits one specific event, not the container's whole
|
|
`dispatch`.
|
|
3. **`dispatch` is never passed down.** The container owns the Model and decides what a
|
|
step's event means; a step never calls `dispatch` itself.
|
|
|
|
Corollary: a step gets **no** story of its own. The wizard's own story already mounts every
|
|
step, because it seeds the machine.
|
|
|
|
### 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 `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 (`libs/shared/src/ui/molecules/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
|
|
one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models
|
|
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
|
|
send messages, never mutate. **`createStore` is the one wiring idiom** — a page
|
|
never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level
|
|
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
|
|
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
|
|
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
|
|
model keeps prefixed _value_ exports instead (`initialUpload`/`reduceUpload`,
|
|
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` (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`).
|
|
|
|
**Side effects stay out of the reducer.** A _command_ (`application/submit-*.ts`)
|
|
does the HTTP, then dispatches a message describing the outcome. Reducer = "what the
|
|
new state is"; command = "go do it, then say what happened."
|
|
|
|
**Shared cross-page state = one root singleton.** Stores are `providedIn: 'root'`
|
|
(`BigProfileStore`, `SessionStore`). That single instance is the shared state — no
|
|
NgRx, no extra lib. Optimistic update pattern: `begin*` (flip pending) →
|
|
`confirm*` (clear + `resource.reload()`) / `rollback*` (undo).
|
|
|
|
### 4. BFF-lite + decision DTOs (ADR-0001)
|
|
|
|
`infrastructure/` is the **only** layer that touches the network — the
|
|
anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a
|
|
decision-enriched DTO; **the FE renders decisions, it does not recompute business
|
|
rules.** Per rule, pick: _decision flag_ (server computes the boolean — e.g.
|
|
herregistratie eligibility) or _config value_ (server sends threshold, FE applies
|
|
for instant feedback, server re-validates as authority — e.g. scholing threshold).
|
|
FE keeps only **format** validation, never as authority.
|
|
|
|
The generated client
|
|
(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is**
|
|
the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
|
|
`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types
|
|
it too loosely (the four survivors are all the latter — the generator emits every property as
|
|
optional and flattens unions); such a file must still import nothing. Either way a hand-written
|
|
`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain —
|
|
**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend
|
|
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
|
|
rules live **only** on the server, with no FE mirror to drift from it — the FE may
|
|
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
|
|
never reimplements the _algorithm_.
|
|
|
|
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
|
|
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
|
|
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
|
|
fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
|
|
exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
|
|
code, an unknown key fails closed, the value is operational rather than a shared business rule,
|
|
and writes are admin-capability-gated **and** audited. Two surfaces pass it today —
|
|
`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in
|
|
SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004.
|
|
|
|
### 5. Testing
|
|
|
|
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 —
|
|
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`
|
|
component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
|
|
a component in an app context's `ui/`, or in `libs/beheer/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
|
|
|
|
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
|
|
constructor DI (constructor only for `effect()`/template-ref injection).
|
|
- Angular-native control flow `@if/@for`; fetch via `resource({ loader })` over the
|
|
generated `ApiClient` inside an `infrastructure/*.adapter.ts` (one place HTTP lives),
|
|
with a `parse*` boundary; `withViewTransitions()` for page transitions (header/footer
|
|
have stable `view-transition-name`, excluded from the fade).
|
|
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
|
|
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
|
|
`*.machine.ts`). Pick the language by which side of the seam the code is on.
|
|
- **English prose uses Simplified Technical English (STE).** This covers documentation,
|
|
code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
|
|
20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
|
|
One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
|
|
articles ("the test fails"). Three nouns together at most. No idioms and no humour.
|
|
Six sentences per paragraph at most. Write a procedure as numbered steps, one action
|
|
per step.
|
|
**STE governs form, not content.** Split a long sentence; never drop a caveat, a
|
|
measurement, or a precise term to make it shorter.
|
|
**STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or
|
|
existing documents you are not already editing.
|
|
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
|
|
first-party `$localize` (no third-party i18n lib), with a stable custom id
|
|
(`` $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 `libs/shared/src/ui/molecules/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
|
|
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
|
|
fields + ad-hoc error signals.
|
|
- **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` (`libs/shared/src/kernel/datum.ts`). Never a third hand-rolled
|
|
`toLocaleDateString` call.
|
|
- 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 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`
|
|
on data pages (`scenario.interceptor.ts`) to see every async state — sticky per tab
|
|
(change it via a full navigation, not an in-app link). Hand-written `fetch`/XHR calls
|
|
(uploads, `/brief/preview`, `/admin/org-template/*/preview`, `/brief/reveal-bignummer`)
|
|
bypass the interceptor.
|
|
- Dev role stand-in (**dev-only**): `?role=drafter|approver|admin` (or the `⚙ state` dev panel).
|
|
Roles, how to switch, and what each unlocks: `docs/reference/roles-and-access.md`. `admin`
|
|
unlocks the capability-gated pages: `/brief/huisstijl` (org-template editor),
|
|
`/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`, scoped to
|
|
`{apps,libs}/**`) fails the build on `any`; the same config's `max-lines` rule caps every
|
|
`{apps,libs}/**/*.{page,component,section,step}.ts` file at 250 lines
|
|
(`skipBlankLines: true`, `skipComments: true`). 250 is reachable, not a style-guide
|
|
default — the dashboard page lands at 42 lines. `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 `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`,
|
|
`ui-component`, `new-ssp` (bootstrap a new portal from this template),
|
|
`document-feature` (ship/update docs in the same diff as the code).
|
|
|
|
## Out of scope (POC, don't build unprompted)
|
|
|
|
Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark),
|
|
runtime DTO validation on every endpoint, multi-tab session sync.
|