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>
361 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|