Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs

Checkpoint of in-progress work: the registration wizard (address prefill,
DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't-
validate value objects, infrastructure adapters, plus CLAUDE.md and the
architecture/ADR docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:23:52 +02:00
parent 8a8a2f0f29
commit 64385999eb
58 changed files with 7271 additions and 556 deletions

View File

@@ -1,9 +1,10 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource, map2 } from '@shared/application/remote-data';
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import { BrpAdapter } from '../infrastructure/brp.adapter';
import { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
type Err = Error | undefined;
@@ -13,29 +14,32 @@ type Err = Error | undefined;
* (created here, in the required injection context) and exposes them as
* RemoteData signals.
*
* The headline trick: `profile` combines TWO independent services — the
* BIG-register and the BRP — into ONE RemoteData via map2. A page renders a
* single state (loading / error / loaded), never juggling three.
* The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
* returns registration + person + server-computed `decisions`. One request → one
* consistent snapshot, instead of stitching three independently loading/erroring
* resources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md.
*/
@Injectable({ providedIn: 'root' })
export class BigProfileStore {
private big = inject(BigRegisterAdapter);
private brp = inject(BrpAdapter);
private viewAdapter = inject(DashboardViewAdapter);
private registrationRes = this.big.registrationResource();
private viewRes = this.viewAdapter.dashboardViewResource();
private aantekeningenRes = this.big.aantekeningenResource();
private personRes = this.brp.personResource();
/** BIG-register + BRP folded into one state. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
map2(
fromResource(this.registrationRes),
fromResource(this.personRes),
// httpResource types value as T | undefined; in the Success branch it is
// always present, so narrowing here is safe.
(registration, person) => ({ registration: registration!, person: person! }),
),
);
/** The aggregated view, validated at the trust boundary (DTO → domain). */
private view = computed<RemoteData<Err, DashboardView>>(() => {
const rd = fromResource(this.viewRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDashboardView(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** Registration + person, from the single aggregated call. */
readonly profile = computed<RemoteData<Err, BigProfile>>(() => map(this.view(), (v) => v.profile));
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
/** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
@@ -52,7 +56,7 @@ export class BigProfileStore {
}
confirmHerregistratie() {
this.pending.set(false);
this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration
this.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions)
}
rollbackHerregistratie() {
this.pending.set(false); // submission failed — undo the optimistic flag

View File

@@ -0,0 +1,19 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
/**
* Command: send the completed registration to the backend, which invokes the
* domain command to create/finalize the registration aggregate. Returns a Result
* so the caller branches on success/failure without try/catch; on success it
* yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap
* for a real POST when there's an API. The "manually entered diploma" rule lets
* the demo show the failure path.
*/
export async function submitRegistratie(data: ValidRegistratie): Promise<Result<string, string>> {
await new Promise((r) => setTimeout(r, 800));
if (data.diplomaHerkomst === 'handmatig') {
return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.');
}
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
return ok(referentie);
}

View File

@@ -0,0 +1,15 @@
/**
* WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call).
*
* In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our
* own backend, which talks to the BRP behind an adapter. The frontend never sees
* the BRP's own wire format. See docs/architecture/0001-bff-lite-decision-dtos.md.
*
* "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error —
* the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy
* path (gevonden: true).
*/
export interface BrpAddressDto {
gevonden: boolean;
adres?: { straat: string; postcode: string; woonplaats: string };
}

View File

@@ -0,0 +1,36 @@
import { Registration } from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile';
/**
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
*
* In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source
* of truth for both sides), and the `decisions` block is computed BY THE BACKEND
* — never recomputed on the client. The frontend renders decisions; it does not
* own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
*
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
* so the page always sees one consistent snapshot instead of three independently
* loading/erroring resources.
*/
export interface DashboardViewDto {
registration: Registration;
person: Person;
decisions: HerregistratieDecisions;
}
/** Server-computed decisions. The eligibility rule lives on the backend; the
optional reason lets the UI explain itself without knowing the rule. */
export interface HerregistratieDecisions {
eligibleForHerregistratie: boolean;
herregistratieReason?: string;
}
/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping
this distinct from DashboardViewDto is the decoupling seam: the wire shape can
change without the FE domain following, and vice-versa. */
export interface DashboardView {
profile: BigProfile;
decisions: HerregistratieDecisions;
}

View File

@@ -0,0 +1,38 @@
/**
* WIRE CONTRACT for the DUO diploma lookup ("BFF-lite" — one screen-shaped call
* returning everything the beroep step needs).
*
* Each diploma carries its server-computed `beroep` (the profession it maps to)
* and the `policyQuestions` (geldigheidsvragen) that apply to it. These are
* DECISIONS computed by the backend from the diploma's attributes — the frontend
* renders them, it does not derive them (decision-DTO pattern, ADR-0001). E.g. an
* English-language diploma carries the Dutch-proficiency question.
*
* `handmatig` is the fallback when the diploma is not in the DUO list: the
* professions the user may declare and the MAXIMAL policy-question set that then
* applies (a manual diploma is unverified, so the strictest set is used).
*/
export interface DuoLookupDto {
diplomas: DuoDiplomaDto[];
handmatig: ManualDiplomaPolicyDto;
}
export interface DuoDiplomaDto {
id: string;
naam: string;
instelling: string;
jaar: number;
beroep: string; // server-derived profession
policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen
}
export interface ManualDiplomaPolicyDto {
beroepen: string[]; // professions the user may declare for a manual diploma
policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma
}
export interface PolicyQuestionDto {
id: string;
vraag: string;
type: 'ja-nee' | 'tekst';
}

View File

@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import {
Draft,
RegistratieState,
STEPS,
initial,
currentStep,
next,
back,
gaNaarStap,
kiesDiploma,
kiesHandmatig,
declareerBeroep,
setAntwoord,
setField,
prefillAdres,
submit,
resolve,
reduce,
} from './registratie-wizard.machine';
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
tag: 'Invullen',
draft: { antwoorden: {}, ...draft },
cursor,
errors: {},
});
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
const validDraft: Partial<Draft> = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' };
describe('STEPS (fixed)', () => {
it('always has the same three steps', () => {
expect(STEPS).toEqual(['adres', 'beroep', 'controle']);
});
});
describe('navigation', () => {
it('Next is a no-op (sets errors) when the adres step is invalid', () => {
const s = next(initial);
expect(s.tag).toBe('Invullen');
expect((s as any).cursor).toBe(0);
expect((s as any).errors.straat).toBeTruthy();
expect((s as any).errors.correspondentie).toBeTruthy();
});
it('Next advances once the adres step is valid', () => {
const s = next(invullen(validAdres));
expect((s as any).cursor).toBe(1);
expect(currentStep(s as any)).toBe('beroep');
});
it('requires a valid e-mail only when the channel is email', () => {
const bad = next(invullen({ ...validAdres, correspondentie: 'email' }));
expect((bad as any).errors.email).toBeTruthy();
const good = next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' }));
expect((good as any).cursor).toBe(1);
});
it('beroep step requires a chosen diploma', () => {
const noDiploma = next(invullen(validAdres, 1));
expect((noDiploma as any).cursor).toBe(1);
expect((noDiploma as any).errors.diploma).toBeTruthy();
const withDiploma = next(invullen(validDraft, 1));
expect((withDiploma as any).cursor).toBe(2);
});
it('Back never goes below the first step and preserves the draft', () => {
expect(back(initial)).toBe(initial);
const s = back(invullen(validDraft, 2));
expect((s as any).cursor).toBe(1);
expect((s as any).draft.beroep).toBe('Arts');
});
it('GaNaarStap only jumps backwards', () => {
expect((gaNaarStap(invullen(validDraft, 2), 0) as any).cursor).toBe(0);
expect((gaNaarStap(invullen(validDraft, 1), 2) as any).cursor).toBe(1); // forward jump rejected
});
});
describe('adres origin (BRP vs handmatig)', () => {
it('prefillAdres flags origin brp', () => {
const s = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
expect((s as any).draft.adresHerkomst).toBe('brp');
expect((s as any).draft.straat).toBe('Lange Voorhout 9');
});
it('editing a prefilled address field flips origin to handmatig', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = setField(prefilled, 'woonplaats', 'Rotterdam');
expect((edited as any).draft.adresHerkomst).toBe('handmatig');
});
it('typing an address with no BRP prefill yields handmatig', () => {
const s = setField(invullen({}), 'straat', 'Kerkstraat 1');
expect((s as any).draft.adresHerkomst).toBe('handmatig');
});
it('editing the e-mail field does not change the address origin', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = setField(prefilled, 'email', 'a@b.nl');
expect((edited as any).draft.adresHerkomst).toBe('brp');
});
it('a manually entered address still submits (only manual diploma is gated)', () => {
const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }));
expect(s.tag).toBe('Indienen');
expect((s as any).data.adresHerkomst).toBe('handmatig');
});
});
describe('kiesDiploma', () => {
it('derives the beroep from the chosen diploma and flags origin duo', () => {
const s = kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []);
expect((s as any).draft.diplomaId).toBe('d9');
expect((s as any).draft.beroep).toBe('Verpleegkundige');
expect((s as any).draft.diplomaHerkomst).toBe('duo');
});
});
describe('policy questions (geldigheidsvragen)', () => {
it('a diploma with questions blocks Next until they are answered', () => {
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
const blocked = next(s);
expect((blocked as any).cursor).toBe(1);
expect((blocked as any).errors.antwoorden['nl-taalvaardigheid']).toBeTruthy();
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
expect((next(s) as any).cursor).toBe(2);
});
it('validateAll keeps only the answers to the questions that applied', () => {
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
const done = submit(s);
expect(done.tag).toBe('Indienen');
expect((done as any).data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' });
});
});
describe('manual diploma fallback', () => {
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
const s = kiesHandmatig(invullen(validAdres, 1), maxIds);
expect((s as any).draft.diplomaHerkomst).toBe('handmatig');
expect((s as any).draft.beroep).toBeUndefined();
expect((s as any).draft.vraagIds).toEqual(maxIds);
});
it('requires a declared beroep + all maximal questions before submit', () => {
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
s = declareerBeroep(s, 'Fysiotherapeut');
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
for (const id of maxIds) s = setAntwoord(s, id, 'ja');
const done = submit(s);
expect(done.tag).toBe('Indienen');
expect((done as any).data.diplomaHerkomst).toBe('handmatig');
expect((done as any).data.beroep).toBe('Fysiotherapeut');
});
});
describe('submit', () => {
it('reaches Indienen ONLY with a complete, valid draft', () => {
expect(submit(invullen(validAdres)).tag).toBe('Invullen'); // no diploma
const good = submit(invullen(validDraft));
expect(good.tag).toBe('Indienen');
expect((good as any).data.beroep).toBe('Arts');
expect((good as any).data.adres.postcode).toBe('2514 EA');
expect((good as any).data.adresHerkomst).toBe('brp');
});
it('resolve maps Indienen to Ingediend (with referentie) / Mislukt', () => {
const indienen = submit(invullen(validDraft));
expect(resolve(indienen, ok('BIG-2026-001')).tag).toBe('Ingediend');
expect((resolve(indienen, ok('BIG-2026-001')) as any).referentie).toBe('BIG-2026-001');
expect(resolve(indienen, err('boom')).tag).toBe('Mislukt');
});
});
describe('reduce (message-driven happy path)', () => {
it('drives the full flow via messages', () => {
let s: RegistratieState = initial;
s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' });
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('beroep');
s = reduce(s, { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('controle');
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Indienen');
s = reduce(s, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' });
expect(s.tag).toBe('Ingediend');
});
it('SubmitFailed then Retry returns to Indienen with the same data', () => {
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
expect(s.tag).toBe('Mislukt');
s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Indienen');
expect((s as any).data.beroep).toBe('Arts');
});
});

View File

@@ -0,0 +1,280 @@
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
/**
* A FIXED 3-step registration wizard. The steps never change in number (always
* `STEPS`): (1) adres + correspondentievoorkeur, (2) beroep o.b.v. diploma,
* (3) controle & indienen. Follow-up questions appear *inline within a step*
* (e.g. choosing 'email' reveals the e-mail field). "Is this field required
* right now" is a pure function (`validateStep`), so it is trivial to test and
* impossible to get out of sync with the data. Invariants live here, not in the
* UI: the wizard reaches `Indienen` only when a complete `ValidRegistratie` parses.
*/
export type StepId = 'adres' | 'beroep' | 'controle';
/** The fixed step list. Number of steps never changes; questions reveal inline. */
export const STEPS: StepId[] = ['adres', 'beroep', 'controle'];
/** Where a piece of data came from — recorded on the aggregate (PRD §5). */
export type AdresHerkomst = 'brp' | 'handmatig';
export type DiplomaHerkomst = 'duo' | 'handmatig';
export type Correspondentie = 'email' | 'post';
/** One record carried across every step (and persisted). All optional: the user
fills it in gradually. Adres fields are kept flat so one `SetField` message
serves them all (mirrors the intake machine). */
export interface Draft {
straat?: string;
postcode?: string;
woonplaats?: string;
adresHerkomst?: AdresHerkomst;
correspondentie?: Correspondentie;
email?: string;
diplomaId?: string;
diplomaHerkomst?: DiplomaHerkomst;
beroep?: string; // DERIVED from the chosen DUO diploma (or declared for a manual one)
vraagIds?: string[]; // ids of the policy questions that apply to the chosen diploma
antwoorden: Record<string, string>; // geldigheidsantwoorden, keyed by question id
}
/** What we have after the controle step parses — guaranteed valid/typed. */
export interface ValidRegistratie {
adres: { straat: string; postcode: Postcode; woonplaats: string };
adresHerkomst: AdresHerkomst;
correspondentie: Correspondentie;
email?: Email; // only when correspondentie === 'email'
diplomaId: string;
diplomaHerkomst: DiplomaHerkomst;
beroep: string;
antwoorden: Record<string, string>;
}
/** Text fields settable via SetField. */
export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
question id (a step can show several questions). */
export interface Errors {
straat?: string;
postcode?: string;
woonplaats?: string;
email?: string;
correspondentie?: string;
diploma?: string;
antwoorden?: Record<string, string>;
}
export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }
| { tag: 'Indienen'; data: ValidRegistratie }
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
const emptyDraft: Draft = { antwoorden: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} };
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
}
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, d: Draft): Result<Errors, void> {
const errors: Errors = {};
switch (step) {
case 'adres': {
if (!d.straat || d.straat.trim() === '') errors.straat = 'Vul een straat en huisnummer in.';
const pc = parsePostcode(d.postcode ?? '');
if (!pc.ok) errors.postcode = pc.error;
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.';
if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.';
// E-mail is only required when 'email' is the chosen channel.
if (d.correspondentie === 'email') {
const e = parseEmail(d.email ?? '');
if (!e.ok) errors.email = e.error;
}
break;
}
case 'beroep': {
// A diploma must be chosen (or declared manually); its beroep is then known.
if (!d.diplomaId || !d.beroep) {
errors.diploma = 'Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.';
break;
}
// Every policy question the chosen diploma raised must be answered. Which
// questions apply is server-decided (carried in `vraagIds`); we only check
// they're answered.
const open: Record<string, string> = {};
for (const id of d.vraagIds ?? []) {
if (!(d.antwoorden[id] ?? '').trim()) open[id] = 'Beantwoord deze vraag.';
}
if (Object.keys(open).length > 0) errors.antwoorden = open;
break;
}
case 'controle':
break; // controle shows a summary; no own fields
default:
return assertNever(step);
}
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
}
/** Parse the whole wizard into a ValidRegistratie (called on submit). */
function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
const errors: Errors = {};
for (const step of STEPS) {
const r = validateStep(step, d);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
const pc = parsePostcode(d.postcode ?? '');
// validateStep guaranteed these parse, but keep the compiler happy.
if (!pc.ok || !d.diplomaId || !d.beroep || !d.correspondentie) return err(errors);
const email = d.correspondentie === 'email' ? parseEmail(d.email ?? '') : undefined;
// Keep only the answers to the questions that actually applied.
const vraagIds = d.vraagIds ?? [];
const antwoorden = Object.fromEntries(vraagIds.map((id) => [id, d.antwoorden[id] ?? '']));
return ok({
adres: { straat: d.straat!, postcode: pc.value, woonplaats: d.woonplaats! },
adresHerkomst: d.adresHerkomst ?? 'handmatig',
correspondentie: d.correspondentie,
email: email?.ok ? email.value : undefined,
diplomaId: d.diplomaId,
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
beroep: d.beroep,
antwoorden,
});
}
export function setField(s: RegistratieState, key: DraftField, value: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
const draft: Draft = { ...s.draft, [key]: value };
// Editing an address field means the user owns it now — not the BRP copy.
if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig';
return { ...s, draft };
}
export function setCorrespondentie(s: RegistratieState, value: Correspondentie): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, correspondentie: value } };
}
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
export function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
}
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy
questions (`vraagIds`) come with it (both server-computed, passed in). */
export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} };
}
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
policy-question set applies and the entry is flagged handmatig/unverified. The
beroep is declared separately (declareerBeroep). */
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} };
}
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
export function declareerBeroep(s: RegistratieState, beroep: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, beroep } };
}
export function setAntwoord(s: RegistratieState, vraagId: string, value: string): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, draft: { ...s.draft, antwoorden: { ...s.draft.antwoorden, [vraagId]: value } } };
}
export function next(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateStep(currentStep(s), s.draft);
if (!r.ok) return { ...s, errors: r.error };
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
}
export function back(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen' || s.cursor === 0) return s;
return { ...s, cursor: s.cursor - 1, errors: {} };
}
/** Jump back to an earlier step to correct data (controle → step N). Forward
jumps are not allowed (would skip validation). Preserves the draft. */
export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieState {
if (s.tag !== 'Invullen' || cursor < 0 || cursor >= s.cursor) return s;
return { ...s, cursor, errors: {} };
}
export function submit(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateAll(s.draft);
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
}
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
if (s.tag !== 'Indienen') return s;
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };
}
export type RegistratieMsg =
| { tag: 'SetField'; key: DraftField; value: string }
| { tag: 'SetCorrespondentie'; value: Correspondentie }
| { tag: 'PrefillAdres'; straat: string; postcode: string; woonplaats: string }
| { tag: 'KiesDiploma'; diplomaId: string; beroep: string; vraagIds: string[] }
| { tag: 'KiesHandmatig'; vraagIds: string[] }
| { tag: 'DeclareerBeroep'; beroep: string }
| { tag: 'SetAntwoord'; vraagId: string; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: RegistratieState };
export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {
switch (m.tag) {
case 'SetField':
return setField(s, m.key, m.value);
case 'SetCorrespondentie':
return setCorrespondentie(s, m.value);
case 'PrefillAdres':
return prefillAdres(s, m.straat, m.postcode, m.woonplaats);
case 'KiesDiploma':
return kiesDiploma(s, m.diplomaId, m.beroep, m.vraagIds);
case 'KiesHandmatig':
return kiesHandmatig(s, m.vraagIds);
case 'DeclareerBeroep':
return declareerBeroep(s, m.beroep);
case 'SetAntwoord':
return setAntwoord(s, m.vraagId, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'GaNaarStap':
return gaNaarStap(s, m.cursor);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}

View File

@@ -34,7 +34,10 @@ export function herregistratieDeadline(reg: Registration): Date | null {
}
/** A registration may apply for herregistratie only while active and within the
window before its deadline. A struck-off or suspended registration may not. */
window before its deadline. A struck-off or suspended registration may not.
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
the reference implementation + unit test; the frontend no longer calls it. */
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
const deadline = herregistratieDeadline(reg);
if (!deadline) return false;

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { parseEmail } from './email';
describe('parseEmail', () => {
it('accepts a well-formed address and trims it', () => {
const r = parseEmail(' naam@voorbeeld.nl ');
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('naam@voorbeeld.nl');
});
it('rejects malformed addresses', () => {
expect(parseEmail('').ok).toBe(false);
expect(parseEmail('naam').ok).toBe(false);
expect(parseEmail('naam@voorbeeld').ok).toBe(false);
expect(parseEmail('naam @voorbeeld.nl').ok).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: an e-mail address. "Parse, don't validate" — an Email is a
* distinct type from a raw string, mintable only via parseEmail, so holding one
* is proof it is well-formed. Format-only check (the FE keeps format validation
* for instant feedback; the backend stays the authority — see ADR-0001).
*/
export type Email = Brand<string, 'Email'>;
export function parseEmail(raw: string): Result<string, Email> {
const t = raw.trim();
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
// for instant feedback; the server re-validates.
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
return err('Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.');
}
return ok(t as Email);
}

View File

@@ -1,19 +1,18 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Registration, Aantekening } from '../domain/registration';
import { Aantekening } from '../domain/registration';
/**
* Infrastructure adapter for the BIG-register source. Exposes signal-based
* resources (Angular's httpResource); each returns a Resource with
* status()/value()/error()/reload(). Call from an injection context
* (a field initializer in the store).
*
* Note: registration + person are now served via the aggregated dashboard-view
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
*/
@Injectable({ providedIn: 'root' })
export class BigRegisterAdapter {
registrationResource() {
return httpResource<Registration>(() => 'mock/registration.json');
}
aantekeningenResource() {
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { parseBrpAddress } from './brp.adapter';
describe('parseBrpAddress (trust boundary)', () => {
it('accepts a found address', () => {
const r = parseBrpAddress({ gevonden: true, adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' } });
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA');
});
it('accepts "geen adres" (gevonden: false) as a valid outcome', () => {
const r = parseBrpAddress({ gevonden: false });
expect(r.ok).toBe(true);
});
it('rejects malformed responses', () => {
expect(parseBrpAddress(null).ok).toBe(false);
expect(parseBrpAddress({}).ok).toBe(false); // missing gevonden
expect(parseBrpAddress({ gevonden: true }).ok).toBe(false); // found but no adres
expect(parseBrpAddress({ gevonden: true, adres: { straat: 'x' } }).ok).toBe(false);
});
});

View File

@@ -1,11 +1,34 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Person } from '../domain/person';
import { Result, ok, err } from '@shared/kernel/fp';
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */
/**
* Infrastructure adapter for the BRP address lookup, reached only through our own
* ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
* is a static mock; pointing at a real backend touches only this file + the DTO.
*/
@Injectable({ providedIn: 'root' })
export class BrpAdapter {
personResource() {
return httpResource<Person>(() => 'mock/brp.json');
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
// parseBrpAddress validates it.
adresResource() {
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
}
}
/** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a
valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;
reach for a schema lib once the contract count grows. */
export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
if (typeof json !== 'object' || json === null) return err('brp-address: not an object');
const dto = json as Partial<BrpAddressDto>;
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
if (dto.gevonden) {
const a = dto.adres;
if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') {
return err('brp-address: missing/invalid adres');
}
}
return ok({ gevonden: dto.gevonden, adres: dto.adres });
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { parseDashboardView } from './dashboard-view.adapter';
const valid = {
registration: {
bigNummer: '19012345601',
naam: 'Dr. A. de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
},
person: { naam: 'Dr. A. de Vries', geboortedatum: '1985-03-14', adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' } },
decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' },
};
describe('parseDashboardView (trust boundary)', () => {
it('maps a valid response into a DashboardView', () => {
const r = parseDashboardView(valid);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value.profile.registration.bigNummer).toBe('19012345601');
expect(r.value.decisions.eligibleForHerregistratie).toBe(true);
}
});
it('rejects malformed responses instead of trusting them', () => {
expect(parseDashboardView(null).ok).toBe(false);
expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false);
expect(parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok).toBe(false);
});
});

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Result, ok, err } from '@shared/kernel/fp';
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
/**
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
* ONE call returns registration + person + server-computed decisions.
* (In this POC the endpoint is a static mock; the decisions are precomputed to
* stand in for what the backend would compute.)
*/
@Injectable({ providedIn: 'root' })
export class DashboardViewAdapter {
// Typed as the DTO for ergonomics, but the value is still untrusted JSON —
// parseDashboardView validates it at the boundary before the app uses it.
dashboardViewResource() {
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
}
}
/**
* Trust-boundary parse: validate the untrusted response shape and map the DTO
* onto our own domain model. Hand-written on purpose — no Zod for a single
* contract. ponytail: reach for a schema lib once the contract count grows.
*/
export function parseDashboardView(json: unknown): Result<string, DashboardView> {
if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object');
const dto = json as Partial<DashboardViewDto>;
const reg = dto.registration;
if (!reg || typeof reg.bigNummer !== 'string' || !reg.status || typeof reg.status.tag !== 'string') {
return err('dashboard-view: missing/invalid registration');
}
const person = dto.person;
if (!person || !person.adres || typeof person.adres.postcode !== 'string') {
return err('dashboard-view: missing/invalid person');
}
const d = dto.decisions;
if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {
return err('dashboard-view: missing/invalid decisions');
}
return ok({
profile: { registration: reg, person },
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
});
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { parseDuoLookup } from './duo.adapter';
const valid = {
diplomas: [
{ id: 'd1', naam: 'Geneeskunde', instelling: 'Universiteit Leiden', jaar: 2011, beroep: 'Arts', policyQuestions: [] },
{ id: 'd2', naam: 'Medicine', instelling: 'University of Edinburgh', jaar: 2013, beroep: 'Arts', policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }] },
],
handmatig: {
beroepen: ['Arts', 'Verpleegkundige'],
policyQuestions: [{ id: 'toelichting', vraag: 'Toelichting', type: 'tekst' }],
},
};
describe('parseDuoLookup (trust boundary)', () => {
it('maps a valid lookup (diplomas + manual fallback)', () => {
const r = parseDuoLookup(valid);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value.diplomas).toHaveLength(2);
expect(r.value.diplomas[1].policyQuestions[0].id).toBe('nl-taal');
expect(r.value.handmatig.beroepen).toContain('Verpleegkundige');
expect(r.value.handmatig.policyQuestions[0].type).toBe('tekst');
}
});
it('accepts an empty diploma list (forces manual entry)', () => {
const r = parseDuoLookup({ diplomas: [], handmatig: valid.handmatig });
expect(r.ok).toBe(true);
});
it('rejects malformed responses', () => {
expect(parseDuoLookup(null).ok).toBe(false);
expect(parseDuoLookup({}).ok).toBe(false); // no diplomas
expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig
expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma
expect(parseDuoLookup({ diplomas: [], handmatig: { beroepen: ['Arts'], policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }] } }).ok).toBe(false); // bad question type
});
});

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Result, ok, err } from '@shared/kernel/fp';
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
/**
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
* user's diplomas (each with its server-computed beroep + policy questions) and
* the manual-entry fallback policy. The frontend renders; it does not derive. In
* this POC the endpoint is a static mock.
*/
@Injectable({ providedIn: 'root' })
export class DuoAdapter {
diplomasResource() {
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
}
}
function parseQuestions(json: unknown): PolicyQuestionDto[] | null {
if (!Array.isArray(json)) return null;
const out: PolicyQuestionDto[] = [];
for (const q of json) {
if (typeof q !== 'object' || q === null) return null;
const p = q as Partial<PolicyQuestionDto>;
if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null;
out.push({ id: p.id, vraag: p.vraag, type: p.type });
}
return out;
}
/** Trust-boundary parse: validate the untrusted response shape (diplomas, each
with derived beroep + policy questions, plus the manual-entry fallback). */
export function parseDuoLookup(json: unknown): Result<string, DuoLookupDto> {
if (typeof json !== 'object' || json === null) return err('duo-lookup: not an object');
const dto = json as Partial<DuoLookupDto>;
if (!Array.isArray(dto.diplomas)) return err('duo-lookup: missing diplomas');
const diplomas: DuoDiplomaDto[] = [];
for (const item of dto.diplomas) {
if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');
const d = item as Partial<DuoDiplomaDto>;
const vragen = parseQuestions(d.policyQuestions);
if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) {
return err('duo-lookup: missing/invalid diploma fields');
}
diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen });
}
const hm = dto.handmatig;
const hmVragen = hm ? parseQuestions(hm.policyQuestions) : null;
if (!hm || !Array.isArray(hm.beroepen) || hmVragen === null) {
return err('duo-lookup: missing/invalid handmatig fallback');
}
const handmatig: ManualDiplomaPolicyDto = { beroepen: hm.beroepen, policyQuestions: hmVragen };
return ok({ diplomas, handmatig });
}

