Mijn aanvragen (E): two-flow submit through the aanvraag + all-wizard persistence

All three wizards now submit through the backend aanvraag lifecycle, so a
submitted Concept actually transitions (dashboard shows it correctly in F).

- blockActions(status) (domain + spec): the pure per-status action decision
  (Concept → resume/cancel; In behandeling → viewDocuments; resolved → none).
- createDraftSync.submit(): ensure the Concept exists, then
  POST /applications/{id}/submit; folded into a Result like the old commands.
- registratie: submit via draftSync (duo → auto, handmatig → manual pending — the
  old 422 path is gone from the wizard).
- intake + herregistratie: adopt createDraftSync (persistence + resume-by-link);
  intake retires sessionStorage `intake-v3`; herregistratie gains persistence.
  Both submit through the aanvraag too. hasProgress added to each machine (+spec).
- Delete now-dead submit-registratie/submit-intake/submit-herregistratie commands.

Deferred: the old /registrations, /intakes, /herregistraties backend endpoints +
RejectRegistratie are now unused by the FE but still present (+ tested) — retiring
them cascades into backend test rewrites, so it's a focused follow-up cleanup.
Gates green: vitest 128, lint, build; backend unchanged (dotnet 56).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:25:02 +02:00
parent 6db7f1e673
commit 9f217abe19
15 changed files with 136 additions and 144 deletions

View File

@@ -1,15 +0,0 @@
import { Result } from '@shared/kernel/fp';
import { Valid } from '../domain/herregistratie.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
/**
* Command: POST a herregistratie application to the backend (`/api/herregistraties`).
* The "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced
* as the error by `runSubmit`.
*/
export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
return runSubmit<void>(async () => {
await client.herregistraties({ uren: data.uren, documents: data.documents });
}, SUBMIT_FAILED);
}

View File

@@ -1,26 +0,0 @@
import { describe, it, expect } from 'vitest';
import { submitIntake } from './submit-intake';
import { ApiClient } from '@shared/infrastructure/api-client';
import { ValidIntake } from '../domain/intake.machine';
// submit-herregistratie shares this exact shape (POST uren → Result); covered here.
const data = { uren: 40 } as unknown as ValidIntake;
describe('submitIntake', () => {
it('returns ok on success', async () => {
const client = { intakes: async () => ({ referentie: 'BIG-2026-1' }) } as unknown as ApiClient;
const r = await submitIntake(client, data);
expect(r.ok).toBe(true);
});
it('returns err with the ProblemDetails detail when the server rejects', async () => {
const client = {
intakes: async () => {
throw { detail: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.', status: 422 };
},
} as unknown as ApiClient;
const r = await submitIntake(client, data);
expect(r.ok).toBe(false);
expect(r).toMatchObject({ error: expect.stringContaining('geen gewerkte uren') });
});
});

View File

@@ -1,15 +0,0 @@
import { Result } from '@shared/kernel/fp';
import { ValidIntake } from '../domain/intake.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
/**
* Command: POST the intake questionnaire to the backend (`/api/intakes`). The
* "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced as
* the error by `runSubmit`.
*/
export function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
return runSubmit<void>(async () => {
await client.intakes({ uren: data.uren });
}, SUBMIT_FAILED);
}

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { hasProgress, initial, WizardState } from './herregistratie.machine';
const editing = initial as Extract<WizardState, { tag: 'Editing' }>;
describe('herregistratie hasProgress', () => {
it('is false for a fresh form', () => {
expect(hasProgress(editing)).toBe(false);
});
it('is true once a field is filled or the user advances', () => {
expect(hasProgress({ ...editing, draft: { uren: '40', jaren: '', punten: '' } })).toBe(true);
expect(hasProgress({ ...editing, step: 2 })).toBe(true);
});
});

View File

