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,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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user