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
+12
View File
@@ -0,0 +1,12 @@
import { Registration } from './registration';
import { Person } from './person';
/**
* The view the dashboard/detail render: a registration (from the BIG-register)
* enriched with person data (from the BRP). It only exists when BOTH sources
* have loaded — see BigProfileStore, which builds it with map2.
*/
export interface BigProfile {
registration: Registration;
person: Person;
}
+12
View File
@@ -0,0 +1,12 @@
/** Person identity as supplied by the BRP (Basisregistratie Personen). */
export interface Adres {
straat: string;
postcode: string;
woonplaats: string;
}
export interface Person {
naam: string;
geboortedatum: string; // ISO date
adres: Adres;
}
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { Registration } from './registration';
import { isHerregistratieEligible, statusColor } from './registration.policy';
const reg = (status: Registration['status']): Registration => ({
bigNummer: '19012345601', naam: 'Test', beroep: 'Arts',
registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', status,
});
describe('registration.policy', () => {
it('only an active registration within the window is eligible', () => {
const active = reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' });
expect(isHerregistratieEligible(active, new Date('2026-06-01'))).toBe(true); // within 12 months
expect(isHerregistratieEligible(active, new Date('2020-01-01'))).toBe(false); // too early
});
it('struck-off / suspended registrations are never eligible', () => {
expect(isHerregistratieEligible(reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
expect(isHerregistratieEligible(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
});
it('statusColor is total over the union', () => {
expect(statusColor('Geregistreerd')).toContain('groen');
expect(statusColor('Doorgehaald')).toContain('rood');
expect(statusColor('Geschorst')).toContain('oranje');
});
});
@@ -0,0 +1,51 @@
import { assertNever } from '@shared/kernel/fp';
import { Registration, RegistrationStatus, StatusTag } from './registration';
/**
* Domain logic for a registration — pure functions, NO Angular. This is where
* "what the business rules say" lives, separate from "how it looks" (UI) and
* "where the data comes from" (infrastructure). Keeping it framework-free means
* it is trivial to read and unit-test.
*/
/** Human-readable label for a status. */
export function statusLabel(tag: StatusTag): string {
return tag; // the tag already reads as Dutch; kept as a function so labels can diverge later
}
/** Brand colour token for a status. assertNever forces a colour for every new
status variant at compile time. */
export function statusColor(tag: StatusTag): string {
switch (tag) {
case 'Geregistreerd':
return 'var(--rhc-color-groen-500)';
case 'Doorgehaald':
return 'var(--rhc-color-rood-500)';
case 'Geschorst':
return 'var(--rhc-color-oranje-500)';
default:
return assertNever(tag);
}
}
/** The herregistratie deadline, if the status has one (only the active state does). */
export function herregistratieDeadline(reg: Registration): Date | null {
return reg.status.tag === 'Geregistreerd' ? new Date(reg.status.herregistratieDatum) : null;
}
/** A registration may apply for herregistratie only while active and within the
window before its deadline. A struck-off or suspended registration may not. */
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
const deadline = herregistratieDeadline(reg);
if (!deadline) return false;
const windowStart = new Date(deadline);
windowStart.setMonth(windowStart.getMonth() - windowMonths);
return today >= windowStart;
}
/** Invariant check used in tests/demos: a non-active status must not carry a
herregistratie date. The union already enforces this structurally; this is
the runtime statement of the same rule. */
export function isStatusConsistent(status: RegistrationStatus): boolean {
return status.tag === 'Geregistreerd' ? typeof status.herregistratieDatum === 'string' : true;
}
@@ -0,0 +1,29 @@
/**
* Registration status as a discriminated union: each variant owns exactly the
* data that makes sense for it. Only an active (Geregistreerd) registration has
* a herregistratie date; a struck-off (Doorgehaald) one cannot carry one. The
* old flat interface allowed that impossible combination — this makes it
* unrepresentable.
*/
export type RegistrationStatus =
| { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
/** Just the discriminant — for atoms that only need the label/color. */
export type StatusTag = RegistrationStatus['tag'];
export interface Registration {
bigNummer: string;
naam: string;
beroep: string; // arts, verpleegkundige, apotheker, ...
registratiedatum: string; // ISO date
geboortedatum: string;
status: RegistrationStatus;
}
export interface Aantekening {
type: string; // specialisme of aantekening
omschrijving: string;
datum: string;
}
@@ -0,0 +1,9 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/** Value object: a BIG registration number — 11 digits. */
export type BigNummer = Brand<string, 'BigNummer'>;
export function parseBigNummer(raw: string): Result<string, BigNummer> {
const t = raw.trim();
return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err('Een BIG-nummer bestaat uit 11 cijfers.');
}
@@ -0,0 +1,17 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: a Dutch postcode. "Parse, don't validate" — a Postcode is a
* distinct type from a raw string, mintable only via parsePostcode, so holding
* one is proof it is well-formed.
*/
export type Postcode = Brand<string, 'Postcode'>;
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
return err('Voer een geldige postcode in, bijv. 1234 AB.');
}
// Normalise to "1234 AB" — the parser also cleans up.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
}
@@ -0,0 +1,14 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/** Value object: a non-negative whole number of hours. */
export type Uren = Brand<number, 'Uren'>;
export function parseUren(raw: string): Result<string, Uren> {
const t = raw.trim();
const n = Number(t);
// Number('') is 0 — guard the empty string explicitly.
if (t === '' || !Number.isInteger(n) || n < 0) {
return err('Vul een geheel aantal in (0 of meer).');
}
return ok(n as Uren);
}