diff --git a/.claude/skills/bff-endpoint/SKILL.md b/.claude/skills/bff-endpoint/SKILL.md new file mode 100644 index 0000000..e9a4d6e --- /dev/null +++ b/.claude/skills/bff-endpoint/SKILL.md @@ -0,0 +1,57 @@ +--- +name: bff-endpoint +description: Add a screen-shaped read — backend endpoint returning a decision-enriched DTO, regenerated typed client, contracts DTO, adapter with parse boundary, application store, in the page. Use for any new data a page needs. +--- + +# BFF-lite read endpoint (decision DTO) + +One screen = one endpoint returning everything that screen needs, **decisions +included**. The FE renders the server's decisions; it never recomputes business +rules (ADR-0001). Per rule choose: **decision flag** (server computes the boolean) +or **config value** (server sends the threshold, FE applies it for instant feedback, +server re-validates as authority). + +## Steps + +1. **Backend** (`backend/src/BigRegister.Api/`): minimal-API endpoint under `/api/v1`, + wire DTO in `Contracts/`, rule in `Domain/` with a test in + `backend/tests/BigRegister.Tests/`. Rejections are ProblemDetails (RFC 7807, 422). +2. **Regenerate the client**: `npm run gen:api` — commits `backend/swagger.json` + + `src/app/shared/infrastructure/api-client.ts` (CI has a drift check; never edit the generated file). +3. **DTO** in `/contracts/.dto.ts` — plain interfaces/string-literal + unions. Contracts import **nothing** (lint-enforced): no Angular, no aliases, no relative imports. +4. **Adapter** in `/infrastructure/.adapter.ts` — the only place HTTP lives: + + ```ts + @Injectable({ providedIn: 'root' }) + export class DashboardViewAdapter { + private client = inject(ApiClient); + viewResource() { return resource({ loader: () => this.client.dashboardView() }); } + } + // The trust boundary: validate the untrusted shape AND map DTO → domain. + export function parseDashboardView(json: unknown): Result { … } + ``` + + The mapping may read as an identity copy today — the point is the **types** differ, + so the compiler forces a real mapping the moment the wire diverges. +5. **Application store** exposes the resource as `RemoteData` (`fromResource`, + combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`). + UI never imports infrastructure (lint-enforced). +6. **Page** renders through `` (`shared/ui/async`) — one of + loading/empty/error/loaded by construction. Check all four states with the + dev-only `?scenario=slow|loading|empty|error` toggle. + +## Worked example (trace one name through all layers) + +`dashboard-view`: `backend/src/BigRegister.Api` endpoint → +`src/app/registratie/contracts/dashboard-view.dto.ts` → +`src/app/registratie/infrastructure/dashboard-view.adapter.ts` (+ spec on `parseDashboardView`) → +`src/app/registratie/application/big-profile.store.ts` → dashboard page. + +## Verify + +```bash +cd backend && dotnet test && cd .. +npm run gen:api && git diff --exit-code src/app/shared/infrastructure/api-client.ts +npm test && npm run lint && npm run build +``` diff --git a/.claude/skills/form-machine/SKILL.md b/.claude/skills/form-machine/SKILL.md new file mode 100644 index 0000000..cb97ac8 --- /dev/null +++ b/.claude/skills/form-machine/SKILL.md @@ -0,0 +1,70 @@ +--- +name: form-machine +description: Add a form or wizard as an Elm-style state machine (Model/Msg/pure reduce) — the one idiom for anything with validation or submission, one step or many. Use instead of hand-rolled mutable fields + ad-hoc error signals. +--- + +# Form machine (Model / Msg / reduce) + +If you're about to add a second boolean to track state, stop — model a discriminated +union. Fields exist only in the states that need them, so illegal states are +unrepresentable. + +## Skeleton + +`/domain/.machine.ts` (pure TS, no Angular imports): + +```ts +import { Result, assertNever } from '@shared/kernel/fp'; + +export interface Draft { postcode: string; uren: string } // raw strings as typed +export type StepErrors = Partial>; +export interface Valid { postcode: Postcode; uren: Uren } // branded, proven valid + +export type State = + | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors } + | { tag: 'Submitting'; data: Valid } + | { tag: 'Submitted'; data: Valid } + | { tag: 'Failed'; data: Valid; error: string }; + +export type Msg = + | { tag: 'SetField'; key: keyof Draft; value: string } + | { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' } + | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } + | { tag: 'Seed'; state: State }; // mount any state (stories, resume) + +export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} }; + +export function reduce(s: State, m: Msg): State { + switch (m.tag) { + /* … pure transitions only … */ + default: return assertNever(m); // exhaustiveness enforced + } +} + +export function validate(draft: Draft): Result { /* calls value-object parsers */ } +``` + +Rules: +- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill). +- **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field. +- Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine). +- Co-located `.machine.spec.ts` is **required**: drive `reduce` with messages, assert states. No TestBed. + +## Wiring in the UI + +The organism holds `createStore(initial, reduce)` (`@shared/application/store`) as a +field initializer, derives view state via `computed` + `whenTag(state, 'Editing')`, +and renders into `` (`shared/layout/wizard-shell`) — status, steps, +errors, and primary/back/retry outputs map 1:1 onto the machine. + +## Worked examples + +- `src/app/herregistratie/domain/herregistratie.machine.ts` — canonical multi-step + submit lifecycle. +- `src/app/herregistratie/domain/intake.machine.ts` — progressive disclosure, derive-don't-store, server-owned threshold. +- `src/app/herregistratie/ui/` — the wizard organism + page composition. + +## Verify + +```bash +npm test && npm run lint && npm run build +``` diff --git a/.claude/skills/mutation-command/SKILL.md b/.claude/skills/mutation-command/SKILL.md new file mode 100644 index 0000000..b324289 --- /dev/null +++ b/.claude/skills/mutation-command/SKILL.md @@ -0,0 +1,58 @@ +--- +name: mutation-command +description: Add a write/submit operation — infrastructure adapter method plus an application command factory returning Result, keeping the reducer pure. Use for any POST/PUT/DELETE a form or action triggers. +--- + +# Mutation command + +Reducer = "what the new state is"; command = "go do it, then say what happened". +The UI holds an application command, never the network client (lint-enforced). + +## Skeleton + +**Adapter method** (`/infrastructure/.adapter.ts`) — takes the +machine's `Valid` type, returns the server's answer, no parse (server is authority): + +```ts +@Injectable({ providedIn: 'root' }) +export class ChangeRequestAdapter { + private client = inject(ApiClient); + async changeRequest(data: Valid): Promise { /* → server reference */ } +} +``` + +**Command factory** (`/application/submit-.ts`) — binds the adapter in +an injection context; `runSubmit` is the one try/catch + ProblemDetails mapping: + +```ts +export function createSubmitChangeRequest() { + const adapter = inject(ChangeRequestAdapter); + return (data: Valid): Promise> => + runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED); +} +``` + +**Caller** (organism, field initializer — same shape as `createStore`): on the +machine's `Submitting` state, run the command, then dispatch +`SubmitConfirmed` / `SubmitFailed { error }`. Never throw into the template. + +## Optimistic updates on shared state + +When a root store's view must reflect the write before the server confirms: +`begin*` (flip pending) → `confirm*` (clear + `resource.reload()`) / `rollback*` +(undo). See `src/app/registratie/application/big-profile.store.ts`. + +## Worked examples + +- `src/app/registratie/infrastructure/change-request.adapter.ts` +- `src/app/registratie/application/submit-change-request.ts` +- `src/app/registratie/application/draft-sync.ts` — `createDraftSync(...)` bundles + draft persistence + submit for wizard flows; prefer it when the form has a draft. +- `src/app/shared/application/submit.ts` — `runSubmit`, `SUBMIT_FAILED`. + +## Verify + +```bash +npm test && npm run lint && npm run build +# exercise the failure path in the browser: ?scenario=error on the submitting page +``` diff --git a/.claude/skills/new-context/SKILL.md b/.claude/skills/new-context/SKILL.md new file mode 100644 index 0000000..cd43d38 --- /dev/null +++ b/.claude/skills/new-context/SKILL.md @@ -0,0 +1,51 @@ +--- +name: new-context +description: Scaffold a new DDD bounded context (folders, path alias, eslint boundary rules, lazy route). Use when adding a new business capability that doesn't belong in an existing context. +--- + +# New bounded context + +A context is a business **capability**, not a user group — a user group is an actor +that may span contexts (ADR-0002). Check first whether the capability belongs in an +existing context; new contexts are rare. + +Naming: domain contexts are **Dutch** (`registratie`, `herregistratie`); only +shared/reusable code is English. The context name is the ubiquitous language term. + +## Steps + +1. **Folders** — `src/app//{domain,application,infrastructure,ui}` (`contracts/` + only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders. +2. **Path alias** — add `"@/*": ["src/app//*"]` to `tsconfig.json` `paths`. + Aliases are direction statements; always import cross-context via the alias. +3. **eslint boundaries** (`eslint.config.mjs`) — dependencies point inward and + toward `shared` only. Copy the existing per-context block (the `brief` block is + the minimal leaf-context example) and: + - add a block for `src/app//**/*.ts` banning imports from every context it + may **not** depend on; + - add `@/*` to the ban lists of `shared/**` and every context that must not + depend on the new one (grep the config for `@brief/*` to find all lists); + - the generic blocks (`domain/**` framework-free, `contracts/**` import-nothing, + ApiClient confinement, `ui/**` never imports `infrastructure`) match by glob + and cover the new context automatically. +4. **Route** — lazy child under the persistent shell in `app.routes.ts`: + + ```ts + { path: '', canActivate: [authGuard], + loadComponent: () => import('@/ui/.page').then(m => m.CtxPage) } + ``` + +5. Build the first feature slice with the **new-feature** skill. + +## Worked example + +`src/app/brief/` — an independent leaf context (depends only on shared): see its +folder layout and its eslint block in `eslint.config.mjs`. + +## Verify + +```bash +npm run lint && npm run build +# prove the fence works: add a forbidden import (e.g. new ctx → @herregistratie/*), +# confirm lint fails, remove it. +``` diff --git a/.claude/skills/new-feature/SKILL.md b/.claude/skills/new-feature/SKILL.md new file mode 100644 index 0000000..f443ea0 --- /dev/null +++ b/.claude/skills/new-feature/SKILL.md @@ -0,0 +1,45 @@ +--- +name: new-feature +description: Add a feature (screen, flow, capability) to this Angular SSP following the house pipeline — domain first, then infrastructure, application, UI last. Use whenever building anything user-facing that has business rules or data. +--- + +# New feature (the pipeline) + +Every feature is built inward-out. Never start with the component. + +## Steps + +1. **Domain** (`/domain/`, pure TS, no Angular imports — lint-enforced) + - Data types + pure rules. Forms/wizards get a `*.machine.ts` → use the **form-machine** skill. + - Raw input that must be valid → branded value object → use the **value-object** skill. + - Co-locate a `.spec.ts`; test the pure functions directly, no TestBed. + - Server-owned rules stay here only as reference impl + test, marked server-owned; the FE renders the server's decision (ADR-0001). + +2. **Infrastructure** (`/infrastructure/`, the only layer that touches HTTP) + - Read → screen-shaped endpoint + adapter → use the **bff-endpoint** skill. + - Write → command adapter returning `Result` → use the **mutation-command** skill. + +3. **Application** (`/application/`) + - Shared cross-page state → `providedIn: 'root'` store exposing `RemoteData` signals. + - Page-local flow state → `createStore(initial, reduce)` from `@shared/application/store`, held in the component. + - Side effects live in commands (`submit-*.ts`), never in the reducer. + +4. **UI last** (`/ui/`) + - A page is **composition of existing blocks** — ``, ``, atoms/molecules from `shared/ui`. Adding a new building block is the exception → **ui-component** skill. + - Templates dispatch messages, never mutate. Async data always renders through ``. + - All copy via `$localize` with stable `@@context.key` ids. + - Route: lazy `loadComponent` under the `ShellComponent` parent in `app.routes.ts`, `canActivate: [authGuard]` when protected. + +## Worked example + +The intake wizard slice, end to end: +- `src/app/herregistratie/domain/intake.machine.ts` (+ spec) +- `src/app/herregistratie/infrastructure/` +- `src/app/herregistratie/ui/` (wizard component + page) + +## Verify (GREEN gate) + +```bash +npm run lint && npm run check:tokens && npm test && npm run build +cd backend && dotnet test # if the backend changed +``` diff --git a/.claude/skills/new-ssp/SKILL.md b/.claude/skills/new-ssp/SKILL.md new file mode 100644 index 0000000..3ac0191 --- /dev/null +++ b/.claude/skills/new-ssp/SKILL.md @@ -0,0 +1,55 @@ +--- +name: new-ssp +description: Bootstrap a new self-service portal from this repo as a template — what to keep, strip, rename, and re-seed. Use when starting a new SSP for a different domain/register. +--- + +# New SSP from this template + +The template's value is the **enforced architecture** (layer fences, token gate, +a11y gate, API-drift gate) and the shared building blocks — not the BIG-register +business content. Keep the machinery, replace the domain. + +## Keep as-is + +- `src/app/shared/` — kernel (`fp.ts`), application (`remote-data`, `store`, + `submit`), ui atoms/molecules, layout templates, upload subtree. +- Tooling: `eslint.config.mjs`, `scripts/check-tokens.sh`, `.github/workflows/ci.yml`, + `nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps` — + and never `npm audit fix --force`, it downgrades Angular). +- `src/app/auth/` (fake auth shell) and `src/app/shared/infrastructure/scenario.interceptor.ts` (dev-only). +- `docs/architecture/` ADRs 0001–0003 — the decisions still apply; amend, don't delete. +- `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/fp-tea-atomic-design.md` — update names/examples as contexts change. +- `.claude/skills/` — these recipes are the point of the template. + +## Strip / replace + +- Business contexts `registratie/`, `herregistratie/`, `brief/`, and `showcase/`: + delete or keep one slice temporarily as the worked example while building the + first real context (**new-context** + **new-feature** skills). If deleted, update + the worked-example paths in these skills to the new flagship context. +- `app.routes.ts` routes and `tsconfig.json` aliases for removed contexts, plus + their eslint blocks in `eslint.config.mjs`. +- Backend: keep the skeleton (`Program.cs` minimal-API style, ProblemDetails 422, + `X-Correlation-Id` audit line, `/api/v1` versioning, `Contracts/`/`Domain/`/`Data/` + split, test project) — replace `Data/SeedData.cs`, `Domain/*` rules, and + `Contracts/*` DTOs with the new domain's. Rename the solution/projects from + `BigRegister.*` (also update `package.json` `gen:api` and `ci.yml` paths). +- Regenerate the seam: `npm run gen:api` (commits `backend/swagger.json` + + `src/app/shared/infrastructure/api-client.ts`). +- Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a + different house style, swap the vendored CSS and re-point the `--rhc-*` bridge + (ADR-0003 pattern: bridge, don't rewrite tokens). +- `docs/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog + (keep `docs/backlog/README.md`'s WP process/template if you like the workflow). + +## Verify — the GREEN gate must pass at every step + +```bash +npm run lint && npm run check:tokens && npm test && npm run build +npm run build-storybook && npm run test-storybook:ci +cd backend && dotnet test && cd .. +npm run gen:api && git diff --exit-code backend/swagger.json src/app/shared/infrastructure/api-client.ts +``` + +Strip incrementally and keep this green — the fences are only worth having if they +never go red. diff --git a/.claude/skills/ui-component/SKILL.md b/.claude/skills/ui-component/SKILL.md new file mode 100644 index 0000000..a565b35 --- /dev/null +++ b/.claude/skills/ui-component/SKILL.md @@ -0,0 +1,53 @@ +--- +name: ui-component +description: Add a shared UI building block (atom, molecule, organism) with its Storybook story. Use only after confirming no existing block in shared/ui fits — new blocks are the exception, composition is the default. +--- + +# UI component (atom / molecule / organism) + +First: check `shared/ui/` and `shared/layout/` — a new page should be composition of +existing blocks. Only add a block when nothing fits. + +## Rules + +- **Folder = atomic layer**: `shared/ui/` atoms → molecules → organisms; + `shared/layout/` templates. Each level only uses levels below. +- Standalone component, **English name** (shared = language-agnostic), signal + `input()`s only, `inject()` over constructor DI. +- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`, + `form-control`, `card`, …) — you own a small typed `input()` API, the design system + owns the visuals. No CIBG class for it? Hand-roll a small surface from the token + bridge (ADR-0003, e.g. the `alert` atom). +- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors + (`npm run check:tokens` fails the build; escape hatch: `token-ok` marker + reason). +- **No hardcoded Dutch** in shared components — expose copy as `input()`s with + `$localize` defaults; the domain caller supplies the text (see `shared/ui/async`). +- Components with content-projected slots export a spread constant so callers import + one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;` + +## Story (required — this is the component's test) + +Co-located `.stories.ts`, title prefixed with the layer: + +```ts +const meta: Meta = { title: 'Atoms/Button', component: ButtonComponent }; +export const Primary: StoryObj = { args: { variant: 'primary' } }; +``` + +One named export per meaningful state. CI runs axe on every story +(`test-storybook:ci`) — a11y failures break the build. Machines/wizards mount +specific states via the `Seed` message. No heavy component specs; Storybook is the +UI test surface. + +## Worked examples + +- Atom: `src/app/shared/ui/button/` — typed variant API over `btn` classes. +- Molecule: `src/app/shared/ui/async/` — slot directives, localizable input defaults, spread constant. +- Template: `src/app/shared/layout/wizard-shell/` — the canonical wizard outline. + +## Verify + +```bash +npm run lint && npm run check:tokens && npm run build +npm run storybook # eyeball the story; CI will run axe +``` diff --git a/.claude/skills/value-object/SKILL.md b/.claude/skills/value-object/SKILL.md new file mode 100644 index 0000000..e78ec6d --- /dev/null +++ b/.claude/skills/value-object/SKILL.md @@ -0,0 +1,55 @@ +--- +name: value-object +description: Add a validated input type (postcode, email, hours, id number, …) as a branded value object with a parser — "parse, don't validate". Use whenever a form field or API value has format rules. +--- + +# Value object (parse, don't validate) + +Raw input becomes a branded type only via a parser returning `Result`. Once you hold +the type, never re-check it. Never model validity as a boolean flag next to a string. + +## Skeleton + +`/domain/value-objects/.ts` (pure TS, no Angular): + +```ts +import { Brand, Result, ok, err } from '@shared/kernel/fp'; + +export type Postcode = Brand; + +export function parsePostcode(raw: string): Result { + const t = raw.trim().toUpperCase(); + if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) { + return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`); + } + // The parser also normalises — callers always hold the canonical form. + return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode); +} +``` + +Rules: +- The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else. +- Error message is user-facing → `$localize` with a stable `@@validation.` id. +- Trim/normalise before testing; return the cleaned value. +- This is **format** feedback only. The server re-validates as authority (ADR-0001) — never encode business rules (existence, eligibility) here. + +## Spec (required) + +Co-located `.spec.ts`: happy path, normalisation, each rejection case. Call the +parser directly — no TestBed. + +## Wiring into a form + +The machine's `validate(draft)` calls the parsers and collects errors into +`StepErrors`; the `Valid` type holds the branded values (see **form-machine** skill). + +## Worked examples + +`src/app/registratie/domain/value-objects/` — `postcode.ts`, `email.ts`, `uren.ts`, +`big-nummer.ts`, each with a co-located spec. + +## Verify + +```bash +npm test && npm run lint +``` diff --git a/CLAUDE.md b/CLAUDE.md index 48fbb33..9dd344d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,6 +161,10 @@ Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in ``, dispatch messages). Worked example: the 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). + ## Out of scope (POC, don't build unprompted) Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark),