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

@@ -42,9 +42,10 @@ export class BigProfileStore {
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[]>>(() =>
fromResource(this.aantekeningenRes, (v) => v.length === 0),
);
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0);
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd;
});
// --- Optimistic herregistratie state, shared with the dashboard -----------
private pending = signal(false);

View File

@@ -0,0 +1,27 @@
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,19 +1,20 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { problemDetail } from '@shared/infrastructure/api-error';
/**
* 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.
* Command: POST the completed registration to the backend (`/api/registrations`),
* which re-validates and decides. The business rule that a manually entered
* diploma cannot be auto-verified now lives server-side; the backend returns it as
* a 422 ProblemDetails, which we surface as the error. On success it yields the
* server-generated confirmation reference (PRD §9).
*/
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.');
export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
try {
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
return ok(res.referentie ?? '');
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
return ok(referentie);
}

View File

@@ -1,19 +1,26 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Aantekening } from '../domain/registration';
import { Injectable, inject, resource } from '@angular/core';
import { Aantekening, AantekeningType } from '../domain/registration';
import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client';
/**
* 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).
* resources (Angular's `resource` over the generated typed client); 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 {
private client = inject(ApiClient);
aantekeningenResource() {
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
return resource({ loader: () => this.client.notes().then((ns) => ns.map(toAantekening)) });
}
}
/** Map the wire DTO (all fields optional) onto our domain type. */
function toAantekening(n: AantekeningDto): Aantekening {
return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' };
}

View File

@@ -1,19 +1,20 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
* 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.
* ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET
* backend (`GET /api/brp/address`) via the generated typed client.
*/
@Injectable({ providedIn: 'root' })
export class BrpAdapter {
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
// parseBrpAddress validates it.
private client = inject(ApiClient);
// The value is untrusted JSON until parseBrpAddress validates it.
adresResource() {
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
return resource({ loader: () => this.client.address() });
}
}

View File

@@ -1,20 +1,22 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
* 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.)
* ONE call returns registration + person + server-computed decisions. The data
* comes from the .NET backend (`GET /api/dashboard-view`) via the generated typed
* client; the decisions (e.g. herregistratie eligibility) are computed there.
*/
@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.
private client = inject(ApiClient);
// The value is still untrusted JSON — parseDashboardView validates it at the
// boundary and maps DTO → domain before the app uses it.
dashboardViewResource() {
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
return resource({ loader: () => this.client.dashboardView() });
}
}

View File

@@ -1,21 +1,21 @@
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Injectable, inject, resource } from '@angular/core';
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: [] } };
import { ApiClient } from '@shared/infrastructure/api-client';
/**
* 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.
* the manual-entry fallback policy. The frontend renders; it does not derive.
* Data comes from the .NET backend (`GET /api/duo/diplomas`) via the typed client.
*/
@Injectable({ providedIn: 'root' })
export class DuoAdapter {
private client = inject(ApiClient);
diplomasResource() {
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
return resource({ loader: () => this.client.diplomas() });
}
}

View File

@@ -18,61 +18,69 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
],
template: `
<app-page-shell heading="Mijn BIG-registratie">
@if (store.pendingHerregistratie()) {
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
}
<div class="app-stack">
@if (store.pendingHerregistratie()) {
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
}
<!-- ONE state for two combined services (BIG-register + BRP). -->
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(p).registration" />
<div class="rhc-card rhc-card--default" style="margin-top:1rem">
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
<dl class="rhc-data-summary rhc-data-summary--row">
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
</dl>
</div>
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<div style="margin-top:2rem">
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
<app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" />
<!-- ONE state for two combined services (BIG-register + BRP). -->
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(p).registration" />
<div class="rhc-card rhc-card--default app-section">
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
<dl class="rhc-data-summary rhc-data-summary--row">
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
</dl>
</div>
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
</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>
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
</p>
<p>
<app-link to="/intake">Herregistratie-intake (vragenlijst met vertakkingen) →</app-link>
</p>
<p>
<app-link to="/concepts">Functionele patronen (impossible states) →</app-link>
</p>
<section>
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
<app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" />
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
</ng-template>
</app-async>
</section>
<section>
<app-heading [level]="2">Wat wilt u doen?</app-heading>
<ul class="app-card-grid app-section">
@for (a of acties; track a.to) {
<li class="rhc-card rhc-card--default">
<app-heading [level]="3">{{ a.titel }}</app-heading>
<p class="rhc-paragraph">{{ a.tekst }}</p>
<app-link [to]="a.to">{{ a.actie }} →</app-link>
</li>
}
</ul>
</section>
</div>
</app-page-shell>
`,
})
export class DashboardPage {
protected store = inject(BigProfileStore);
/** Primary actions, rendered as an accessible card grid. */
protected readonly acties = [
{ to: '/registreren', titel: 'Inschrijven', tekst: 'Schrijf u in in het BIG-register via de registratiewizard.', actie: 'Start inschrijving' },
{ to: '/registratie', titel: 'Mijn gegevens', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' },
{ to: '/herregistratie', titel: 'Herregistratie', tekst: 'Vraag uw herregistratie aan.', actie: 'Vraag aan' },
{ to: '/intake', titel: 'Herregistratie-intake', tekst: 'Vragenlijst met vertakkingen.', actie: 'Start intake' },
{ to: '/concepts', titel: 'Functionele patronen', tekst: 'Onmogelijke toestanden onmogelijk maken.', actie: 'Bekijk patronen' },
];
}

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 });
}