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
+496
View File
@@ -0,0 +1,496 @@
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
import { Registration } from '@registratie/domain/registration';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { parseBsn } from '@shared/kernel/bsn';
import { maskBsn } from '@shared/kernel/pii';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { SNIPPETS } from './snippets.generated';
import { highlightTs } from './highlight-ts';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
return {
value: () => value as T,
status: () => status,
error: () => error,
hasValue: () => value !== undefined,
reload: () => {},
} as unknown as Resource<T>;
}
/** Teaching showcase: each section pairs the impossible-state-permitting"before"
with the"after" where the type system rules it out. Composition-only. */
@Component({
selector: 'app-concepts-page',
imports: [
FormsModule,
PageShellComponent,
HeadingComponent,
TextInputComponent,
...ASYNC,
SkeletonComponent,
RegistrationSummaryComponent,
HerregistratieWizardComponent,
IntakeWizardComponent,
MaskedValueComponent,
],
styles: [
`
.section {
margin: 0 0 3rem;
}
.lead {
color: var(--rhc-color-grijs-700);
max-width: 46rem;
margin: 0.25rem 0 1.25rem;
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1.5rem;
align-items: start;
}
.card {
border: 1px solid var(--rhc-color-grijs-200, #e5e5e5);
border-radius: 10px;
padding: 1.25rem;
background: #fff;
}
.card--bad {
border-color: var(--rhc-color-rood-300, #f0b4b4);
}
.card--good {
border-color: var(--rhc-color-groen-300, #b4e0b4);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.tag::before {
content: '';
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
}
.tag.bad {
color: var(--rhc-color-rood-600, #a30000);
}
.tag.bad::before {
background: var(--rhc-color-rood-500, #d52b1e);
}
.tag.good {
color: var(--rhc-color-groen-700, #277337);
}
.tag.good::before {
background: var(--rhc-color-groen-500, #39870c);
}
.tag.plain {
color: var(--rhc-color-grijs-700);
}
.tag.plain::before {
display: none;
}
pre {
background: #1e2430;
color: #e6e9ef;
padding: 1rem;
border-radius: 8px;
overflow: auto;
font-size: 0.82rem;
line-height: 1.55;
margin: 0;
}
pre .k {
color: #c792ea;
}
pre .s {
color: #c3e88d;
}
pre .c {
color: #7e8aa0;
font-style: italic;
}
.note {
font-size: 0.9rem;
color: var(--rhc-color-grijs-700);
margin: 0.75rem 0 0;
}
/* live state diagram */
.machine {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
}
.node {
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--rhc-color-grijs-300, #ccc);
font-size: 0.82rem;
color: var(--rhc-color-grijs-700);
transition: all 0.15s;
}
.node.on {
background: var(--rhc-color-hemelblauw-100, #e5f1fb);
border-color: var(--rhc-color-hemelblauw-500, #007bc7);
color: var(--rhc-color-hemelblauw-700, #00567d);
font-weight: 700;
/* teaching motion: the active state pops as the wizard transitions (the .node
transition above animates it; reduced-motion is handled globally). */
transform: scale(1.06);
}
.linked {
margin: 0 0 1rem;
}
.linked .src {
font-size: 0.72rem;
color: var(--rhc-color-grijs-700);
margin: 0.35rem 0 0;
font-family: monospace;
}
.steplist {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin: 0 0 1rem;
}
.pill {
padding: 0.3rem 0.7rem;
border-radius: 8px;
background: var(--rhc-color-grijs-100, #f3f3f3);
font-size: 0.8rem;
}
.pill.extra {
background: var(--rhc-color-geel-100, #fff6d6);
border: 1px dashed var(--rhc-color-geel-600, #c79a00);
}
.arrow {
color: var(--rhc-color-grijs-400, #999);
}
`,
],
template: `
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
<p class="lead">
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens "fout"
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
</p>
<!-- 1. Discriminated unions -->
<section class="section">
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — vlakke interface</p>
<pre [innerHTML]="code['unionBad']"></pre>
<p class="note">
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
</p>
</div>
<div class="card card--good">
<p class="tag good">Goed — sum type</p>
<figure class="linked">
<pre [innerHTML]="code['union']"></pre>
<figcaption class="src">↳ {{ src['union'] }}</figcaption>
</figure>
<app-registration-summary [reg]="doorgehaald" />
<p class="note">
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
simpelweg niet.
</p>
</div>
</div>
</section>
<!-- 2. RemoteData fold -->
<section class="section">
<app-heading [level]="2">2 · RemoteData fold</app-heading>
<p class="lead">
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Vier toestanden, één molecuul</p>
<p class="tag plain">Loading</p>
<app-async [resource]="loadingRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
><ng-template appAsyncLoading
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
></app-async>
<p class="tag plain">Empty</p>
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
<p class="tag plain">Failure</p>
<app-async [resource]="errorRes"
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
>
<p class="tag plain">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded
><ul>
@for (i of successRes.value(); track i) {
<li>{{ i }}</li>
}
</ul></ng-template
></app-async
>
</div>
<div class="card card--good">
<p class="tag good">De exhaustieve fold</p>
<figure class="linked">
<pre [innerHTML]="code['fold']"></pre>
<figcaption class="src">↳ {{ src['fold'] }}</figcaption>
</figure>
<p class="note">
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
afhandelt.
</p>
</div>
</div>
</section>
<!-- 3. Parse, don't validate -->
<section class="section">
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
<div class="cols">
<div class="card">
<p class="tag good">Smart constructor → Result</p>
<figure class="linked">
<pre [innerHTML]="code['parse']"></pre>
<figcaption class="src">↳ {{ src['parse'] }}</figcaption>
</figure>
<app-text-input
inputId="pc"
[ngModel]="raw()"
(ngModelChange)="raw.set($event)"
name="pc"
placeholder="Typ een postcode, bijv. 1234 AB"
/>
</div>
@let r = parsed();
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
@if (r.ok) {
<div animate.enter="app-item-enter">
<p class="tag good">ok</p>
<pre>Postcode ="{{ r.value }}"</pre>
<p class="note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p>
</div>
} @else {
<div animate.enter="app-item-enter">
<p class="tag bad">err</p>
<pre>{{ r.error }}</pre>
</div>
}
</div>
</div>
</section>
<!-- 4. State machine / wizard (live state diagram) -->
<section class="section">
<app-heading [level]="2">4 · Form als state machine</app-heading>
<p class="lead">
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de
huidige.
</p>
<div class="cols">
<div class="card card--bad">
<p class="tag bad">Fout — losse booleans</p>
<pre [innerHTML]="code['machineBad']"></pre>
<p class="note">
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
</p>
</div>
<div class="card card--good">
<p class="tag good">Goed — één tagged union</p>
<figure class="linked">
<pre [innerHTML]="code['machine']"></pre>
<figcaption class="src">↳ {{ src['machine'] }}</figcaption>
</figure>
<div class="machine">
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
}
</div>
<app-herregistratie-wizard #w />
</div>
</div>
</section>
<!-- 5. Fixed steps, questions revealed inline -->
<section class="section">
<app-heading [level]="2"
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
>
<p class="lead">
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of
vul weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N"
blijft gelijk.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Vaste stappen</p>
<figure class="linked">
<pre [innerHTML]="code['steps']"></pre>
<figcaption class="src">↳ {{ src['steps'] }}</figcaption>
</figure>
<div class="steplist">
@for (s of iw.steps; track s; let last = $last) {
<span class="pill">{{ s }}</span>
@if (!last) {
<span class="arrow">→</span>
}
}
</div>
<p class="note">
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
of verdwijnen.
</p>
</div>
<div class="card card--good">
<p class="tag good">De wizard</p>
<app-intake-wizard #iw />
</div>
</div>
</section>
<!-- 6. PII: mask + parse -->
<section class="section">
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
<p class="lead">
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
</p>
<div class="cols">
<div class="card card--good">
<p class="tag good">Maskeren — atom</p>
<p>
BSN:
<app-masked-value
[value]="bsnShown()"
[canReveal]="true"
revealLabel="Toon BSN"
(reveal)="bsnRevealed.set(true)"
/>
</p>
<figure class="linked">
<pre [innerHTML]="code['mask']"></pre>
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
</figure>
<p class="note">
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
bij elke gebruiker.
</p>
</div>
<div class="card">
<p class="tag good">Parse (elfproef) → Result</p>
<app-text-input
inputId="bsn"
[ngModel]="bsnRaw()"
(ngModelChange)="bsnRaw.set($event)"
name="bsn"
placeholder="Typ een BSN, bijv. 123456782"
/>
@let b = bsnParsed();
@if (bsnRaw()) {
<div animate.enter="app-item-enter">
@if (b.ok) {
<p class="tag good">ok</p>
<pre>Bsn ="{{ b.value }}"</pre>
} @else {
<p class="tag bad">err</p>
<pre>{{ b.error }}</pre>
}
</div>
}
<figure class="linked">
<pre [innerHTML]="code['parseBsn']"></pre>
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
</figure>
</div>
</div>
</section>
</app-page-shell>
`,
})
export class ConceptsPage {
isEmpty = (v: string[]) => !v || v.length === 0;
doorgehaald: Registration = {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
};
loadingRes = fakeResource<string[]>('loading');
emptyRes = fakeResource<string[]>('resolved', []);
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
demoBsn = '123456782';
bsnRevealed = signal(false);
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
bsnRaw = signal('');
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
private readonly illustrations: Record<string, string> = {
unionBad: `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
}`,
machineBad: `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`,
};
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
by gen:snippets — they can't drift), the illustrations are authored above. */
protected readonly code: Record<string, string> = Object.fromEntries(
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
);
/** The real file each linked snippet is extracted from (shown as a caption). */
protected readonly src: Record<string, string> = {
union: 'registratie/domain/registration.ts',
fold: 'shared/application/remote-data.ts',
parse: 'registratie/domain/value-objects/postcode.ts',
machine: 'registratie/domain/change-request.machine.ts',
steps: 'herregistratie/domain/intake.machine.ts',
parseBsn: 'shared/kernel/bsn.ts',
mask: 'shared/kernel/pii.ts',
};
}
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { highlightTs } from './highlight-ts';
describe('highlightTs', () => {
it('wraps keywords, strings and comments in the styling spans', () => {
const out = highlightTs(`const x = 'hi'; // note`);
expect(out).toContain('<span class="k">const</span>');
expect(out).toContain(`<span class="s">'hi'</span>`);
expect(out).toContain('<span class="c">// note</span>');
});
it('escapes HTML metacharacters so the [innerHTML] sink is safe', () => {
const out = highlightTs(`type T = A<B> & C;`);
expect(out).toContain('&lt;B&gt;');
expect(out).toContain('&amp;');
expect(out).not.toContain('<B>');
});
it('treats the whole // tail as one comment (keywords after // are not re-highlighted)', () => {
const out = highlightTs(`x(); // return here`);
expect(out).toContain('<span class="c">// return here</span>');
expect(out).not.toContain('<span class="k">return</span>');
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Tiny, dependency-free TS highlighter for the teaching showcase (WP-39). Escapes HTML,
* then wraps line-comments, strings, and a fixed keyword set in `.c`/`.s`/`.k` spans (the
* classes `concepts.page` styles). Deliberately naive — good enough for the short, curated
* snippets shown here; not a real tokenizer. Input is always our OWN source (extracted by
* `scripts/gen-snippets.mjs` or authored inline), so the `[innerHTML]` sink is safe once
* the HTML metacharacters are escaped first. Pure.
*/
const KEYWORDS = [
'interface',
'type',
'export',
'import',
'from',
'const',
'let',
'return',
'function',
'switch',
'case',
'default',
'if',
'else',
'new',
'readonly',
'extends',
'as',
'void',
];
const escapeHtml = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
export function highlightTs(code: string): string {
const kw = new RegExp(`\\b(${KEYWORDS.join('|')})\\b`, 'g');
return escapeHtml(code)
.split('\n')
.map((line) => {
// Line comment: everything from // to EOL is one comment span (skip the rest).
const c = line.indexOf('//');
const head = c === -1 ? line : line.slice(0, c);
const tail = c === -1 ? '' : `<span class="c">${line.slice(c)}</span>`;
const lit = head
.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => `<span class="s">${m}</span>`) // strings
.replace(kw, '<span class="k">$1</span>'); // keywords
return lit + tail;
})
.join('\n');
}
@@ -0,0 +1,11 @@
// GENERATED by `npm run gen:snippets` (scripts/gen-snippets.mjs) — do not edit.
// Source-of-truth code excerpts extracted from real files via // #region showcase:<name>.
export const SNIPPETS: Record<string, string> = {
"fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote<E, T, R>(\n rd: RemoteData<E, T>,\n h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },\n): R {\n switch (rd.tag) {\n case 'Loading':\n return h.loading();\n case 'Empty':\n return h.empty();\n case 'Failure':\n return h.failure(rd.error);\n case 'Success':\n return h.success(rd.value);\n default:\n return assertNever(rd); // add a variant → compile error until handled\n }\n}",
"machine": "export type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing\n | { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };",
"mask": "/** Keep the last `keep` characters, mask the rest with `*`. */\nexport function maskTail(value: string, keep: number): string {\n if (value.length <= keep) return '*'.repeat(value.length);\n return '*'.repeat(value.length - keep) + value.slice(-keep);\n}\n\n/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */\nexport function maskBsn(value: string): string {\n return maskTail(value, 3);\n}",
"parse": "export function parsePostcode(raw: string): Result<string, Postcode> {\n const t = raw.trim().toUpperCase();\n if (!/^[1-9]\\d{3}\\s?[A-Z]{2}$/.test(t)) {\n return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);\n }\n // Normalise to \"1234 AB\" — the parser also cleans up.\n return ok(t.replace(/^(\\d{4})\\s?([A-Z]{2})$/, '$1 $2') as Postcode);\n}",
"parseBsn": "export function parseBsn(raw: string): Result<string, Bsn> {\n const t = raw.trim();\n if (!/^\\d{9}$/.test(t)) {\n return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);\n }\n const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);\n if (t === '000000000' || sum % 11 !== 0) {\n return err(\n $localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,\n );\n }\n return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef\n}",
"steps": "/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];",
"union": "export type RegistrationStatus =\n | { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date\n | { tag: 'Geschorst'; geschorstTot: string; reden: string }\n | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };",
};