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