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:
eho
2026-06-26 07:20:13 +02:00
co-authored by Claude Opus 4.8
parent 6bd6e854c7
commit 2114514ad7
74 changed files with 841 additions and 347 deletions
@@ -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);
}
}