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

@@ -2,18 +2,19 @@ import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
/**
* A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of
* steps here is NOT stored — it's *derived* from the answers by `visibleSteps`.
* Answer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few
* hours and a scholing-question appears. The progress bar's denominator changes
* as you type. "Which question comes next" is a pure function, so it's trivial
* to test and impossible to get out of sync with the data.
* A FIXED 3-step wizard with progressive disclosure. The steps never change in
* number (always `STEPS`); instead, follow-up questions appear *inline within a
* step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja"
* and the country/hours questions reveal in the same step; report few hours and
* the scholing-question reveals inside the 'werk' step. "Is this field required
* right now" is a pure function (`validateStep`/`lageUren`), so it's trivial to
* test and impossible to get out of sync with the data.
*/
export type JaNee = 'ja' | 'nee';
/** Every possible question, as a union — never a bare string. */
export type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | 'review';
/** The three fixed steps. Each step groups one or more questions. */
export type StepId = 'buitenland' | 'werk' | 'review';
/** One record carried across every step (and persisted). All optional: the user
fills it in gradually, and branches may never ask some fields. */
@@ -33,111 +34,115 @@ export interface ValidIntake {
buitenlandseUren?: Uren;
uren: Uren;
aanvullendeScholing?: boolean;
punten: Uren;
punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')
}
/** Below this many NL-hours we ask whether extra scholing was followed. */
const LAGE_UREN_DREMPEL = 1000;
/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched
at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline
fallback; the server value wins. */
export const SCHOLING_THRESHOLD_DEFAULT = 1000;
function lageUren(a: Answers): boolean {
/** True when NL-hours are low enough that the scholing question must be answered.
The threshold is passed in (server-owned), not hardcoded. */
export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {
const r = parseUren(a.uren ?? '');
return r.ok && r.value < LAGE_UREN_DREMPEL;
return r.ok && r.value < scholingThreshold;
}
/**
* THE branching, as one pure function. The step list is recomputed on every
* transition, so changing an earlier answer immediately adds/removes later steps.
*/
export function visibleSteps(a: Answers): StepId[] {
const steps: StepId[] = ['buitenland'];
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
steps.push('uren');
if (lageUren(a)) steps.push('scholing');
steps.push('punten', 'review');
return steps;
}
/** The fixed step list. Number of steps never changes; questions reveal inline. */
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
/** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState =
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Partial<Record<StepId, string>> }
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number }
| { tag: 'Submitting'; data: ValidIntake }
| { tag: 'Submitted'; data: ValidIntake }
| { tag: 'Failed'; data: ValidIntake; error: string };
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {} };
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT };
/** Which step the cursor currently points at (clamped to the live step list). */
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
const steps = visibleSteps(s.answers);
return steps[Math.min(s.cursor, steps.length - 1)];
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
}
/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */
function validateStep(step: StepId, a: Answers): Result<Partial<Record<StepId, string>>, void> {
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result<Errors, void> {
const errors: Errors = {};
switch (step) {
case 'buitenland':
return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' });
case 'buitenlandDetails': {
if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' });
const u = parseUren(a.buitenlandseUren ?? '');
return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error });
}
case 'uren': {
if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.';
else if (a.buitenlandGewerkt === 'ja') {
if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.';
const u = parseUren(a.buitenlandseUren ?? '');
if (!u.ok) errors.buitenlandseUren = u.error;
}
break;
case 'werk': {
const u = parseUren(a.uren ?? '');
return u.ok ? ok(undefined) : err({ uren: u.error });
}
case 'scholing':
return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' });
case 'punten': {
const p = parseUren(a.punten ?? '');
return p.ok ? ok(undefined) : err({ punten: p.error });
if (!u.ok) errors.uren = u.error;
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.';
// Nascholingspunten are only asked (and required) when scholing was followed.
if (a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error;
}
break;
}
case 'review':
return ok(undefined); // review shows a summary; no own fields
break; // review shows a summary; no own fields
default:
return assertNever(step);
}
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
}
/** Parse the whole questionnaire into a ValidIntake (called on submit). */
function validateAll(a: Answers): Result<Partial<Record<StepId, string>>, ValidIntake> {
const errors: Partial<Record<StepId, string>> = {};
for (const step of visibleSteps(a)) {
const r = validateStep(step, a);
function validateAll(a: Answers, scholingThreshold: number): Result<Errors, ValidIntake> {
const errors: Errors = {};
for (const step of STEPS) {
const r = validateStep(step, a, scholingThreshold);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
const uren = parseUren(a.uren ?? '');
const punten = parseUren(a.punten ?? '');
// visibleSteps guaranteed these parse, but keep the compiler happy.
if (!uren.ok || !punten.ok) return err(errors);
// validateStep guaranteed uren parses, but keep the compiler happy.
if (!uren.ok) return err(errors);
const werktBuitenland = a.buitenlandGewerkt === 'ja';
const buitenland = parseUren(a.buitenlandseUren ?? '');
// Punten are only collected when aanvullende scholing was gevolgd.
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
return ok({
werktBuitenland,
land: werktBuitenland ? a.land : undefined,
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
uren: uren.value,
aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined,
punten: punten.value,
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
punten: punten?.ok ? punten.value : undefined,
});
}
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
if (s.tag !== 'Answering') return s;
const answers = { ...s.answers, [key]: value };
// Editing an earlier answer can shrink the step list; clamp the cursor.
const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1);
return { ...s, answers, cursor };
// Steps are fixed, so editing an answer never moves the cursor — it only
// reveals/hides inline questions within the current step.
return { ...s, answers: { ...s.answers, [key]: value } };
}
export function next(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateStep(currentStep(s), s.answers);
const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);
if (!r.ok) return { ...s, errors: r.error };
const last = visibleSteps(s.answers).length - 1;
return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} };
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
}
/** Apply a server-owned policy value (e.g. the scholing threshold). */
export function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState {
return s.tag === 'Answering' ? { ...s, scholingThreshold } : s;
}
export function back(s: IntakeState): IntakeState {
@@ -147,7 +152,7 @@ export function back(s: IntakeState): IntakeState {
export function submit(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
const r = validateAll(s.answers);
const r = validateAll(s.answers, s.scholingThreshold);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
@@ -164,6 +169,7 @@ export type IntakeMsg =
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'SetPolicy'; scholingThreshold: number }
| { tag: 'Seed'; state: IntakeState };
export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
@@ -182,6 +188,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'SetPolicy':
return setPolicy(s, m.scholingThreshold);
case 'Seed':
return m.state;
default: