Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs
Checkpoint of in-progress work: the registration wizard (address prefill, DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't- validate value objects, infrastructure adapters, plus CLAUDE.md and the architecture/ADR docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { RemoteData, fromResource, map2 } from '@shared/application/remote-data';
|
||||
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
|
||||
import { Aantekening } from '../domain/registration';
|
||||
import { BigProfile } from '../domain/big-profile';
|
||||
import { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto';
|
||||
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
|
||||
import { BrpAdapter } from '../infrastructure/brp.adapter';
|
||||
import { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
@@ -13,29 +14,32 @@ type Err = Error | undefined;
|
||||
* (created here, in the required injection context) and exposes them as
|
||||
* RemoteData signals.
|
||||
*
|
||||
* The headline trick: `profile` combines TWO independent services — the
|
||||
* BIG-register and the BRP — into ONE RemoteData via map2. A page renders a
|
||||
* single state (loading / error / loaded), never juggling three.
|
||||
* The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
|
||||
* returns registration + person + server-computed `decisions`. One request → one
|
||||
* consistent snapshot, instead of stitching three independently loading/erroring
|
||||
* resources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BigProfileStore {
|
||||
private big = inject(BigRegisterAdapter);
|
||||
private brp = inject(BrpAdapter);
|
||||
private viewAdapter = inject(DashboardViewAdapter);
|
||||
|
||||
private registrationRes = this.big.registrationResource();
|
||||
private viewRes = this.viewAdapter.dashboardViewResource();
|
||||
private aantekeningenRes = this.big.aantekeningenResource();
|
||||
private personRes = this.brp.personResource();
|
||||
|
||||
/** BIG-register + BRP folded into one state. */
|
||||
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
|
||||
map2(
|
||||
fromResource(this.registrationRes),
|
||||
fromResource(this.personRes),
|
||||
// httpResource types value as T | undefined; in the Success branch it is
|
||||
// always present, so narrowing here is safe.
|
||||
(registration, person) => ({ registration: registration!, person: person! }),
|
||||
),
|
||||
);
|
||||
/** The aggregated view, validated at the trust boundary (DTO → domain). */
|
||||
private view = computed<RemoteData<Err, DashboardView>>(() => {
|
||||
const rd = fromResource(this.viewRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDashboardView(rd.value);
|
||||
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Registration + person, from the single aggregated call. */
|
||||
readonly profile = computed<RemoteData<Err, BigProfile>>(() => map(this.view(), (v) => v.profile));
|
||||
|
||||
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
|
||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
||||
|
||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
|
||||
@@ -52,7 +56,7 @@ export class BigProfileStore {
|
||||
}
|
||||
confirmHerregistratie() {
|
||||
this.pending.set(false);
|
||||
this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration
|
||||
this.viewRes.reload(); // invalidate: re-fetch the now-updated view (registration + decisions)
|
||||
}
|
||||
rollbackHerregistratie() {
|
||||
this.pending.set(false); // submission failed — undo the optimistic flag
|
||||
|
||||
19
src/app/registratie/application/submit-registratie.ts
Normal file
19
src/app/registratie/application/submit-registratie.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
||||
|
||||
/**
|
||||
* Command: send the completed registration to the backend, which invokes the
|
||||
* domain command to create/finalize the registration aggregate. Returns a Result
|
||||
* so the caller branches on success/failure without try/catch; on success it
|
||||
* yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap
|
||||
* for a real POST when there's an API. The "manually entered diploma" rule lets
|
||||
* the demo show the failure path.
|
||||
*/
|
||||
export async function submitRegistratie(data: ValidRegistratie): Promise<Result<string, string>> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
if (data.diplomaHerkomst === 'handmatig') {
|
||||
return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.');
|
||||
}
|
||||
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
|
||||
return ok(referentie);
|
||||
}
|
||||
15
src/app/registratie/contracts/brp-address.dto.ts
Normal file
15
src/app/registratie/contracts/brp-address.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call).
|
||||
*
|
||||
* In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our
|
||||
* own backend, which talks to the BRP behind an adapter. The frontend never sees
|
||||
* the BRP's own wire format. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*
|
||||
* "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error —
|
||||
* the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy
|
||||
* path (gevonden: true).
|
||||
*/
|
||||
export interface BrpAddressDto {
|
||||
gevonden: boolean;
|
||||
adres?: { straat: string; postcode: string; woonplaats: string };
|
||||
}
|
||||
36
src/app/registratie/contracts/dashboard-view.dto.ts
Normal file
36
src/app/registratie/contracts/dashboard-view.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { Person } from '@registratie/domain/person';
|
||||
import { BigProfile } from '@registratie/domain/big-profile';
|
||||
|
||||
/**
|
||||
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
|
||||
*
|
||||
* In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source
|
||||
* of truth for both sides), and the `decisions` block is computed BY THE BACKEND
|
||||
* — never recomputed on the client. The frontend renders decisions; it does not
|
||||
* own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
||||
*
|
||||
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
|
||||
* so the page always sees one consistent snapshot instead of three independently
|
||||
* loading/erroring resources.
|
||||
*/
|
||||
export interface DashboardViewDto {
|
||||
registration: Registration;
|
||||
person: Person;
|
||||
decisions: HerregistratieDecisions;
|
||||
}
|
||||
|
||||
/** Server-computed decisions. The eligibility rule lives on the backend; the
|
||||
optional reason lets the UI explain itself without knowing the rule. */
|
||||
export interface HerregistratieDecisions {
|
||||
eligibleForHerregistratie: boolean;
|
||||
herregistratieReason?: string;
|
||||
}
|
||||
|
||||
/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping
|
||||
this distinct from DashboardViewDto is the decoupling seam: the wire shape can
|
||||
change without the FE domain following, and vice-versa. */
|
||||
export interface DashboardView {
|
||||
profile: BigProfile;
|
||||
decisions: HerregistratieDecisions;
|
||||
}
|
||||
38
src/app/registratie/contracts/duo-diplomas.dto.ts
Normal file
38
src/app/registratie/contracts/duo-diplomas.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* WIRE CONTRACT for the DUO diploma lookup ("BFF-lite" — one screen-shaped call
|
||||
* returning everything the beroep step needs).
|
||||
*
|
||||
* Each diploma carries its server-computed `beroep` (the profession it maps to)
|
||||
* and the `policyQuestions` (geldigheidsvragen) that apply to it. These are
|
||||
* DECISIONS computed by the backend from the diploma's attributes — the frontend
|
||||
* renders them, it does not derive them (decision-DTO pattern, ADR-0001). E.g. an
|
||||
* English-language diploma carries the Dutch-proficiency question.
|
||||
*
|
||||
* `handmatig` is the fallback when the diploma is not in the DUO list: the
|
||||
* professions the user may declare and the MAXIMAL policy-question set that then
|
||||
* applies (a manual diploma is unverified, so the strictest set is used).
|
||||
*/
|
||||
export interface DuoLookupDto {
|
||||
diplomas: DuoDiplomaDto[];
|
||||
handmatig: ManualDiplomaPolicyDto;
|
||||
}
|
||||
|
||||
export interface DuoDiplomaDto {
|
||||
id: string;
|
||||
naam: string;
|
||||
instelling: string;
|
||||
jaar: number;
|
||||
beroep: string; // server-derived profession
|
||||
policyQuestions: PolicyQuestionDto[]; // server-decided geldigheidsvragen
|
||||
}
|
||||
|
||||
export interface ManualDiplomaPolicyDto {
|
||||
beroepen: string[]; // professions the user may declare for a manual diploma
|
||||
policyQuestions: PolicyQuestionDto[]; // maximal set applied to a manual diploma
|
||||
}
|
||||
|
||||
export interface PolicyQuestionDto {
|
||||
id: string;
|
||||
vraag: string;
|
||||
type: 'ja-nee' | 'tekst';
|
||||
}
|
||||
206
src/app/registratie/domain/registratie-wizard.machine.spec.ts
Normal file
206
src/app/registratie/domain/registratie-wizard.machine.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
Draft,
|
||||
RegistratieState,
|
||||
STEPS,
|
||||
initial,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
kiesDiploma,
|
||||
kiesHandmatig,
|
||||
declareerBeroep,
|
||||
setAntwoord,
|
||||
setField,
|
||||
prefillAdres,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
} from './registratie-wizard.machine';
|
||||
|
||||
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
|
||||
tag: 'Invullen',
|
||||
draft: { antwoorden: {}, ...draft },
|
||||
cursor,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
|
||||
const validDraft: Partial<Draft> = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' };
|
||||
|
||||
describe('STEPS (fixed)', () => {
|
||||
it('always has the same three steps', () => {
|
||||
expect(STEPS).toEqual(['adres', 'beroep', 'controle']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('Next is a no-op (sets errors) when the adres step is invalid', () => {
|
||||
const s = next(initial);
|
||||
expect(s.tag).toBe('Invullen');
|
||||
expect((s as any).cursor).toBe(0);
|
||||
expect((s as any).errors.straat).toBeTruthy();
|
||||
expect((s as any).errors.correspondentie).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Next advances once the adres step is valid', () => {
|
||||
const s = next(invullen(validAdres));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
});
|
||||
|
||||
it('requires a valid e-mail only when the channel is email', () => {
|
||||
const bad = next(invullen({ ...validAdres, correspondentie: 'email' }));
|
||||
expect((bad as any).errors.email).toBeTruthy();
|
||||
const good = next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' }));
|
||||
expect((good as any).cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('beroep step requires a chosen diploma', () => {
|
||||
const noDiploma = next(invullen(validAdres, 1));
|
||||
expect((noDiploma as any).cursor).toBe(1);
|
||||
expect((noDiploma as any).errors.diploma).toBeTruthy();
|
||||
const withDiploma = next(invullen(validDraft, 1));
|
||||
expect((withDiploma as any).cursor).toBe(2);
|
||||
});
|
||||
|
||||
it('Back never goes below the first step and preserves the draft', () => {
|
||||
expect(back(initial)).toBe(initial);
|
||||
const s = back(invullen(validDraft, 2));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect((s as any).draft.beroep).toBe('Arts');
|
||||
});
|
||||
|
||||
it('GaNaarStap only jumps backwards', () => {
|
||||
expect((gaNaarStap(invullen(validDraft, 2), 0) as any).cursor).toBe(0);
|
||||
expect((gaNaarStap(invullen(validDraft, 1), 2) as any).cursor).toBe(1); // forward jump rejected
|
||||
});
|
||||
});
|
||||
|
||||
describe('adres origin (BRP vs handmatig)', () => {
|
||||
it('prefillAdres flags origin brp', () => {
|
||||
const s = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
expect((s as any).draft.adresHerkomst).toBe('brp');
|
||||
expect((s as any).draft.straat).toBe('Lange Voorhout 9');
|
||||
});
|
||||
|
||||
it('editing a prefilled address field flips origin to handmatig', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = setField(prefilled, 'woonplaats', 'Rotterdam');
|
||||
expect((edited as any).draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('typing an address with no BRP prefill yields handmatig', () => {
|
||||
const s = setField(invullen({}), 'straat', 'Kerkstraat 1');
|
||||
expect((s as any).draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('editing the e-mail field does not change the address origin', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = setField(prefilled, 'email', 'a@b.nl');
|
||||
expect((edited as any).draft.adresHerkomst).toBe('brp');
|
||||
});
|
||||
|
||||
it('a manually entered address still submits (only manual diploma is gated)', () => {
|
||||
const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }));
|
||||
expect(s.tag).toBe('Indienen');
|
||||
expect((s as any).data.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kiesDiploma', () => {
|
||||
it('derives the beroep from the chosen diploma and flags origin duo', () => {
|
||||
const s = kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []);
|
||||
expect((s as any).draft.diplomaId).toBe('d9');
|
||||
expect((s as any).draft.beroep).toBe('Verpleegkundige');
|
||||
expect((s as any).draft.diplomaHerkomst).toBe('duo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy questions (geldigheidsvragen)', () => {
|
||||
it('a diploma with questions blocks Next until they are answered', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
const blocked = next(s);
|
||||
expect((blocked as any).cursor).toBe(1);
|
||||
expect((blocked as any).errors.antwoorden['nl-taalvaardigheid']).toBeTruthy();
|
||||
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
||||
expect((next(s) as any).cursor).toBe(2);
|
||||
});
|
||||
|
||||
it('validateAll keeps only the answers to the questions that applied', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
||||
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
|
||||
const done = submit(s);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual diploma fallback', () => {
|
||||
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
|
||||
|
||||
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
|
||||
const s = kiesHandmatig(invullen(validAdres, 1), maxIds);
|
||||
expect((s as any).draft.diplomaHerkomst).toBe('handmatig');
|
||||
expect((s as any).draft.beroep).toBeUndefined();
|
||||
expect((s as any).draft.vraagIds).toEqual(maxIds);
|
||||
});
|
||||
|
||||
it('requires a declared beroep + all maximal questions before submit', () => {
|
||||
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
|
||||
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
|
||||
s = declareerBeroep(s, 'Fysiotherapeut');
|
||||
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
|
||||
for (const id of maxIds) s = setAntwoord(s, id, 'ja');
|
||||
const done = submit(s);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.diplomaHerkomst).toBe('handmatig');
|
||||
expect((done as any).data.beroep).toBe('Fysiotherapeut');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
it('reaches Indienen ONLY with a complete, valid draft', () => {
|
||||
expect(submit(invullen(validAdres)).tag).toBe('Invullen'); // no diploma
|
||||
const good = submit(invullen(validDraft));
|
||||
expect(good.tag).toBe('Indienen');
|
||||
expect((good as any).data.beroep).toBe('Arts');
|
||||
expect((good as any).data.adres.postcode).toBe('2514 EA');
|
||||
expect((good as any).data.adresHerkomst).toBe('brp');
|
||||
});
|
||||
|
||||
it('resolve maps Indienen to Ingediend (with referentie) / Mislukt', () => {
|
||||
const indienen = submit(invullen(validDraft));
|
||||
expect(resolve(indienen, ok('BIG-2026-001')).tag).toBe('Ingediend');
|
||||
expect((resolve(indienen, ok('BIG-2026-001')) as any).referentie).toBe('BIG-2026-001');
|
||||
expect(resolve(indienen, err('boom')).tag).toBe('Mislukt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives the full flow via messages', () => {
|
||||
let s: RegistratieState = initial;
|
||||
s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' });
|
||||
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
s = reduce(s, { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('controle');
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Indienen');
|
||||
s = reduce(s, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' });
|
||||
expect(s.tag).toBe('Ingediend');
|
||||
});
|
||||
|
||||
it('SubmitFailed then Retry returns to Indienen with the same data', () => {
|
||||
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(s.tag).toBe('Mislukt');
|
||||
s = reduce(s, { tag: 'Retry' });
|
||||
expect(s.tag).toBe('Indienen');
|
||||
expect((s as any).data.beroep).toBe('Arts');
|
||||
});
|
||||
});
|
||||
280
src/app/registratie/domain/registratie-wizard.machine.ts
Normal file
280
src/app/registratie/domain/registratie-wizard.machine.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
antwoorden?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type RegistratieState =
|
||||
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }
|
||||
| { 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: {} };
|
||||
|
||||
/** 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)];
|
||||
}
|
||||
|
||||
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
||||
function validateStep(step: StepId, d: Draft): Result<Errors, void> {
|
||||
const errors: Errors = {};
|
||||
switch (step) {
|
||||
case 'adres': {
|
||||
if (!d.straat || d.straat.trim() === '') errors.straat = '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 = 'Vul een woonplaats in.';
|
||||
if (!d.correspondentie) errors.correspondentie = '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 = '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] = 'Beantwoord deze vraag.';
|
||||
}
|
||||
if (Object.keys(open).length > 0) errors.antwoorden = open;
|
||||
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): Result<Errors, ValidRegistratie> {
|
||||
const errors: Errors = {};
|
||||
for (const step of STEPS) {
|
||||
const r = validateStep(step, d);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
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: '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 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,10 @@ export function herregistratieDeadline(reg: Registration): Date | 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. */
|
||||
window before its deadline. A struck-off or suspended registration may not.
|
||||
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
|
||||
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
|
||||
the reference implementation + unit test; the frontend no longer calls it. */
|
||||
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
|
||||
const deadline = herregistratieDeadline(reg);
|
||||
if (!deadline) return false;
|
||||
|
||||
17
src/app/registratie/domain/value-objects/email.spec.ts
Normal file
17
src/app/registratie/domain/value-objects/email.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEmail } from './email';
|
||||
|
||||
describe('parseEmail', () => {
|
||||
it('accepts a well-formed address and trims it', () => {
|
||||
const r = parseEmail(' naam@voorbeeld.nl ');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe('naam@voorbeeld.nl');
|
||||
});
|
||||
|
||||
it('rejects malformed addresses', () => {
|
||||
expect(parseEmail('').ok).toBe(false);
|
||||
expect(parseEmail('naam').ok).toBe(false);
|
||||
expect(parseEmail('naam@voorbeeld').ok).toBe(false);
|
||||
expect(parseEmail('naam @voorbeeld.nl').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
19
src/app/registratie/domain/value-objects/email.ts
Normal file
19
src/app/registratie/domain/value-objects/email.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* Value object: an e-mail address. "Parse, don't validate" — an Email is a
|
||||
* distinct type from a raw string, mintable only via parseEmail, so holding one
|
||||
* is proof it is well-formed. Format-only check (the FE keeps format validation
|
||||
* for instant feedback; the backend stays the authority — see ADR-0001).
|
||||
*/
|
||||
export type Email = Brand<string, 'Email'>;
|
||||
|
||||
export function parseEmail(raw: string): Result<string, Email> {
|
||||
const t = raw.trim();
|
||||
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
|
||||
// for instant feedback; the server re-validates.
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
|
||||
return err('Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.');
|
||||
}
|
||||
return ok(t as Email);
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Registration, Aantekening } from '../domain/registration';
|
||||
import { Aantekening } from '../domain/registration';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
||||
* resources (Angular's httpResource); each returns a Resource with
|
||||
* status()/value()/error()/reload(). Call from an injection context
|
||||
* (a field initializer in the store).
|
||||
*
|
||||
* Note: registration + person are now served via the aggregated dashboard-view
|
||||
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BigRegisterAdapter {
|
||||
registrationResource() {
|
||||
return httpResource<Registration>(() => 'mock/registration.json');
|
||||
}
|
||||
|
||||
aantekeningenResource() {
|
||||
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
|
||||
}
|
||||
|
||||
22
src/app/registratie/infrastructure/brp.adapter.spec.ts
Normal file
22
src/app/registratie/infrastructure/brp.adapter.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBrpAddress } from './brp.adapter';
|
||||
|
||||
describe('parseBrpAddress (trust boundary)', () => {
|
||||
it('accepts a found address', () => {
|
||||
const r = parseBrpAddress({ gevonden: true, adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value.adres?.postcode).toBe('2514 EA');
|
||||
});
|
||||
|
||||
it('accepts "geen adres" (gevonden: false) as a valid outcome', () => {
|
||||
const r = parseBrpAddress({ gevonden: false });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed responses', () => {
|
||||
expect(parseBrpAddress(null).ok).toBe(false);
|
||||
expect(parseBrpAddress({}).ok).toBe(false); // missing gevonden
|
||||
expect(parseBrpAddress({ gevonden: true }).ok).toBe(false); // found but no adres
|
||||
expect(parseBrpAddress({ gevonden: true, adres: { straat: 'x' } }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Person } from '../domain/person';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
|
||||
|
||||
/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */
|
||||
/**
|
||||
* Infrastructure adapter for the BRP address lookup, reached only through our own
|
||||
* ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
|
||||
* is a static mock; pointing at a real backend touches only this file + the DTO.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BrpAdapter {
|
||||
personResource() {
|
||||
return httpResource<Person>(() => 'mock/brp.json');
|
||||
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
|
||||
// parseBrpAddress validates it.
|
||||
adresResource() {
|
||||
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a
|
||||
valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;
|
||||
reach for a schema lib once the contract count grows. */
|
||||
export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
|
||||
if (typeof json !== 'object' || json === null) return err('brp-address: not an object');
|
||||
const dto = json as Partial<BrpAddressDto>;
|
||||
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
|
||||
if (dto.gevonden) {
|
||||
const a = dto.adres;
|
||||
if (!a || typeof a.straat !== 'string' || typeof a.postcode !== 'string' || typeof a.woonplaats !== 'string') {
|
||||
return err('brp-address: missing/invalid adres');
|
||||
}
|
||||
}
|
||||
return ok({ gevonden: dto.gevonden, adres: dto.adres });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDashboardView } from './dashboard-view.adapter';
|
||||
|
||||
const valid = {
|
||||
registration: {
|
||||
bigNummer: '19012345601',
|
||||
naam: 'Dr. A. de Vries',
|
||||
beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01',
|
||||
geboortedatum: '1985-03-14',
|
||||
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
|
||||
},
|
||||
person: { naam: 'Dr. A. de Vries', geboortedatum: '1985-03-14', adres: { straat: 'X 1', postcode: '2514 EA', woonplaats: 'Den Haag' } },
|
||||
decisions: { eligibleForHerregistratie: true, herregistratieReason: 'within window' },
|
||||
};
|
||||
|
||||
describe('parseDashboardView (trust boundary)', () => {
|
||||
it('maps a valid response into a DashboardView', () => {
|
||||
const r = parseDashboardView(valid);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value.profile.registration.bigNummer).toBe('19012345601');
|
||||
expect(r.value.decisions.eligibleForHerregistratie).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed responses instead of trusting them', () => {
|
||||
expect(parseDashboardView(null).ok).toBe(false);
|
||||
expect(parseDashboardView({ ...valid, registration: undefined }).ok).toBe(false);
|
||||
expect(parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
47
src/app/registratie/infrastructure/dashboard-view.adapter.ts
Normal file
47
src/app/registratie/infrastructure/dashboard-view.adapter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
||||
* ONE call returns registration + person + server-computed decisions.
|
||||
* (In this POC the endpoint is a static mock; the decisions are precomputed to
|
||||
* stand in for what the backend would compute.)
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardViewAdapter {
|
||||
// Typed as the DTO for ergonomics, but the value is still untrusted JSON —
|
||||
// parseDashboardView validates it at the boundary before the app uses it.
|
||||
dashboardViewResource() {
|
||||
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-boundary parse: validate the untrusted response shape and map the DTO
|
||||
* onto our own domain model. Hand-written on purpose — no Zod for a single
|
||||
* contract. ponytail: reach for a schema lib once the contract count grows.
|
||||
*/
|
||||
export function parseDashboardView(json: unknown): Result<string, DashboardView> {
|
||||
if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object');
|
||||
const dto = json as Partial<DashboardViewDto>;
|
||||
|
||||
const reg = dto.registration;
|
||||
if (!reg || typeof reg.bigNummer !== 'string' || !reg.status || typeof reg.status.tag !== 'string') {
|
||||
return err('dashboard-view: missing/invalid registration');
|
||||
}
|
||||
const person = dto.person;
|
||||
if (!person || !person.adres || typeof person.adres.postcode !== 'string') {
|
||||
return err('dashboard-view: missing/invalid person');
|
||||
}
|
||||
const d = dto.decisions;
|
||||
if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {
|
||||
return err('dashboard-view: missing/invalid decisions');
|
||||
}
|
||||
|
||||
return ok({
|
||||
profile: { registration: reg, person },
|
||||
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
|
||||
});
|
||||
}
|
||||
39
src/app/registratie/infrastructure/duo.adapter.spec.ts
Normal file
39
src/app/registratie/infrastructure/duo.adapter.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDuoLookup } from './duo.adapter';
|
||||
|
||||
const valid = {
|
||||
diplomas: [
|
||||
{ id: 'd1', naam: 'Geneeskunde', instelling: 'Universiteit Leiden', jaar: 2011, beroep: 'Arts', policyQuestions: [] },
|
||||
{ id: 'd2', naam: 'Medicine', instelling: 'University of Edinburgh', jaar: 2013, beroep: 'Arts', policyQuestions: [{ id: 'nl-taal', vraag: 'Toon taalvaardigheid', type: 'ja-nee' }] },
|
||||
],
|
||||
handmatig: {
|
||||
beroepen: ['Arts', 'Verpleegkundige'],
|
||||
policyQuestions: [{ id: 'toelichting', vraag: 'Toelichting', type: 'tekst' }],
|
||||
},
|
||||
};
|
||||
|
||||
describe('parseDuoLookup (trust boundary)', () => {
|
||||
it('maps a valid lookup (diplomas + manual fallback)', () => {
|
||||
const r = parseDuoLookup(valid);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value.diplomas).toHaveLength(2);
|
||||
expect(r.value.diplomas[1].policyQuestions[0].id).toBe('nl-taal');
|
||||
expect(r.value.handmatig.beroepen).toContain('Verpleegkundige');
|
||||
expect(r.value.handmatig.policyQuestions[0].type).toBe('tekst');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an empty diploma list (forces manual entry)', () => {
|
||||
const r = parseDuoLookup({ diplomas: [], handmatig: valid.handmatig });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed responses', () => {
|
||||
expect(parseDuoLookup(null).ok).toBe(false);
|
||||
expect(parseDuoLookup({}).ok).toBe(false); // no diplomas
|
||||
expect(parseDuoLookup({ diplomas: [] }).ok).toBe(false); // no handmatig
|
||||
expect(parseDuoLookup({ diplomas: [{ id: 'd1' }], handmatig: valid.handmatig }).ok).toBe(false); // bad diploma
|
||||
expect(parseDuoLookup({ diplomas: [], handmatig: { beroepen: ['Arts'], policyQuestions: [{ id: 'x', vraag: 'y', type: 'bogus' }] } }).ok).toBe(false); // bad question type
|
||||
});
|
||||
});
|
||||
60
src/app/registratie/infrastructure/duo.adapter.ts
Normal file
60
src/app/registratie/infrastructure/duo.adapter.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||
|
||||
const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
|
||||
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
|
||||
* user's diplomas (each with its server-computed beroep + policy questions) and
|
||||
* the manual-entry fallback policy. The frontend renders; it does not derive. In
|
||||
* this POC the endpoint is a static mock.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DuoAdapter {
|
||||
diplomasResource() {
|
||||
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuestions(json: unknown): PolicyQuestionDto[] | null {
|
||||
if (!Array.isArray(json)) return null;
|
||||
const out: PolicyQuestionDto[] = [];
|
||||
for (const q of json) {
|
||||
if (typeof q !== 'object' || q === null) return null;
|
||||
const p = q as Partial<PolicyQuestionDto>;
|
||||
if (typeof p.id !== 'string' || typeof p.vraag !== 'string' || (p.type !== 'ja-nee' && p.type !== 'tekst')) return null;
|
||||
out.push({ id: p.id, vraag: p.vraag, type: p.type });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trust-boundary parse: validate the untrusted response shape (diplomas, each
|
||||
with derived beroep + policy questions, plus the manual-entry fallback). */
|
||||
export function parseDuoLookup(json: unknown): Result<string, DuoLookupDto> {
|
||||
if (typeof json !== 'object' || json === null) return err('duo-lookup: not an object');
|
||||
const dto = json as Partial<DuoLookupDto>;
|
||||
if (!Array.isArray(dto.diplomas)) return err('duo-lookup: missing diplomas');
|
||||
|
||||
const diplomas: DuoDiplomaDto[] = [];
|
||||
for (const item of dto.diplomas) {
|
||||
if (typeof item !== 'object' || item === null) return err('duo-lookup: invalid diploma');
|
||||
const d = item as Partial<DuoDiplomaDto>;
|
||||
const vragen = parseQuestions(d.policyQuestions);
|
||||
if (typeof d.id !== 'string' || typeof d.naam !== 'string' || typeof d.beroep !== 'string' || vragen === null) {
|
||||
return err('duo-lookup: missing/invalid diploma fields');
|
||||
}
|
||||
diplomas.push({ id: d.id, naam: d.naam, instelling: d.instelling ?? '', jaar: typeof d.jaar === 'number' ? d.jaar : 0, beroep: d.beroep, policyQuestions: vragen });
|
||||
}
|
||||
|
||||
const hm = dto.handmatig;
|
||||
const hmVragen = hm ? parseQuestions(hm.policyQuestions) : null;
|
||||
if (!hm || !Array.isArray(hm.beroepen) || hmVragen === null) {
|
||||
return err('duo-lookup: missing/invalid handmatig fallback');
|
||||
}
|
||||
const handmatig: ManualDiplomaPolicyDto = { beroepen: hm.beroepen, policyQuestions: hmVragen };
|
||||
|
||||
return ok({ diplomas, handmatig });
|
||||
}
|
||||
@@ -56,6 +56,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
</div>
|
||||
|
||||
<p style="margin-top:2rem">
|
||||
<app-link to="/registreren">Inschrijven in het BIG-register (registratiewizard) →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
||||
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
||||
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||
import {
|
||||
RegistratieState,
|
||||
RegistratieMsg,
|
||||
Draft,
|
||||
DraftField,
|
||||
Correspondentie,
|
||||
StepId,
|
||||
initial,
|
||||
reduce,
|
||||
STEPS,
|
||||
} from '@registratie/domain/registratie-wizard.machine';
|
||||
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
||||
|
||||
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
|
||||
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
|
||||
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
||||
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
||||
|
||||
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
||||
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
||||
draft via an effect; the DUO diploma list renders through <app-async>; choosing
|
||||
a diploma reveals its server-derived beroep. The draft is persisted to
|
||||
localStorage so a reload keeps the user's progress. Built entirely from existing
|
||||
atoms/molecules. */
|
||||
@Component({
|
||||
selector: 'app-registratie-wizard',
|
||||
imports: [
|
||||
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
||||
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, ...ASYNC,
|
||||
],
|
||||
template: `
|
||||
@switch (state().tag) {
|
||||
@case ('Invullen') {
|
||||
<app-stepper class="app-section" [steps]="stepLabels" [current]="cursor()" />
|
||||
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
|
||||
<form (ngSubmit)="onPrimary()" class="app-form">
|
||||
@switch (step()) {
|
||||
@case ('adres') {
|
||||
@if (adresStatus() === 'laden') {
|
||||
<app-skeleton height="2.5rem" [count]="4" />
|
||||
} @else {
|
||||
@switch (adresStatus()) {
|
||||
@case ('gevonden') {
|
||||
<app-alert type="info">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
|
||||
}
|
||||
@case ('geen') {
|
||||
<app-alert type="warning">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
|
||||
}
|
||||
@case ('fout') {
|
||||
<app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
|
||||
}
|
||||
}
|
||||
<app-form-field label="Straat en huisnummer" fieldId="straat" [error]="err('straat')">
|
||||
<app-text-input inputId="straat" [invalid]="!!err('straat')" [ngModel]="draft().straat ?? ''" (ngModelChange)="set('straat', $event)" name="straat" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Postcode" fieldId="postcode" [error]="err('postcode')">
|
||||
<app-text-input inputId="postcode" [invalid]="!!err('postcode')" [ngModel]="draft().postcode ?? ''" (ngModelChange)="set('postcode', $event)" name="postcode" placeholder="1234 AB" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Woonplaats" fieldId="woonplaats" [error]="err('woonplaats')">
|
||||
<app-text-input inputId="woonplaats" [invalid]="!!err('woonplaats')" [ngModel]="draft().woonplaats ?? ''" (ngModelChange)="set('woonplaats', $event)" name="woonplaats" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')">
|
||||
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
|
||||
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
|
||||
</app-form-field>
|
||||
@if (draft().correspondentie === 'email') {
|
||||
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')">
|
||||
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
}
|
||||
@case ('beroep') {
|
||||
<app-async [data]="lookupRd()">
|
||||
<ng-template appAsyncLoaded let-data>
|
||||
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" [error]="err('diploma')">
|
||||
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
|
||||
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
|
||||
</app-form-field>
|
||||
|
||||
@if (handmatigActief()) {
|
||||
<app-alert type="warning">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
|
||||
<app-form-field label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
|
||||
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
|
||||
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
|
||||
</app-form-field>
|
||||
} @else if (draft().beroep) {
|
||||
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
||||
<app-data-row key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
|
||||
</dl>
|
||||
}
|
||||
|
||||
@for (q of actieveVragen($any(data)); track q.id) {
|
||||
<app-form-field [label]="q.vraag" [fieldId]="'vraag-' + q.id" [error]="vraagErr(q.id)">
|
||||
@if (q.type === 'ja-nee') {
|
||||
<app-radio-group [name]="'vraag-' + q.id" [options]="jaNee" [invalid]="!!vraagErr(q.id)"
|
||||
[ngModel]="draft().antwoorden[q.id] ?? ''" (ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
|
||||
} @else {
|
||||
<app-text-input [inputId]="'vraag-' + q.id" [invalid]="!!vraagErr(q.id)" [ngModel]="draft().antwoorden[q.id] ?? ''"
|
||||
(ngModelChange)="dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })" [ngModelOptions]="{ standalone: true }" />
|
||||
}
|
||||
</app-form-field>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="3" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
@case ('controle') {
|
||||
<app-alert type="info">Controleer uw gegevens en dien de registratie in.</app-alert>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
||||
<app-data-row key="Adres" [value]="adresSamenvatting()" />
|
||||
<app-data-row key="Herkomst adres" [value]="adresHerkomstLabel()" />
|
||||
<app-data-row key="Correspondentie" [value]="correspondentieLabel()" />
|
||||
@if (draft().correspondentie === 'email') {
|
||||
<app-data-row key="E-mailadres" [value]="draft().email ?? ''" />
|
||||
}
|
||||
<app-data-row key="Beroep" [value]="draft().beroep ?? ''" />
|
||||
<app-data-row key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
|
||||
@for (item of samenvattingVragen(); track item.vraag) {
|
||||
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
|
||||
}
|
||||
</dl>
|
||||
<div class="app-button-row app-section">
|
||||
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">Adres wijzigen</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">Diploma wijzigen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="app-button-row app-button-row--spaced">
|
||||
@if (cursor() > 0) {
|
||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
}
|
||||
<app-button type="submit" variant="primary">{{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}</app-button>
|
||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@case ('Indienen') {
|
||||
<app-spinner /> <span>Uw registratie wordt verwerkt…</span>
|
||||
}
|
||||
@case ('Ingediend') {
|
||||
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
|
||||
</div>
|
||||
}
|
||||
@case ('Mislukt') {
|
||||
<app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RegistratieWizardComponent {
|
||||
private brp = inject(BrpAdapter);
|
||||
private duo = inject(DuoAdapter);
|
||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
||||
|
||||
protected adresRes = this.brp.adresResource();
|
||||
private diplomasRes = this.duo.diplomasResource();
|
||||
|
||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||
seed = input<RegistratieState>(initial);
|
||||
|
||||
readonly kanalen = KANALEN;
|
||||
readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper
|
||||
private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];
|
||||
readonly state = this.store.model;
|
||||
readonly dispatch = this.store.dispatch;
|
||||
|
||||
/** Focus target: the step heading receives focus on step change (a11y). */
|
||||
private stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
|
||||
|
||||
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
|
||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
||||
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
|
||||
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
|
||||
protected adresSamenvatting = computed(() => {
|
||||
const d = this.draft();
|
||||
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||
});
|
||||
// Readable labels for the controle summary (instead of raw enum values).
|
||||
protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']));
|
||||
protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));
|
||||
protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));
|
||||
|
||||
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
|
||||
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
|
||||
malformed response; 'fout' is an unreachable BRP. */
|
||||
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
|
||||
const st = this.adresRes.status();
|
||||
if (st === 'loading' || st === 'reloading') return 'laden';
|
||||
if (st === 'error') return 'fout';
|
||||
const json = this.adresRes.value();
|
||||
const parsed = json !== undefined ? parseBrpAddress(json) : null;
|
||||
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
|
||||
});
|
||||
|
||||
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
|
||||
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
|
||||
const rd = fromResource(this.diplomasRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDuoLookup(rd.value);
|
||||
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
|
||||
controle summary) where the <app-async> template variable isn't in scope. */
|
||||
private duoData = computed<DuoLookupDto | null>(() => {
|
||||
const rd = this.lookupRd();
|
||||
return rd.tag === 'Success' ? rd.value : null;
|
||||
});
|
||||
|
||||
readonly jaNee = JA_NEE;
|
||||
|
||||
protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';
|
||||
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
|
||||
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
|
||||
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
||||
|
||||
/** True while the user is entering a diploma manually (not in the DUO list). */
|
||||
protected handmatigActief = computed(() => this.draft().diplomaHerkomst === 'handmatig');
|
||||
/** The radio selection: a diploma id, or the "not listed" sentinel in manual mode. */
|
||||
protected diplomaKeuze = computed(() => (this.handmatigActief() ? HANDMATIG : this.draft().diplomaId ?? ''));
|
||||
|
||||
protected diplomaOptions = (data: DuoLookupDto) => [
|
||||
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })),
|
||||
{ value: HANDMATIG, label: 'Mijn diploma staat er niet bij' },
|
||||
];
|
||||
|
||||
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
|
||||
|
||||
/** The policy questions that apply to the current choice (server-decided). */
|
||||
protected actieveVragen = (data: DuoLookupDto): PolicyQuestionDto[] => {
|
||||
if (this.handmatigActief()) return data.handmatig.policyQuestions;
|
||||
return data.diplomas.find((d) => d.id === this.draft().diplomaId)?.policyQuestions ?? [];
|
||||
};
|
||||
|
||||
/** Answered policy questions for the controle summary (question text + answer). */
|
||||
protected samenvattingVragen = computed(() => {
|
||||
const data = this.duoData();
|
||||
const d = this.draft();
|
||||
if (!data) return [] as { vraag: string; antwoord: string }[];
|
||||
const alle = [...data.diplomas.flatMap((x) => x.policyQuestions), ...data.handmatig.policyQuestions];
|
||||
return (d.vraagIds ?? []).map((id) => ({ vraag: alle.find((q) => q.id === id)?.vraag ?? id, antwoord: d.antwoorden[id] ?? '' }));
|
||||
});
|
||||
|
||||
protected onDiplomaKeuze(data: DuoLookupDto, id: string) {
|
||||
if (id === HANDMATIG) {
|
||||
this.dispatch({ tag: 'KiesHandmatig', vraagIds: data.handmatig.policyQuestions.map((q) => q.id) });
|
||||
return;
|
||||
}
|
||||
const d = data.diplomas.find((x) => x.id === id);
|
||||
if (d) this.dispatch({ tag: 'KiesDiploma', diplomaId: d.id, beroep: d.beroep, vraagIds: d.policyQuestions.map((q) => q.id) });
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories) wins; otherwise resume from localStorage.
|
||||
const seeded = this.seed();
|
||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
|
||||
// Persist only while filling in; clear once the flow is done.
|
||||
effect(() => {
|
||||
const s = this.state();
|
||||
if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
// Prefill the address from the BRP lookup as it arrives. Track only the resource
|
||||
// value; untrack the dispatch (it reads the state signal, which would otherwise
|
||||
// make this effect loop on its own write). Don't clobber edits/restored data.
|
||||
effect(() => {
|
||||
const json = this.adresRes.value();
|
||||
if (!json) return;
|
||||
const parsed = parseBrpAddress(json);
|
||||
untracked(() => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Invullen' || s.draft.straat) return;
|
||||
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
|
||||
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
|
||||
const a = parsed.value.adres;
|
||||
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
|
||||
}
|
||||
});
|
||||
});
|
||||
// A11y: move focus to the step heading when the STEP changes (depends only on
|
||||
// cursor(), which is value-stable across keystrokes — so typing never steals
|
||||
// focus). Skip the first run so we don't grab focus on initial load.
|
||||
let firstStep = true;
|
||||
effect(() => {
|
||||
this.cursor();
|
||||
if (firstStep) { firstStep = false; return; }
|
||||
untracked(() => {
|
||||
if (this.state().tag !== 'Invullen') return;
|
||||
queueMicrotask(() => this.stepHeading()?.nativeElement.focus());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private restore(): RegistratieState | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as RegistratieState;
|
||||
} catch {
|
||||
return null; // ponytail: corrupt entry -> start fresh, no migration.
|
||||
}
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Invullen') return;
|
||||
this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' });
|
||||
this.runIfIndienen();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfIndienen();
|
||||
}
|
||||
|
||||
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
||||
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
|
||||
restart() {
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
this.adresRes.reload();
|
||||
}
|
||||
|
||||
/** The effect: when we enter Indienen, call the backend, then dispatch the
|
||||
outcome (success carries the confirmation reference). */
|
||||
private async runIfIndienen() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Indienen') return;
|
||||
const r = await submitRegistratie(s.data);
|
||||
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
|
||||
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { RegistratieWizardComponent } from './registratie-wizard.component';
|
||||
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
||||
import { Postcode } from '@registratie/domain/value-objects/postcode';
|
||||
|
||||
const adres: Partial<Draft> = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' };
|
||||
const filled: Partial<Draft> = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] };
|
||||
|
||||
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} });
|
||||
|
||||
const validData: ValidRegistratie = {
|
||||
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
|
||||
adresHerkomst: 'brp',
|
||||
correspondentie: 'post',
|
||||
diplomaId: 'd1',
|
||||
diplomaHerkomst: 'duo',
|
||||
beroep: 'Arts',
|
||||
antwoorden: {},
|
||||
};
|
||||
|
||||
const meta: Meta<RegistratieWizardComponent> = {
|
||||
title: 'Registratie/RegistratieWizard',
|
||||
component: RegistratieWizardComponent,
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient()] })],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RegistratieWizardComponent>;
|
||||
|
||||
export const Adres: Story = { args: { seed: invullen(adres, 0) } };
|
||||
export const Beroep: Story = { args: { seed: invullen(filled, 1) } };
|
||||
/** English-language diploma → the Dutch-proficiency policy question appears. */
|
||||
export const BeroepEngelstalig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'd2', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: ['nl-taalvaardigheid'] }, 1) } };
|
||||
/** Diploma not in DUO → declare beroep + the maximal policy-question set. */
|
||||
export const BeroepHandmatig: Story = { args: { seed: invullen({ ...adres, diplomaId: 'handmatig', diplomaHerkomst: 'handmatig', vraagIds: ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'] }, 1) } };
|
||||
export const Controle: Story = { args: { seed: invullen(filled, 2) } };
|
||||
export const Indienen: Story = { args: { seed: { tag: 'Indienen', data: validData } } };
|
||||
export const Ingediend: Story = { args: { seed: { tag: 'Ingediend', data: validData, referentie: 'BIG-2026-123456' } } };
|
||||
export const Mislukt: Story = { args: { seed: { tag: 'Mislukt', data: validData, error: 'Netwerkfout' } } };
|
||||
27
src/app/registratie/ui/registratie.page.ts
Normal file
27
src/app/registratie/ui/registratie.page.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/registratie-wizard.component';
|
||||
|
||||
/** Page: register in the BIG-register. Built entirely from existing building
|
||||
blocks (page shell + alert + the registratie-wizard organism). */
|
||||
@Component({
|
||||
selector: 'app-registratie-page',
|
||||
imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
heading="Inschrijven in het BIG-register"
|
||||
backLink="/dashboard"
|
||||
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
|
||||
<app-alert type="info">
|
||||
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
|
||||
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
|
||||
als u de pagina herlaadt.
|
||||
</app-alert>
|
||||
<div class="app-section">
|
||||
<app-registratie-wizard />
|
||||
</div>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class RegistratiePage {}
|
||||
Reference in New Issue
Block a user