Restructure into DDD bounded contexts + functional state management
Reorganise from atomic-design-only folders into bounded contexts (auth / registratie / herregistratie) over a shared kernel, each split into domain / application / infrastructure / ui layers. Dependencies point inward; the domain layer is framework-free. Path aliases (@shared/@auth/@registratie/ @herregistratie) make import direction explicit. State management (Elm-style, native TS, no new deps): - shared/application/store.ts — createStore(init, update): pure reducer + signal - shared/application/remote-data.ts — add map/map2/map3/andThen combinators so several services fold into one RemoteData; <app-async> gains an [rd] input - registratie/application/big-profile.store.ts — root singleton combining the BIG-register and BRP services via map2 into one state; holds the optimistic herregistratie flag shared with the dashboard - herregistratie: machine gains a WizardMsg union + pure reduce; submit is a command that calls infra and dispatches the result, with optimistic update + rollback against the shared store - auth: SessionStore + DigiD adapter + functional route guard; login establishes the session, protected routes use canActivate Rich domain: registration.policy.ts (statusColor/label, herregistratie eligibility, invariants); BigNummer/Postcode/Uren value objects with smart constructors. status-badge is now domain-free (colour/label inputs). Specs for the reducer, RemoteData combinators, and eligibility policy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). */
|
||||
export interface Draft {
|
||||
uren: string;
|
||||
punten: string;
|
||||
}
|
||||
|
||||
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
|
||||
export interface Valid {
|
||||
uren: Uren;
|
||||
punten: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
|
||||
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
|
||||
* So "submitting while a field is invalid" or "showing the success screen with
|
||||
* errors set" are unrepresentable — the bug class is gone by construction.
|
||||
*/
|
||||
export type WizardState =
|
||||
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial<Record<keyof Draft, string>> }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Submitted'; data: Valid }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
|
||||
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} };
|
||||
|
||||
/** Parse every field; on success hand back a Valid, else the per-field errors. */
|
||||
function validate(draft: Draft): Result<Partial<Record<keyof Draft, string>>, Valid> {
|
||||
const uren = parseUren(draft.uren);
|
||||
const punten = parseUren(draft.punten);
|
||||
const errors: Partial<Record<keyof Draft, string>> = {};
|
||||
if (!uren.ok) errors.uren = uren.error;
|
||||
if (!punten.ok) errors.punten = punten.error;
|
||||
if (uren.ok && punten.ok) return { ok: true, value: { uren: uren.value, punten: punten.value } };
|
||||
return { ok: false, error: errors };
|
||||
}
|
||||
|
||||
/** Step 1 → 2: only advance if the uren field parses. Illegal elsewhere = no-op. */
|
||||
export function next(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing' || s.step !== 1) return s;
|
||||
const uren = parseUren(s.draft.uren);
|
||||
return uren.ok
|
||||
? { ...s, step: 2, errors: {} }
|
||||
: { ...s, errors: { uren: uren.error } };
|
||||
}
|
||||
|
||||
export function back(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing' || s.step !== 2) return s;
|
||||
return { ...s, step: 1, errors: {} };
|
||||
}
|
||||
|
||||
/** Step 2 submit: parse everything; move to Submitting only with Valid data. */
|
||||
export function submit(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing' || s.step !== 2) return s;
|
||||
const result = validate(s.draft);
|
||||
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
||||
}
|
||||
|
||||
/** Resolve the async submit. Only meaningful while Submitting. */
|
||||
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
/** Update one draft field while editing; ignored in any other state. */
|
||||
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
return { ...s, draft: { ...s.draft, [key]: value } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event that can happen to the wizard, as one message type. The component
|
||||
* sends a WizardMsg; `reduce` decides the next state. This is the Elm
|
||||
* Model+Msg+update pattern: ONE pure function describes all state changes.
|
||||
*/
|
||||
export type WizardMsg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
|
||||
|
||||
export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return setField(s, m.key, m.value);
|
||||
case 'Next':
|
||||
return next(s);
|
||||
case 'Back':
|
||||
return back(s);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user