Add ASP.NET Core backend hosting business rules; FE consumes via typed client

Move the authoritative business rules off the frontend into a real backend,
realising the BFF-lite + decision-DTO design (ADR-0001) that until now lived
only in static mock JSON.

Backend (backend/):
- ASP.NET Core (.NET 10) minimal API, contract-first, Swagger UI at /swagger.
- DDD Domain/ rules layer: profession derivation + applicable policy questions
  (DiplomaRules), herregistratie eligibility + reason (HerregistratieRule),
  scholing threshold (IntakePolicy), submit rejections + reference generation
  (SubmissionRules). In-memory seeded data, ProblemDetails (RFC 7807) errors.
- 27 xUnit tests: rule units + endpoint integration incl. BRP no-address and
  DUO not-found fallbacks and 422 submit paths.

Frontend (only infrastructure/ + contracts/ change, as the architecture promised):
- NSwag-generated typed client (api-client.ts), routed through Angular HttpClient
  via a small fetch adapter so the ?scenario= interceptor still applies.
- GET adapters use resource({ loader: client.x }); submit commands call the client
  and map ProblemDetails -> err. The hardcoded uren==0 / manual-diploma rules are
  deleted (now server-side). Domain, stores, UI and format validators unchanged.
- Deleted the now-dead public/mock/*.json.

Tooling/docs:
- npm start proxies /api -> backend; npm run gen:api regenerates the client;
  docker compose up runs both (bind mounts use :z for SELinux/Fedora).
- backend/README.md walkthrough: adding a policy question is a one-file backend
  change, no FE change, no client regen. Updated CLAUDE.md + ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 20:05:53 +02:00
parent 4e9af05cc1
commit cf570a8132
62 changed files with 2618 additions and 394 deletions

View File

@@ -1,14 +1,13 @@
import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';
import { Component, computed, effect, inject, input, untracked } 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 { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
@@ -28,6 +27,7 @@ import {
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { submitRegistratie } from '@registratie/application/submit-registratie';
import { ApiClient } from '@shared/infrastructure/api-client';
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' }];
@@ -44,17 +44,27 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
selector: 'app-registratie-wizard',
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent,
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
AddressFieldsComponent, ...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') {
<app-wizard-shell
[steps]="stepLabels"
[current]="cursor()"
[stepTitle]="stepTitle()"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
[errors]="errorList()"
[errorMessage]="errorMessage()"
submittingLabel="Uw registratie wordt verwerkt…"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()">
@switch (step()) {
@case ('adres') {
@if (adresStatus() === 'laden') {
<app-skeleton height="2.5rem" [count]="4" />
} @else {
@@ -140,38 +150,22 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
<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') {
<div wizardSuccess>
<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>
}
}
</div>
</app-wizard-shell>
`,
})
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();
@@ -186,9 +180,6 @@ export class RegistratieWizardComponent {
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: {} });
@@ -196,6 +187,30 @@ export class RegistratieWizardComponent {
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 : ''));
// --- Presentational wiring for the shared wizard shell ---------------------
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));
protected errorMessage = computed(() => `Het indienen is niet gelukt: ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
case 'Invullen': return 'editing';
case 'Indienen': return 'submitting';
case 'Ingediend': return 'submitted';
case 'Mislukt': return 'failed';
}
});
/** Current step's errors (incl. per-question), flattened for the error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.invullen()?.errors ?? {};
const out: WizardError[] = [];
for (const [k, v] of Object.entries(e)) {
if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v });
}
for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) {
if (msg) out.push({ id: 'vraag-' + qid, message: msg });
}
return out;
});
protected adresSamenvatting = computed(() => {
const d = this.draft();
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
@@ -302,18 +317,8 @@ export class RegistratieWizardComponent {
}
});
});
// 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());
});
});
// A11y: focus management (step heading on step change, error summary on a
// failed submit) now lives in the shared WizardShellComponent.
}
private restore(): RegistratieState | null {
@@ -350,7 +355,7 @@ export class RegistratieWizardComponent {
private async runIfIndienen() {
const s = this.state();
if (s.tag !== 'Indienen') return;
const r = await submitRegistratie(s.data);
const r = await submitRegistratie(this.apiClient, s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}