@@ -40,6 +40,11 @@ export type WizardState =
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload };
/** Has the user meaningfully started, so it's worth persisting as a Concept? */
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
return s.step > 1 || !!s.draft.uren || !!s.draft.jaren || !!s.draft.punten || deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId);
}
/** Parse every field; on success hand back a Valid, else the per-field errors. */
function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid> {
const uren = parseUren(draft.uren);

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { hasProgress, initial, IntakeState } from './intake.machine';
const answering = initial as Extract<IntakeState, { tag: 'Answering' }>;
describe('intake hasProgress', () => {
it('is false for a fresh questionnaire', () => {
expect(hasProgress(answering)).toBe(false);
});
it('is true once an answer is given or the user advances', () => {
expect(hasProgress({ ...answering, answers: { buitenlandGewerkt: 'ja' } })).toBe(true);
expect(hasProgress({ ...answering, cursor: 1 })).toBe(true);
});
});

View File

@@ -68,6 +68,12 @@ export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): Step
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
}
/** Has the user meaningfully started, so it's worth persisting as a Concept?
(No auto-prefill here — pristine means truly untouched.) */
export function hasProgress(s: Extract<IntakeState, { tag: 'Answering' }>): boolean {
return s.cursor > 0 || Object.keys(s.answers).length > 0;
}
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result<Errors, void> {
const errors: Errors = {};

View File

@@ -7,12 +7,11 @@ import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
import { ApiClient } from '@shared/infrastructure/api-client';
import { WizardState, WizardMsg, Draft, initial, reduce, hasProgress } from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -77,7 +76,6 @@ import { UploadState, initialUpload } from '@shared/upload/upload.machine';
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
private apiClient = inject(ApiClient);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */
@@ -86,6 +84,20 @@ export class HerregistratieWizardComponent {
readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as WizardState | null) ?? initial }),
enabled: () => this.seed() === initial,
});
// Stepper labels + per-step heading titles (presentational only).
readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.documenten:Documenten`];
private stepTitles = [$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`, $localize`:@@herregWizard.title.nascholing:Nascholing`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`];
@@ -126,7 +138,10 @@ export class HerregistratieWizardComponent {
});
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
}
onPrimary() {
@@ -143,16 +158,17 @@ export class HerregistratieWizardComponent {
/** Reset the wizard to a fresh, empty start. */
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we entered Submitting, call the backend command, flip the
optimistic cross-page flag, then dispatch the result (and commit/rollback). */
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitHerregistratie(this.apiClient, s.data);
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();

View File

@@ -19,14 +19,12 @@ import {
reduce,
STEPS,
lageUren,
hasProgress,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { submitIntake } from '@herregistratie/application/submit-intake';
import { ApiClient } from '@shared/infrastructure/api-client';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter';
const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration.
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier
@@ -111,7 +109,6 @@ const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted s
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private apiClient = inject(ApiClient);
private policy = inject(IntakePolicyAdapter);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
@@ -126,6 +123,18 @@ export class IntakeWizardComponent {
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
private draftSync = createDraftSync({
type: 'intake',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Answering' || !hasProgress(s)) return null;
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as IntakeState | null) ?? initial }),
enabled: () => this.seed() === initial,
});
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
@@ -169,16 +178,10 @@ export class IntakeWizardComponent {
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
constructor() {
// An explicit seed (stories) wins; otherwise resume from sessionStorage.
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
// Persist only while answering; clear once the flow is done. G1: sessionStorage
// (not localStorage) — the draft holds personal data and must not outlive the tab.
effect(() => {
const s = this.state();
if (s.tag === 'Answering') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else sessionStorage.removeItem(STORAGE_KEY);
});
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
// Apply the server-owned threshold into machine state as it arrives. Track
// only the policy value; untrack the dispatch (it reads the state signal
// internally, which would otherwise make this effect loop on its own write).
@@ -188,17 +191,6 @@ export class IntakeWizardComponent {
});
}
private restore(): IntakeState | null {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as IntakeState;
return parsed?.tag === 'Answering' ? parsed : null; // G2: only resume a known shape.
} catch {
return null; // ponytail: corrupt entry -> start fresh, no migration.
}
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Answering') return;
@@ -212,16 +204,17 @@ export class IntakeWizardComponent {
}
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we enter Submitting, call the backend, flip the optimistic
cross-page flag, then dispatch the outcome (and commit/rollback). */
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitIntake(this.apiClient, s.data);
const r = await this.draftSync.submit({ uren: s.data.uren });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();

View File

@@ -1,5 +1,8 @@
import { DestroyRef, effect, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
@@ -94,6 +97,13 @@ export function createDraftSync(deps: DraftSyncDeps) {
.catch(() => deps.onResume(null)); // unknown/deleted id → start fresh
},
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
`POST /applications/{id}/submit` (server sets autoApprovable + transitions).
Folded into a Result like the old submit-* commands. */
submit(body: SubmitApplicationRequest): Promise<Result<string, SubmitApplicationResponse>> {
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
},
/** Detach from the current Concept (a new one is created on next progress) and
drop the `?aanvraag` link. Used when the wizard restarts. */
reset() {

View File

@@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest';
import { submitRegistratie } from './submit-registratie';
import { ApiClient } from '@shared/infrastructure/api-client';
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
// Mocked at the client boundary: the rule itself lives server-side now, so these
// tests only assert that the command maps the client's response onto a Result.
const data = { diplomaHerkomst: 'duo' } as unknown as ValidRegistratie;
describe('submitRegistratie', () => {
it('returns ok with the server reference on success', async () => {
const client = { registrations: async () => ({ referentie: 'BIG-2026-123456' }) } as unknown as ApiClient;
const r = await submitRegistratie(client, data);
expect(r).toEqual({ ok: true, value: 'BIG-2026-123456' });
});
it('returns err with the ProblemDetails detail when the server rejects (422)', async () => {
const client = {
registrations: async () => {
throw { detail: 'Handmatig diploma kan niet automatisch worden geverifieerd.', status: 422 };
},
} as unknown as ApiClient;
const r = await submitRegistratie(client, data);
expect(r.ok).toBe(false);
expect(r).toMatchObject({ error: expect.stringContaining('Handmatig diploma') });
});
});

View File

@@ -1,17 +0,0 @@
import { Result } from '@shared/kernel/fp';
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
/**
* Command: POST the completed registration to the backend (`/api/registrations`),
* which re-validates and decides. The rule that a manually entered diploma cannot
* be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On
* success it yields the server-generated confirmation reference (PRD §9).
*/
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
return runSubmit(async () => {
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst, documents: data.documents });
return res.referentie ?? '';
}, SUBMIT_FAILED);
}

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { blockActions } from './block-actions';
describe('blockActions', () => {
it('a Concept can be resumed or cancelled', () => {
expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual(['resume', 'cancel']);
});
it('an in-behandeling aanvraag only exposes its documents', () => {
expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual(['viewDocuments']);
});
it('resolved aanvragen have no actions', () => {
expect(blockActions({ tag: 'Goedgekeurd', referentie: 'BIG-1' })).toEqual([]);
expect(blockActions({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' })).toEqual([]);
});
});

View File

@@ -0,0 +1,18 @@
import { AanvraagStatus } from './aanvraag';
/** What a dashboard "Mijn aanvragen" block offers per status. The badge itself
follows directly from `status.tag` (the UI maps tag → colour + label), so this
pure function owns only the *actions* decision. */
export type BlockAction = 'resume' | 'cancel' | 'viewDocuments';
export function blockActions(status: AanvraagStatus): BlockAction[] {
switch (status.tag) {
case 'Concept':
return ['resume', 'cancel'];
case 'InBehandeling':
return ['viewDocuments'];
case 'Goedgekeurd':
case 'Afgewezen':
return [];
}
}

View File

@@ -28,9 +28,7 @@ import {
hasProgress,
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { submitRegistratie } from '@registratie/application/submit-registratie';
import { createDraftSync } from '@registratie/application/draft-sync';
import { ApiClient } from '@shared/infrastructure/api-client';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
@@ -185,7 +183,6 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
export class RegistratieWizardComponent {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private apiClient = inject(ApiClient);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
protected adresRes = this.brp.adresResource();
@@ -375,13 +372,13 @@ export class RegistratieWizardComponent {
this.adresRes.reload();
}
/** The effect: when we enter Indienen, call the backend, then dispatch the
outcome (success carries the confirmation reference). */
/** The effect: when we enter Indienen, submit through the aanvraag lifecycle
(duo → auto-approve, handmatig → manual), then dispatch the outcome. */
private async runIfIndienen() {
const s = this.state();
if (s.tag !== 'Indienen') return;
const r = await submitRegistratie(this.apiClient, s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
const r = await this.draftSync.submit({ diplomaHerkomst: s.data.diplomaHerkomst, documents: s.data.documents });
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}