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

@@ -3,7 +3,8 @@ import { ok, err } from '@shared/kernel/fp';
import {
Answers,
initial,
visibleSteps,
STEPS,
lageUren,
currentStep,
next,
back,
@@ -13,21 +14,34 @@ import {
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} });
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold });
describe('visibleSteps (the branching)', () => {
it('asks only buitenland/uren/punten/review by default', () => {
expect(visibleSteps({})).toEqual(['buitenland', 'uren', 'punten', 'review']);
describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => {
expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
});
it('adds the buitenlandDetails step when worked abroad', () => {
expect(visibleSteps({ buitenlandGewerkt: 'ja' })).toContain('buitenlandDetails');
expect(visibleSteps({ buitenlandGewerkt: 'nee' })).not.toContain('buitenlandDetails');
it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
expect((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy();
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1);
});
it('adds the scholing step only when NL-hours are below the threshold', () => {
expect(visibleSteps({ uren: '500' })).toContain('scholing');
expect(visibleSteps({ uren: '4160' })).not.toContain('scholing');
it('reveals the scholing question only when NL-hours are below the threshold', () => {
expect(lageUren({ uren: '500' })).toBe(true);
expect(lageUren({ uren: '4160' })).toBe(false);
});
it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => {
// Same hours, different threshold → different visibility. Proves de-hardcoding.
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit:
const lowThreshold = submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000));
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy();
});
});
@@ -36,20 +50,18 @@ describe('navigation', () => {
const s = next(initial); // buitenland unanswered
expect(s.tag).toBe('Answering');
expect((s as any).cursor).toBe(0);
expect((s as any).errors.buitenland).toBeTruthy();
expect((s as any).errors.buitenlandGewerkt).toBeTruthy();
});
it('Next advances once the step is valid', () => {
const s = next(answering({ buitenlandGewerkt: 'nee' }));
expect((s as any).cursor).toBe(1);
expect(currentStep(s as any)).toBe('uren');
expect(currentStep(s as any)).toBe('werk');
});
it('keeps the cursor in range when an answer collapses a branch', () => {
// Worked abroad, cursor sitting on the extra detail step (index 1)...
const collapsed = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
// ...the detail step is gone; cursor must not point past the new shorter list.
expect((collapsed as any).cursor).toBeLessThan(visibleSteps((collapsed as any).answers).length);
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
});
it('Back never goes below the first step', () => {
@@ -58,21 +70,34 @@ describe('navigation', () => {
});
describe('submit', () => {
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160', punten: '200' };
// High hours: no scholing question, so no punten is asked or collected.
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
it('reaches Submitting ONLY with valid answers', () => {
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: 'x' })).tag).toBe('Answering');
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' })).tag).toBe('Answering');
const good = submit(answering(complete));
expect(good.tag).toBe('Submitting');
expect((good as any).data.uren).toBe(4160);
expect((good as any).data.punten).toBeUndefined(); // not collected without scholing
});
it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten.
const missing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }));
expect(missing.tag).toBe('Answering');
expect((missing as any).errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it.
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag).toBe('Submitting');
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', punten: '200' }));
expect(noScholing.tag).toBe('Answering'); // scholing step is required, unanswered
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }));
expect(withScholing.tag).toBe('Submitting');
expect((withScholing as any).data.aanvullendeScholing).toBe(true);
expect((withScholing as any).data.punten).toBe(200);
});
it('resolve maps Submitting to Submitted / Failed', () => {
@@ -85,16 +110,14 @@ describe('submit', () => {
describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => {
let s: IntakeState = initial;
// Step 1: buitenland — the country/hours questions reveal inline (same step).
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('buitenlandDetails');
s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' });
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('uren');
expect(currentStep(s as any)).toBe('werk');
// Step 2: werk — uren + punten (no inline scholing, hours are high).
s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('punten');
s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('review');