View File

@@ -56,6 +56,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
</div>
<p style="margin-top:2rem">
<app-link to="/registreren">Inschrijven in het BIG-register (registratiewizard) →</app-link>
</p>
<p>
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
</p>
<p>

View File

@@ -0,0 +1,360 @@
import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { createStore } from '@shared/application/store';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
RegistratieMsg,
Draft,
DraftField,
Correspondentie,
StepId,
initial,
reduce,
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { submitRegistratie } from '@registratie/application/submit-registratie';
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
draft via an effect; the DUO diploma list renders through <app-async>; choosing
a diploma reveals its server-derived beroep. The draft is persisted to
localStorage so a reload keeps the user's progress. Built entirely from existing
atoms/molecules. */
@Component({
selector: 'app-registratie-wizard',
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, ...ASYNC,
],
template: `
@switch (state().tag) {
@case ('Invullen') {
<app-stepper class="app-section" [steps]="stepLabels" [current]="cursor()" />
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
<form (ngSubmit)="onPrimary()" class="app-form">
@switch (step()) {
@case ('adres') {
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
}
@case ('geen') {
<app-alert type="warning">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
}
@case ('fout') {
<app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
}
}
<app-form-field label="Straat en huisnummer" fieldId="straat" [error]="err('straat')">
<app-text-input inputId="straat" [invalid]="!!err('straat')" [ngModel]="draft().straat ?? ''" (ngModelChange)="set('straat', $event)" name="straat" />
</app-form-field>
<app-form-field label="Postcode" fieldId="postcode" [error]="err('postcode')">
<app-text-input inputId="postcode" [invalid]="!!err('postcode')" [ngModel]="draft().postcode ?? ''" (ngModelChange)="set('postcode', $event)" name="postcode" placeholder="1234 AB" />
</app-form-field>
<app-form-field label="Woonplaats" fieldId="woonplaats" [error]="err('woonplaats')">
<app-text-input inputId="woonplaats" [invalid]="!!err('woonplaats')" [ngModel]="draft().woonplaats ?? ''" (ngModelChange)="set('woonplaats', $event)" name="woonplaats" />
</app-form-field>
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')">
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
</app-form-field>
@if (draft().correspondentie === 'email') {
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')">
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
</app-form-field>
}
}
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data>
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" [error]="err('diploma')">
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
<app-form-field label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
</app-form-field>
} @else if (draft().beroep) {
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
</dl>
}
@for (q of actieveVragen($any(data)); track q.id) {
<app-form-field [label]="q.vraag" [fieldId]="'vraag-' + q.id" [error]="vraagErr(q.id)">
@if (q.type === 'ja-nee') {
<app-radio-group [name]="'vraag-' + q.id" [options]="jaNee" [invalid]="!!vraagErr(q.id)"
[ngModel]="draft().antwoorden[q.id] ?? ''" (ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
} @else {
<app-text-input [inputId]="'vraag-' + q.id" [invalid]="!!vraagErr(q.id)" [ngModel]="draft().antwoorden[q.id] ?? ''"
(ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
}
</app-form-field>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
}
@case ('controle') {
<app-alert type="info">Controleer uw gegevens en dien de registratie in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Adres" [value]="adresSamenvatting()" />
<app-data-row key="Herkomst adres" [value]="adresHerkomstLabel()" />
<app-data-row key="Correspondentie" [value]="correspondentieLabel()" />
@if (draft().correspondentie === 'email') {
<app-data-row key="E-mailadres" [value]="draft().email ?? ''" />
}
<app-data-row key="Beroep" [value]="draft().beroep ?? ''" />
<app-data-row key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
@for (item of samenvattingVragen(); track item.vraag) {
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
}
</dl>
<div class="app-button-row app-section">
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">Adres wijzigen</app-button>
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">Diploma wijzigen</app-button>
</div>
}
}
<div class="app-button-row app-button-row--spaced">
@if (cursor() > 0) {
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
}
<app-button type="submit" variant="primary">{{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
</form>
}
@case ('Indienen') {
<app-spinner /> <span>Uw registratie wordt verwerkt…</span>
}
@case ('Ingediend') {
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
</div>
}
@case ('Mislukt') {
<app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div>
}
}
`,
})
export class RegistratieWizardComponent {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
protected adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN;
readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper
private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
/** Focus target: the step heading receives focus on step change (a11y). */
private stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
protected adresSamenvatting = computed(() => {
const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
});
// Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']));
protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */
private duoData = computed<DuoLookupDto | null>(() => {
const rd = this.lookupRd();
return rd.tag === 'Success' ? rd.value : null;
});
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
/** True while the user is entering a diploma manually (not in the DUO list). */
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));
protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam}${d.instelling} (${d.jaar})` })),
{ value: HANDMATIG, label: 'Mijn diploma staat er niet bij' },
];
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
/** The policy questions that apply to the current choice (server-decided). */
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
if (this.handmatigActief()) return data.handmatig.policyQuestions;
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
};
/** Answered policy questions for the controle summary (question text + answer). */
protected samenvattingVragen = computed(() => {
const data = this.duoData();
const d = this.draft();
if (!data) return [] as { vraag: string; antwoord: string }[];
const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];
return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));
});
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
if (id === HANDMATIG) {
this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) });
return;
}
const d = data.diplomas.find((x) => x.id === id);
if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) });
}
constructor() {
// An explicit seed (stories) wins; otherwise resume from localStorage.
const seeded = this.seed();
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
// Persist only while filling in; clear once the flow is done.
effect(() => {
const s = this.state();
if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
});
// Prefill the address from the BRP lookup as it arrives. Track only the resource
// value; untrack the dispatch (it reads the state signal, which would otherwise
// make this effect loop on its own write). Don't clobber edits/restored data.
effect(() => {
const json = this.adresRes.value();
if (!json) return;
const parsed = parseBrpAddress(json);
untracked(() => {
const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return;
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
const a = parsed.value.adres;
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
}
});
});
// A11y: move focus to the step heading when the STEP changes (depends only on
// cursor(), which is value-stable across keystrokes — so typing never steals
// focus). Skip the first run so we don't grab focus on initial load.
let firstStep = true;
effect(() => {
this.cursor();
if (firstStep) { firstStep = false; return; }
untracked(() => {
if (this.state().tag !== 'Invullen') return;
queueMicrotask(() => this.stepHeading()?.nativeElement.focus());
});
});
}
private restore(): RegistratieState | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as RegistratieState;
} catch {
return null; // ponytail: corrupt entry -> start fresh, no migration.
}
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Invullen') return;
this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' });
this.runIfIndienen();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfIndienen();
}
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
restart() {
this.dispatch({ tag: 'Seed', state: initial });
this.adresRes.reload();
}
/** The effect: when we enter Indienen, call the backend, then dispatch the
outcome (success carries the confirmation reference). */
private async runIfIndienen() {
const s = this.state();
if (s.tag !== 'Indienen') return;
const r = await submitRegistratie(s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { RegistratieWizardComponent } from './registratie-wizard.component';
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial<Draft> = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' };
const filled: Partial<Draft> = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] };
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} });
const validData: ValidRegistratie = {
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
adresHerkomst: 'brp',
correspondentie: 'post',
diplomaId: 'd1',
diplomaHerkomst: 'duo',
beroep: 'Arts',
antwoorden: {},
};
const meta: Meta<RegistratieWizardComponent> = {
title: 'Registratie/RegistratieWizard',
component: RegistratieWizardComponent,
decorators: [applicationConfig({ providers: [provideHttpClient()] })],
};
export default meta;
type Story = StoryObj<RegistratieWizardComponent>;
export const Adres: Story = { args: { seed: invullen(adres, 0) } };
export const Beroep: Story = { args: { seed: invullen(filled, 1) } };
/** English-language diploma → the Dutch-proficiency policy question appears. */
export const BeroepEngelstalig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'd2', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: ['nl-taalvaardigheid'] }, 1) } };
/** Diploma not in DUO → declare beroep + the maximal policy-question set. */
export const BeroepHandmatig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'handmatig', diplomaHerkomst: 'handmatig', vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'] }, 1) } };
export const Controle: Story = { args: { seed: invullen(filled, 2) } };
export const Indienen: Story = { args: { seed: { tag: 'Indienen', data: validData } } };
export const Ingediend: Story = { args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } } };
export const Mislukt: Story = { args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } } };

View File

@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';
/** Page: register in the BIG-register. Built entirely from existing building
blocks (page shell + alert + the registratie-wizard organism). */
@Component({
selector: 'app-registratie-page',
imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],
template: `
<app-page-shell
heading="Inschrijven in het BIG-register"
backLink="/dashboard"
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
<app-alert type="info">
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
als u de pagina herlaadt.
</app-alert>
<div class="app-section">
<app-registratie-wizard />
</div>
</app-page-shell>
`,
})
export class RegistratiePage {}