Files
atomic-design-poc/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts
T
ehoandClaude Opus 5 9520d6c24e refactor(shared): move upload/ into infrastructure/domain/application (RB-24)
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.

Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.

Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.

npm run ci passes; dep:check is clean for both apps with the carve-out gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:41 +02:00

361 lines
14 KiB
TypeScript

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/domain/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<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>;
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<string, string>;
}
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<RegistratieState, { tag: 'Invullen' }>): 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<RegistratieState, { tag: 'Invullen' }>): 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<Errors, void> {
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<string, string> = {};
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<Errors, ValidRegistratie> {
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<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: '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);
}
}