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'; import { UploadState, UploadMsg, DeliveryChannel, initialUpload, reduceUpload, requiredCategoriesSatisfied, deliveryRefs, } from '@shared/upload/upload.machine'; /** * 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; // 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; documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: 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; documenten?: string; antwoorden?: Record; } export type RegistratieState = | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState } | { 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: {}, upload: initialUpload }; /** Which step the cursor currently points at (clamped to the fixed list). */ export function currentStep(s: Extract): StepId { return STEPS[Math.min(s.cursor, STEPS.length - 1)]; } /** Has the user meaningfully started, so it's worth persisting as a Concept? Excludes the automatic BRP address prefill on step 0 — a bare page visit creates nothing. ponytail: an address typed at step 0 without any of these signals is not yet persisted (created once they advance/choose); accepted regression vs. sessionStorage. */ export function hasProgress(s: Extract): boolean { const d = s.draft; return ( s.cursor > 0 || !!d.correspondentie || !!d.email || !!d.diplomaId || !!d.beroep || deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId) ); } /** Validate every question currently visible in ONE step. Errors keyed per field. */ function validateStep(step: StepId, d: Draft, upload: UploadState): Result { const errors: Errors = {}; switch (step) { case 'adres': { if (!d.straat || d.straat.trim() === '') errors.straat = $localize`:@@validation.straat2: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 = $localize`:@@validation.woonplaats:Vul een woonplaats in.`; if (!d.correspondentie) errors.correspondentie = $localize`:@@validation.maakKeuze: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 = $localize`:@@validation.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 = {}; for (const id of d.vraagIds ?? []) { if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`; } if (Object.keys(open).length > 0) errors.antwoorden = open; // Required documents for this wizard attach to the beroep step (inline upload). if (!requiredCategoriesSatisfied(upload)) { errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`; } 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, upload: UploadState): Result { const errors: Errors = {}; for (const step of STEPS) { const r = validateStep(step, d, upload); 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, documents: deliveryRefs(upload), }); } 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, s.upload); 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, s.upload); return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error }; } /** Route an upload sub-message through the pure upload reducer (Invullen only). */ export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { if (s.tag !== 'Invullen') return s; return { ...s, upload: reduceUpload(s.upload, msg) }; } export function resolve(s: RegistratieState, r: Result): 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: 'Upload'; msg: UploadMsg } | { 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 'Upload': return upload(s, m.msg); case 'Seed': return m.state; default: return assertNever(m); } }