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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user