feat(registratie): WP-34 — phone field + BRP address read-only

Reshape the adreswijziging form into a contact-change form: the BRP address is
authoritative and shown read-only (you change it at the gemeente), and the phone
number becomes the editable/submittable field. New Telefoonnummer value object
(parse-don't-validate); backend RejectPhoneChange re-validates as authority.
POST /change-requests now carries { telefoon } (typed client regenerated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-22 20:11:26 +02:00
co-authored by Claude Opus 4.8
parent 1ed4850858
commit 0ea43af7b6
21 changed files with 329 additions and 171 deletions
@@ -3,16 +3,12 @@ import { describe, it, expect } from 'vitest';
import { Valid } from '@registratie/domain/change-request.machine';
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
import { createSubmitChangeRequest } from './submit-change-request';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
import { parseTelefoonnummer } from '@registratie/domain/value-objects/telefoonnummer';
const postcode = parsePostcode('2514 EA');
if (!postcode.ok) throw new Error('fixture postcode should parse');
const telefoon = parseTelefoonnummer('0612345678');
if (!telefoon.ok) throw new Error('fixture phone should parse');
const data: Valid = {
straat: 'Lange Voorhout 9',
postcode: postcode.value,
woonplaats: 'Den Haag',
};
const data: Valid = { telefoon: telefoon.value };
function setup(adapter: Partial<ChangeRequestAdapter>) {
TestBed.configureTestingModule({
@@ -38,9 +34,9 @@ describe('createSubmitChangeRequest', () => {
it('surfaces a ProblemDetails detail message when the server rejects with one', async () => {
const submit = setup({
changeRequest: () => Promise.reject({ detail: 'Postcode komt niet overeen met de straat.' }),
changeRequest: () => Promise.reject({ detail: 'Telefoonnummer is ongeldig.' }),
});
const r = await submit(data);
expect(r).toEqual({ ok: false, error: 'Postcode komt niet overeen met de straat.' });
expect(r).toEqual({ ok: false, error: 'Telefoonnummer is ongeldig.' });
});
});
@@ -1,67 +1,56 @@
import { describe, it, expect } from 'vitest';
import { ChangeRequestState, reduce, initial } from './change-request.machine';
const editingWith = (
draft: Partial<{ straat: string; postcode: string; woonplaats: string }>,
): ChangeRequestState => ({
const editingWith = (telefoon: string): ChangeRequestState => ({
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '', ...draft },
draft: { telefoon },
errors: {},
});
describe('change-request reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
const s = reduce(initial, { tag: 'SetField', key: 'telefoon', value: '0612345678' });
expect(s.tag).toBe('Editing');
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe(
'Lange Voorhout 9',
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.telefoon).toBe(
'0612345678',
);
});
it('Submit with an invalid draft stays Editing and reports field errors', () => {
const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' });
const s = reduce(editingWith('nope'), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
const errors = (s as Extract<ChangeRequestState, { tag: 'Editing' }>).errors;
expect(errors.straat).toBeTruthy();
expect(errors.postcode).toBeTruthy();
expect(errors.telefoon).toBeTruthy();
});
it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => {
const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), {
tag: 'Submit',
});
const s = reduce(editingWith('06 12 34 56 78'), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.telefoon).toBe(
'0612345678',
);
});
it('SubmitConfirmed maps Submitting to Submitted with the referentie', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const submitting = reduce(editingWith('0612345678'), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -1,24 +1,25 @@
import { Result, assertNever } from '@shared/kernel/fp';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
import {
Telefoonnummer,
parseTelefoonnummer,
} from '@registratie/domain/value-objects/telefoonnummer';
/** What the user is typing (raw, possibly invalid). */
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of
the form — it is authoritative and shown read-only (WP-34); only the phone number
is editable here. */
export interface Draft {
straat: string;
postcode: string;
woonplaats: string;
telefoon: string;
}
/** After parsing — postcode is the branded type, so downstream can't get a raw one. */
/** After parsing — telefoon is the branded type, so downstream can't get a raw one. */
export interface Valid {
straat: string;
postcode: Postcode;
woonplaats: string;
telefoon: Telefoonnummer;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/**
* The change-request (adreswijziging) form as one tagged union — the SAME idiom
* The contact-change (telefoonwijziging) form as one tagged union — the SAME idiom
* as the wizards, just single-step. `draft`/`errors` exist only while Editing;
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
* an invalid draft, a success screen with errors) are unrepresentable.
@@ -31,24 +32,15 @@ export type ChangeRequestState =
export const initial: ChangeRequestState = {
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '' },
draft: { telefoon: '' },
errors: {},
};
/** Parse via the value objects; on success hand back a Valid, else per-field errors. */
/** Parse via the value object; on success hand back a Valid, else per-field errors. */
function validate(draft: Draft): Result<Errors, Valid> {
const straat = draft.straat.trim();
const postcode = parsePostcode(draft.postcode);
const errors: Errors = {};
if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;
if (!postcode.ok) errors.postcode = postcode.error;
if (straat && postcode.ok) {
return {
ok: true,
value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },
};
}
return { ok: false, error: errors };
const telefoon = parseTelefoonnummer(draft.telefoon);
if (telefoon.ok) return { ok: true, value: { telefoon: telefoon.value } };
return { ok: false, error: { telefoon: telefoon.error } };
}
export type ChangeRequestMsg =
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { parseTelefoonnummer } from './telefoonnummer';
describe('parseTelefoonnummer', () => {
it('accepts a 10-digit number starting 0 and strips formatting', () => {
const r = parseTelefoonnummer('06 12 34 56 78');
expect(r.ok && r.value).toBe('0612345678');
});
it('normalises a +31 prefix to a leading 0', () => {
const r = parseTelefoonnummer('+31 6 12345678');
expect(r.ok && r.value).toBe('0612345678');
});
it('rejects a too-short number, a non-0 start, and junk', () => {
expect(parseTelefoonnummer('12345').ok).toBe(false);
expect(parseTelefoonnummer('1612345678').ok).toBe(false);
expect(parseTelefoonnummer('nope').ok).toBe(false);
});
});
@@ -0,0 +1,25 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: a Dutch phone number. "Parse, don't validate" — a Telefoonnummer is
* a distinct type from a raw string, mintable only via parseTelefoonnummer, 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). The parsed value
* is normalised to digits (spaces/dashes/parens dropped, a leading +31 → 0).
*/
export type Telefoonnummer = Brand<string, 'Telefoonnummer'>;
export function parseTelefoonnummer(raw: string): Result<string, Telefoonnummer> {
const digits = raw
.trim()
.replace(/[\s\-()]/g, '')
.replace(/^\+31/, '0');
// Deliberately lax: a Dutch number is 10 digits starting 0 (mobile 06 or landline).
// Good enough for instant feedback; the server re-validates.
if (!/^0\d{9}$/.test(digits)) {
return err(
$localize`:@@validation.telefoon:Voer een geldig telefoonnummer in, bijv. 0612345678.`,
);
}
return ok(digits as Telefoonnummer);
}
@@ -3,21 +3,18 @@ import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@registratie/domain/change-request.machine';
/**
* Infrastructure adapter for the adreswijziging POST (`/api/v1/change-requests`) —
* the single place the network client lives for change requests, so the command
* and the UI never touch `ApiClient`. Returns the server reference; the server
* re-validates and is the authority.
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) —
* the single place the network client lives for contact changes, so the command
* and the UI never touch `ApiClient`. The BRP address is authoritative and not
* submitted (WP-34); only the phone number is. Returns the server reference; the
* server re-validates and is the authority.
*/
@Injectable({ providedIn: 'root' })
export class ChangeRequestAdapter {
private client = inject(ApiClient);
async changeRequest(data: Valid): Promise<string> {
const res = await this.client.changeRequests({
straat: data.straat,
postcode: data.postcode,
woonplaats: data.woonplaats,
});
const res = await this.client.changeRequests({ telefoon: data.telefoon });
return res.referentie ?? '';
}
}
@@ -3,11 +3,9 @@ import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import {
AddressFieldsComponent,
AdresValue,
AdresErrors,
} from '@registratie/ui/address-fields/address-fields.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { Adres } from '@registratie/domain/person';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import {
@@ -19,19 +17,44 @@ import {
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
/**
* Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
* wizards — all state in one signal driven by the pure `reduce`
* (change-request.machine.ts), submitted via a `submit-*` command returning
* `Result`. Renders the shared `<app-address-fields>`; the server re-validates.
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
* and shown READ-ONLY (WP-34) — you change your address at the gemeente, not here — so
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
* `submit-*` command returning `Result`. The server re-validates.
*/
@Component({
selector: 'app-change-request-form',
imports: [FormsModule, ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
imports: [
FormsModule,
ButtonComponent,
HeadingComponent,
AlertComponent,
FormFieldComponent,
TextInputComponent,
],
styles: [
`
.brp {
margin-block-end: var(--rhc-space-max-lg);
}
.brp dt {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.brp dd {
margin: 0 0 var(--rhc-space-max-sm) 0;
}
.brp .source {
color: var(--rhc-color-grijs-700);
font-size: var(--rhc-text-font-size-sm);
}
`,
],
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@changeRequest.success">
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5
werkdagen bericht.
Uw wijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen
bericht.
</app-alert>
<div class="app-section">
<app-button
@@ -42,19 +65,43 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
>
</div>
} @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Adreswijziging doorgeven</app-heading>
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
@if (brpAdres(); as a) {
<dl class="brp app-section">
<dt i18n="@@changeRequest.brpAdresLabel">Adres (BRP)</dt>
<dd>{{ a.straat }}<br />{{ a.postcode }} {{ a.woonplaats }}</dd>
<dd class="source" i18n="@@changeRequest.brpAdresBron">
Uw adres komt uit de Basisregistratie Personen en kan hier niet worden gewijzigd. Wijzig
het bij uw gemeente.
</dd>
</dl>
}
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
<div class="form-header">
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<app-address-fields
idPrefix="cr"
[value]="adres()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })"
/>
<app-form-field
i18n-label="@@changeRequest.telefoonLabel"
label="Telefoonnummer"
fieldId="cr-telefoon"
required
[error]="errors().telefoon"
>
<app-text-input
inputId="cr-telefoon"
[invalid]="!!errors().telefoon"
[ngModel]="telefoon()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'telefoon', value: $event })"
name="telefoon"
i18n-placeholder="@@changeRequest.telefoonPlaceholder"
placeholder="0612345678"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
@if (failedError()) {
<app-alert type="error"
@@ -77,6 +124,9 @@ export class ChangeRequestFormComponent {
private submit = createSubmitChangeRequest();
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce);
/** BRP address, shown read-only. Undefined until the profile loads. */
brpAdres = input<Adres | undefined>(undefined);
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<ChangeRequestState>(initial);
@@ -87,19 +137,17 @@ export class ChangeRequestFormComponent {
protected readonly submitBezigLabel = $localize`:@@changeRequest.submitBezig:Bezig met indienen…`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {});
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');
/** The address shown in the fields — the live draft while editing, the parsed
data while submitting/failed (so the user sees what they sent). */
protected adres = computed<AdresValue>(() => {
/** The phone shown in the field — the live draft while editing, the parsed value
while submitting/failed (so the user sees what they sent). */
protected telefoon = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft;
if (s.tag === 'Submitting' || s.tag === 'Failed') {
return { straat: s.data.straat, postcode: s.data.postcode, woonplaats: s.data.woonplaats };
}
return { straat: '', postcode: '', woonplaats: '' }; // Submitted shows the success alert, not the fields
if (s.tag === 'Editing') return s.draft.telefoon;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.telefoon;
return '';
});
constructor() {
@@ -3,38 +3,31 @@ import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { ChangeRequestFormComponent } from './change-request-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Postcode } from '@registratie/domain/value-objects/postcode';
import { Telefoonnummer } from '@registratie/domain/value-objects/telefoonnummer';
const validData = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA' as Postcode,
woonplaats: 'Den Haag',
};
const brpAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' };
const validData = { telefoon: '0612345678' as Telefoonnummer };
const meta: Meta<ChangeRequestFormComponent> = {
title: 'Domein/Registratie/Change Request Form',
component: ChangeRequestFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
args: { brpAdres },
};
export default meta;
type Story = StoryObj<ChangeRequestFormComponent>;
// One render per state of the machine.
export const Empty: Story = {
args: {
seed: { tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '' }, errors: {} },
},
args: { seed: { tag: 'Editing', draft: { telefoon: '' }, errors: {} } },
};
export const WithErrors: Story = {
args: {
seed: {
tag: 'Editing',
draft: { straat: '', postcode: 'nope', woonplaats: '' },
errors: {
straat: 'Vul straat en huisnummer in.',
postcode: 'Voer een geldige postcode in, bijv. 1234 AB.',
},
draft: { telefoon: 'nope' },
errors: { telefoon: 'Voer een geldig telefoonnummer in, bijv. 0612345678.' },
},
},
};
@@ -33,7 +33,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
</app-async>
<div class="app-section">
<app-change-request-form />
<app-change-request-form [brpAdres]="profile()?.person?.adres" />
</div>
</app-page-shell>
`,