Merge feat/dotnet-backend: .NET backend + upload feature (+ tab-crash fix)

This commit is contained in:
2026-07-01 10:27:57 +02:00
156 changed files with 7876 additions and 739 deletions

48
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: CI
on:
push:
pull_request:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run check:tokens
- run: npm test
- run: npm run build
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- run: dotnet test backend/BigRegister.sln
api-client-drift:
# The committed typed client must match the backend OpenAPI doc.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- uses: actions/setup-dotnet@v4
with:
# 8.0 for the bundled NSwag runtime, 10.0 to build/emit the spec.
dotnet-version: |
8.0.x
10.0.x
- run: npm ci
- run: npm run gen:api
- run: git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json

View File

@@ -8,15 +8,21 @@ update this file.
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
view their registration, apply for re-registration). Angular 22, standalone,
signals. No real backend/auth — static mock JSON + fake timers.
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed
through an NSwag-generated typed client. The FE renders the backend's decisions.
## Commands
```bash
npm start # ng serve → http://localhost:4200
npm start # ng serve (proxies /api → backend) → http://localhost:4200
npm test # vitest
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
npm run build # ng build (must stay green)
npm run storybook # component library by atomic layer
npm run gen:api # regenerate the typed client from the backend OpenAPI doc
docker compose up # run FE + backend together (Swagger at :5000/swagger)
cd backend && dotnet test # backend rule + endpoint tests
```
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
@@ -109,16 +115,36 @@ not heavy component tests.
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
constructor DI (constructor only for `effect()`/template-ref injection).
- Angular-native control flow `@if/@for`; native `httpResource` for fetching;
`withViewTransitions()` for page transitions (header/footer have stable
`view-transition-name`, excluded from the fade).
- Angular-native control flow `@if/@for`; fetch via `resource({ loader })` over the
generated `ApiClient` inside an `infrastructure/*.adapter.ts` (one place HTTP lives),
with a `parse*` boundary; `withViewTransitions()` for page transitions (header/footer
have stable `view-transition-name`, excluded from the fade).
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
`*.machine.ts`). Pick the language by which side of the seam the code is on.
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
first-party `$localize` (no third-party i18n lib), with a stable custom id
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
translation file, not a code change (the seam). Shared/English components must **not**
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
supplies the text (see `shared/ui/async`). Format-validation messages in
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
fields + ad-hoc error signals.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`).
- Theming is one import — `src/styles.scss` pulls the RHC palette; no hand-written theme.
- Scenario toggle: `?scenario=slow|loading|empty|error` on data pages
(`scenario.interceptor.ts`) to see every async state.
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
on data pages (`scenario.interceptor.ts`) to see every async state.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
on `any` and on illegal imports — `domain/` importing Angular, or a context importing
"upward" (the `herregistratie → registratie → shared`, `auth → shared` direction).
CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend
`dotnet test`, and an API-client drift check.
## Adding a feature (recipe)
@@ -129,5 +155,5 @@ dispatch messages). Worked example: the intake wizard (`herregistratie/`).
## Out of scope (POC, don't build unprompted)
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo,
runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen.
Real auth/DigiD, NgRx, licensed Rijkshuisstijl font/logo,
runtime DTO validation on every endpoint, multi-tab session sync.

View File

@@ -6,20 +6,35 @@ register of healthcare professionals, run by CIBG). It looks like an NL Design S
app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where
the UI can never reach an inconsistent state.
> Demo / POC — no real data, no real login. Free **Fira Sans** stands in for the
> licensed Rijksoverheid font and a text wordmark for the logo.
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
> business rules and data *are* served by a real **ASP.NET Core backend**
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
> is demonstrable, not hand-waved. Free **Fira Sans** stands in for the licensed
> Rijksoverheid font and a text wordmark for the logo.
---
## Run it
```bash
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
```
Or run the two halves separately:
```bash
npm install
npm start # app → http://localhost:4200
npm run storybook # component library, organized by atomic layer
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
# in another terminal:
cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
npm run storybook # component library, organized by atomic layer
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
```
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
The backend hosts the business rules (profession derivation, policy questions,
eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
> appear based on earlier answers and progress survives a page reload, plus a visual
@@ -96,9 +111,12 @@ import to re-theme the whole app — no component changes.
## State management (no impossible states)
Data fetching uses Angular's native, signal-based **`httpResource`** (no NgRx,
no extra dependency). `core/registration.service.ts` exposes resources that carry
`status()`, `value()`, `error()`, `hasValue()` and `reload()` as signals.
Data fetching uses Angular's native, signal-based **`resource`** over the generated
typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
exposes a resource that carries `status()`, `value()`, `error()` and `reload()` as
signals, and a `parse*` function validates the response at the trust boundary
(DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed
decisions the FE renders rather than recomputes (see ADR-0001).
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
@@ -142,7 +160,10 @@ degrade to an instant navigation.
control flow `@if/@for`).
- Styling: `@rijkshuisstijl-community/{design-tokens,components-css}` (Utrecht + RHC CSS,
pre-themed Rijkshuisstijl) — imported in `src/styles.scss`, no hand-written theme.
- Mock data: JSON in `public/mock/`, timing/outcome shaped by `core/scenario.interceptor.ts`.
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
**dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified).
@@ -157,4 +178,5 @@ Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC)
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl fonts/logo.
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
NgRx, licensed Rijkshuisstijl fonts/logo. (The backend itself *is* implemented.)

View File

@@ -30,9 +30,8 @@
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
"styles": ["src/styles.scss"],
"polyfills": ["@angular/localize/init"]
},
"configurations": {
"production": {
@@ -48,7 +47,13 @@
"maximumError": "8kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
@@ -79,12 +84,7 @@
"configDir": ".storybook",
"browserTarget": "atomic-design-poc:build",
"compodoc": true,
"compodocArgs": [
"-e",
"json",
"-d",
"."
],
"compodocArgs": ["-e", "json", "-d", "."],
"port": 6006
}
},
@@ -94,16 +94,11 @@
"configDir": ".storybook",
"browserTarget": "atomic-design-poc:build",
"compodoc": true,
"compodocArgs": [
"-e",
"json",
"-d",
"."
],
"compodocArgs": ["-e", "json", "-d", "."],
"outputDir": "storybook-static"
}
}
}
}
}
}
}

2
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bin/
obj/

8
backend/BigRegister.slnx Normal file
View File

@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/BigRegister.Api/BigRegister.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/BigRegister.Tests/BigRegister.Tests.csproj" />
</Folder>
</Solution>

112
backend/README.md Normal file
View File

@@ -0,0 +1,112 @@
# BIG-register BFF (ASP.NET Core)
The backend that hosts the **business rules** for the BIG-register portal. The
frontend renders the decisions this service computes; it does not recompute them
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`),
but the endpoints, DTOs, status codes and error envelope are production-shaped.
## Run
### Everything (docker-compose, from repo root)
```bash
docker compose up
```
- App: <http://localhost:4200>
- Swagger UI: <http://localhost:5000/swagger>
### Backend only (local)
```bash
cd backend
dotnet run --project src/BigRegister.Api
# → http://localhost:5000/swagger
```
### Frontend against a local backend
```bash
npm start # ng serve, proxies /api → http://localhost:5000 (proxy.conf.json)
```
### Tests
```bash
cd backend && dotnet test # rule unit tests + endpoint integration tests
```
## API
| Method | Route | Purpose |
|---|---|---|
| GET | `/api/dashboard-view` | registration + person + computed herregistratie decision |
| GET | `/api/notes` | specialisms / aantekeningen |
| GET | `/api/brp/address` | BRP address lookup (`gevonden:false` = no address) |
| GET | `/api/duo/diplomas` | diplomas with derived profession + applicable policy questions, + manual fallback |
| GET | `/api/intake/policy` | scholing threshold (config value) |
| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) |
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) |
Rejections use **ProblemDetails (RFC 7807)** with status **422**. Every request
carries an `X-Correlation-Id` (set by the FE fetch adapter); the backend echoes it
into a no-PII submit-audit log line (`kind`, `outcome`, `reference`, correlation id)
— the seam for real structured logging / an audit store.
### Versioning
Endpoints live under **`/api/v1`**. Additive changes (a new optional field) stay on
v1: the NSwag-generated client and the FE `parse*` boundary ignore unknown fields,
so old clients keep working. A breaking change (renamed/removed field, changed
semantics) is introduced as **`/api/v2`** served alongside v1 until clients migrate.
## Where the rules live (`src/BigRegister.Api/Domain/`)
- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply.
- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant.
- `Intake/IntakePolicy.cs` — scholing threshold.
- `Submissions/SubmissionRules.cs` — submit rejections + reference generation.
## Typed client (NSwag)
The frontend calls this API through a generated TypeScript client. Regenerate it
from the contract after a **shape** change:
```bash
npm run gen:api # builds backend → swagger.json → src/app/shared/infrastructure/api-client.ts
```
## Maintainability: changing a policy is one backend change
**Goal:** require every *Verpleegkundige* diploma to confirm a Dutch skills
assessment. This is a new policy question on a diploma type.
Edit **one file**`Domain/Diplomas/DiplomaRules.cs`:
```diff
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
if (d.Engelstalig)
questions.Add(NlTaalEngelstalig);
+ if (d.Opleiding == "verpleegkunde")
+ questions.Add(new PolicyQuestion(
+ "bekwaamheid",
+ "Heeft u in de afgelopen vijf jaar een bekwaamheidstoets afgelegd?",
+ QuestionType.JaNee));
return questions;
}
```
Rebuild the backend (`docker compose up` or `dotnet run`). The new question now
appears in the registration wizard for HBO-Verpleegkunde.
- **No frontend change.** The FE renders whatever questions the API returns.
- **No client regeneration.** The wire shape (`PolicyQuestionDto`) is unchanged —
only the data behind it. `npm run gen:api` is only needed when a DTO *shape* changes.
Add a unit test for the new rule in `tests/BigRegister.Tests/RuleTests.cs` and
you're done.

13
backend/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "10.2.3",
"commands": [
"swagger"
],
"rollForward": false
}
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,73 @@
namespace BigRegister.Api.Contracts;
// Wire contracts (the FE⇄BE seam). Field names + shapes mirror the frontend's
// contracts/*.dto.ts 1:1; the NSwag-generated TS client is produced from these.
public sealed record AdresDto(string Straat, string Postcode, string Woonplaats);
public sealed record RegistrationStatusDto(
string Tag,
string? HerregistratieDatum = null,
string? GeschorstTot = null,
string? Reden = null,
string? DoorgehaaldOp = null);
public sealed record RegistrationDto(
string BigNummer,
string Naam,
string Beroep,
string Registratiedatum,
string Geboortedatum,
RegistrationStatusDto Status);
public sealed record PersonDto(string Naam, string Geboortedatum, AdresDto Adres);
public sealed record HerregistratieDecisionsDto(bool EligibleForHerregistratie, string? HerregistratieReason);
public sealed record DashboardViewDto(RegistrationDto Registration, PersonDto Person, HerregistratieDecisionsDto Decisions);
public sealed record AantekeningDto(string Type, string Omschrijving, string Datum);
public sealed record BrpAddressDto(bool Gevonden, AdresDto? Adres);
public sealed record PolicyQuestionDto(string Id, string Vraag, string Type);
public sealed record DuoDiplomaDto(
string Id,
string Naam,
string Instelling,
int Jaar,
string Beroep,
IReadOnlyList<PolicyQuestionDto> PolicyQuestions);
public sealed record ManualDiplomaPolicyDto(IReadOnlyList<string> Beroepen, IReadOnlyList<PolicyQuestionDto> PolicyQuestions);
public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> Diplomas, ManualDiplomaPolicyDto Handmatig);
public sealed record IntakePolicyDto(int ScholingThreshold);
// --- Document upload contracts ---
public sealed record DocumentCategoryDto(
string CategoryId, string Label, string Description, bool Required,
IReadOnlyList<string> AcceptedTypes, int MaxSizeMb, bool Multiple, bool AllowPostDelivery);
public sealed record UploadCategoriesDto(IReadOnlyList<DocumentCategoryDto> Categories);
public sealed record UploadResponse(string DocumentId, string LocalId);
public sealed record UploadStatusItemDto(string LocalId, string Status, string? DocumentId);
public sealed record UploadStatusDto(IReadOnlyList<UploadStatusItemDto> Results);
// Per-category delivery intent on submit: digital categories carry their DocumentId,
// post-delivery categories carry Channel="post".
public sealed record DocumentRefDto(string CategoryId, string Channel, string? DocumentId = null);
// Submit requests carry only the fields the server re-validates (UX-only fields
// stay on the client). ponytail: a real submit would carry the full application.
public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record IntakeRequest(int Uren);
public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats);
public sealed record ReferentieResponse(string Referentie);

View File

@@ -0,0 +1,37 @@
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations;
namespace BigRegister.Api.Contracts;
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
public static class Mappers
{
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
public static DuoDiplomaDto ToDto(this Diploma d) => new(
d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
}

View File

@@ -0,0 +1,101 @@
namespace BigRegister.Api.Data;
/// <summary>
/// Stored document metadata. The demo deliberately keeps NO file content — only
/// metadata — which is enough for status/delete/link and avoids holding PII bytes
/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId.
/// </summary>
public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string Owner, DateTimeOffset UploadedAt)
{
public bool Linked { get; set; }
}
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
/// <summary>
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
/// for a single-process demo store; swap for per-key locks if it ever serves load.
/// The audit log holds metadata only (never file content or other PII).
/// </summary>
public static class DocumentStore
{
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
private static readonly Dictionary<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, long sizeBytes, string owner)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, sizeBytes, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc;
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
}
public static StoredDocument? Get(string documentId)
{
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
}
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d)) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
}
public static IReadOnlyList<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
}
}

View File

@@ -0,0 +1,42 @@
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations;
namespace BigRegister.Api.Data;
/// <summary>
/// In-memory seeded synthetic data — no DB, no real BRP/DUO. Stands in for the
/// upstream systems so the API behaves like production for a demo.
/// </summary>
public static class SeedData
{
public static readonly Registration Registration = new(
BigNummer: "19012345601",
Naam: "Dr. A. (Anna) de Vries",
Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries",
Geboortedatum: new DateOnly(1985, 3, 14),
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
/// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres;
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
{
new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
};
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
{
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),
};
}

View File

@@ -0,0 +1,22 @@
namespace BigRegister.Domain.Diplomas;
public enum QuestionType
{
JaNee,
Tekst,
}
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
/// <summary>
/// A diploma as DUO knows it. The profession and the applicable policy questions
/// are NOT stored on the diploma — they are DERIVED by the rules below from its
/// attributes (<see cref="Opleiding"/>, <see cref="Engelstalig"/>).
/// </summary>
public sealed record Diploma(
string Id,
string Naam,
string Instelling,
int Jaar,
string Opleiding,
bool Engelstalig);

View File

@@ -0,0 +1,68 @@
namespace BigRegister.Domain.Diplomas;
/// <summary>
/// SERVER-OWNED business rules for diplomas. This is the single place a policy
/// changes: which profession a study program maps to, and which policy questions
/// (geldigheidsvragen) apply to a diploma. The frontend renders these; it never
/// derives them.
/// </summary>
public static class DiplomaRules
{
// RULE: study program → BIG profession.
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
{
["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts",
};
public static string ProfessionFor(Diploma d) =>
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> ManualProfessions() =>
ProfessionByProgram.Values.Distinct().ToList();
// --- Policy questions (geldigheidsvragen) ---
private static readonly PolicyQuestion NlTaalEngelstalig = new(
"nl-taalvaardigheid",
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalManual = new(
"nl-taalvaardigheid",
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion DiplomaErkend = new(
"diploma-erkend",
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
QuestionType.JaNee);
private static readonly PolicyQuestion Toelichting = new(
"toelichting",
"Geef een korte toelichting op uw diploma en opleiding.",
QuestionType.Tekst);
/// <summary>
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
/// change, no frontend change.
/// </summary>
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
if (d.Engelstalig)
questions.Add(NlTaalEngelstalig);
return questions;
}
/// <summary>
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
/// </summary>
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
new[] { NlTaalManual, DiplomaErkend, Toelichting };
}

View File

@@ -0,0 +1,59 @@
namespace BigRegister.Domain.Documents;
/// <summary>
/// SERVER-OWNED document category config. The frontend renders these; it never
/// hardcodes file types, size limits, labels, required-ness or the post-delivery
/// flag. Adding a new category or changing a rule is a backend-only change.
/// </summary>
public sealed record DocumentCategory(
string CategoryId,
string Label,
string Description,
bool Required,
IReadOnlyList<string> AcceptedTypes,
int MaxSizeMb,
bool Multiple,
bool AllowPostDelivery);
public static class DocumentRules
{
private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
/// <summary>The categories that apply to a given wizard/step.</summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor(string wizardId) => wizardId switch
{
"registratie" => new[]
{
new DocumentCategory("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false),
new DocumentCategory("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true),
},
"herregistratie" => new[]
{
new DocumentCategory("werkervaring", "Bewijs van werkervaring",
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
new DocumentCategory("nascholing", "Nascholingscertificaten",
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
},
_ => Array.Empty<DocumentCategory>(),
};
public static DocumentCategory? Find(string wizardId, string categoryId) =>
CategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable.
/// </summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
{
if (category is null) return "Onbekende documentcategorie.";
if (!category.AcceptedTypes.Contains(contentType))
return $"Bestandstype niet toegestaan voor {category.Label}.";
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
return null;
}
}

View File

@@ -0,0 +1,11 @@
namespace BigRegister.Domain.Intake;
/// <summary>
/// SERVER-OWNED config value. Below this many NL work-hours the scholing question
/// is required. The frontend receives this value and applies it for instant UX
/// feedback, but the backend re-validates on submit as the authority.
/// </summary>
public static class IntakePolicy
{
public const int ScholingThreshold = 1000;
}

View File

@@ -0,0 +1,5 @@
namespace BigRegister.Domain.People;
public sealed record Adres(string Straat, string Postcode, string Woonplaats);
public sealed record Person(string Naam, DateOnly Geboortedatum, Adres Adres);

View File

@@ -0,0 +1,32 @@
namespace BigRegister.Domain.Registrations;
/// <summary>
/// SERVER-OWNED business rule (ported from the frontend reference impl
/// registration.policy.ts:isHerregistratieEligible). A registration may apply for
/// herregistratie only while active and within the window before its deadline.
/// The endpoint ships the result as a decision flag + reason; the frontend renders it.
/// </summary>
public static class HerregistratieRule
{
public const int WindowMonths = 12;
public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths)
{
var deadline = Deadline(reg);
if (deadline is null)
return (false, "Geen actieve registratie.");
var windowStart = deadline.Value.AddMonths(-windowMonths);
return today >= windowStart
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
}
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
}

View File

@@ -0,0 +1,28 @@
namespace BigRegister.Domain.Registrations;
/// <summary>The three states a BIG registration can be in.</summary>
public enum StatusTag
{
Geregistreerd,
Geschorst,
Doorgehaald,
}
/// <summary>
/// Status as a flat record: only <see cref="StatusTag.Geregistreerd"/> carries a
/// herregistratie deadline. The frontend mirrors this as a discriminated union.
/// </summary>
public sealed record RegistrationStatus(
StatusTag Tag,
DateOnly? HerregistratieDatum = null,
DateOnly? GeschorstTot = null,
string? Reden = null,
DateOnly? DoorgehaaldOp = null);
public sealed record Registration(
string BigNummer,
string Naam,
string Beroep,
DateOnly Registratiedatum,
DateOnly Geboortedatum,
RegistrationStatus Status);

View File

@@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
namespace BigRegister.Domain.Submissions;
/// <summary>
/// SERVER-OWNED submit rules (ported from the frontend submit-*.ts commands, where
/// they were hardcoded). Each method returns a rejection reason, or null when the
/// submission is accepted. The reference is generated server-side on acceptance.
/// </summary>
public static class SubmissionRules
{
// RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null;
// RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
private static readonly Regex PostcodePattern =
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// RULE: a change request needs a street and a well-formed Dutch postcode. The
// server re-validates format authoritatively (the FE check is UX-only).
public static string? RejectChangeRequest(string straat, string postcode)
{
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
return null;
}
public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
}

View File

@@ -0,0 +1,185 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" }));
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
const string SpaCors = "spa";
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(SpaCors);
// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII.
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" }));
// Versioned prefix: additive changes (new fields) stay on v1 — the generated client
// + FE parse* boundary absorb them; a breaking change introduces /api/v2 alongside.
var api = app.MapGroup("/api/v1");
// --- GET: screen-shaped reads. Decisions are computed here, never on the client. ---
api.MapGet("/dashboard-view", () =>
{
var reg = SeedData.Registration;
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
new HerregistratieDecisionsDto(eligible, reason));
});
api.MapGet("/notes", () =>
SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList());
// BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded
// citizen has one.
api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto()));
api.MapGet("/duo/diplomas", () => new DuoLookupDto(
SeedData.Diplomas.Select(d => d.ToDto()).ToList(),
new ManualDiplomaPolicyDto(
DiplomaRules.ManualProfessions(),
DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList())));
api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold));
// --- POST: submits. The server is the authority; it re-validates and decides. ---
api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren)))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
Submit(ctx, "adreswijziging", SubmissionRules.RejectChangeRequest(req.Straat, req.Postcode)))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
// --- Document upload ---
// Server-owned category config per wizard. The FE renders these; it never hardcodes.
api.MapGet("/uploads/categories", (string wizardId) =>
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId).Select(c => c.ToDto()).ToList()));
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
// and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request) =>
{
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file");
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
if (file is null || categoryId == "" || localId == "" || wizardId == "")
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
var category = DocumentRules.Find(wizardId, categoryId);
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.Length, DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
})
.ExcludeFromDescription();
// Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) =>
{
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results);
});
// User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId) =>
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
{
DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem(
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
statusCode: StatusCodes.Status409Conflict),
_ => Results.NotFound(),
})
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
// Admin delete (seam): a real system requires an admin role; here an X-Admin header
// stands in. Bypasses ownership, unlinks, and flags the submission for review.
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) =>
!IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden)
: DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.Run();
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store).
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: "none";
if (reject is not null)
{
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
}
if (documents is not null)
{
// Link digital documents (blocks later user delete) and record post-delivery
// intent so a caseworker knows to expect the physical document.
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
}
var reference = SubmissionRules.NewReference();
app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow);
return Results.Ok(new ReferentieResponse(reference));
}
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
public partial class Program { }

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

860
backend/swagger.json Normal file
View File

@@ -0,0 +1,860 @@
{
"openapi": "3.0.4",
"info": {
"title": "BIG-register BFF",
"version": "v1"
},
"paths": {
"/health": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/health/ready": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v1/dashboard-view": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardViewDto"
}
}
}
}
}
}
},
"/api/v1/notes": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AantekeningDto"
}
}
}
}
}
}
}
},
"/api/v1/brp/address": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BrpAddressDto"
}
}
}
}
}
}
},
"/api/v1/duo/diplomas": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DuoLookupDto"
}
}
}
}
}
}
},
"/api/v1/intake/policy": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IntakePolicyDto"
}
}
}
}
}
}
},
"/api/v1/registrations": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegistratieRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/herregistraties": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HerregistratieRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/intakes": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IntakeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/change-requests": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChangeRequestRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/uploads/categories": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"parameters": [
{
"name": "wizardId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadCategoriesDto"
}
}
}
}
}
}
},
"/api/v1/uploads/status": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"parameters": [
{
"name": "localIds",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadStatusDto"
}
}
}
}
}
}
},
"/api/v1/uploads/{documentId}": {
"delete": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"parameters": [
{
"name": "documentId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"409": {
"description": "Conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/v1/admin/uploads/{documentId}": {
"delete": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"parameters": [
{
"name": "documentId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
}
},
"components": {
"schemas": {
"AantekeningDto": {
"type": "object",
"properties": {
"type": {
"type": "string",
"nullable": true
},
"omschrijving": {
"type": "string",
"nullable": true
},
"datum": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"AdresDto": {
"type": "object",
"properties": {
"straat": {
"type": "string",
"nullable": true
},
"postcode": {
"type": "string",
"nullable": true
},
"woonplaats": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"BrpAddressDto": {
"type": "object",
"properties": {
"gevonden": {
"type": "boolean"
},
"adres": {
"$ref": "#/components/schemas/AdresDto"
}
},
"additionalProperties": false
},
"ChangeRequestRequest": {
"type": "object",
"properties": {
"straat": {
"type": "string",
"nullable": true
},
"postcode": {
"type": "string",
"nullable": true
},
"woonplaats": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"DashboardViewDto": {
"type": "object",
"properties": {
"registration": {
"$ref": "#/components/schemas/RegistrationDto"
},
"person": {
"$ref": "#/components/schemas/PersonDto"
},
"decisions": {
"$ref": "#/components/schemas/HerregistratieDecisionsDto"
}
},
"additionalProperties": false
},
"DocumentCategoryDto": {
"type": "object",
"properties": {
"categoryId": {
"type": "string",
"nullable": true
},
"label": {
"type": "string",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
},
"required": {
"type": "boolean"
},
"acceptedTypes": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"maxSizeMb": {
"type": "integer",
"format": "int32"
},
"multiple": {
"type": "boolean"
},
"allowPostDelivery": {
"type": "boolean"
}
},
"additionalProperties": false
},
"DocumentRefDto": {
"type": "object",
"properties": {
"categoryId": {
"type": "string",
"nullable": true
},
"channel": {
"type": "string",
"nullable": true
},
"documentId": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"DuoDiplomaDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"nullable": true
},
"naam": {
"type": "string",
"nullable": true
},
"instelling": {
"type": "string",
"nullable": true
},
"jaar": {
"type": "integer",
"format": "int32"
},
"beroep": {
"type": "string",
"nullable": true
},
"policyQuestions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PolicyQuestionDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"DuoLookupDto": {
"type": "object",
"properties": {
"diplomas": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DuoDiplomaDto"
},
"nullable": true
},
"handmatig": {
"$ref": "#/components/schemas/ManualDiplomaPolicyDto"
}
},
"additionalProperties": false
},
"HerregistratieDecisionsDto": {
"type": "object",
"properties": {
"eligibleForHerregistratie": {
"type": "boolean"
},
"herregistratieReason": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"HerregistratieRequest": {
"type": "object",
"properties": {
"uren": {
"type": "integer",
"format": "int32"
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"IntakePolicyDto": {
"type": "object",
"properties": {
"scholingThreshold": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"IntakeRequest": {
"type": "object",
"properties": {
"uren": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"ManualDiplomaPolicyDto": {
"type": "object",
"properties": {
"beroepen": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"policyQuestions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PolicyQuestionDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"PersonDto": {
"type": "object",
"properties": {
"naam": {
"type": "string",
"nullable": true
},
"geboortedatum": {
"type": "string",
"nullable": true
},
"adres": {
"$ref": "#/components/schemas/AdresDto"
}
},
"additionalProperties": false
},
"PolicyQuestionDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"nullable": true
},
"vraag": {
"type": "string",
"nullable": true
},
"type": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"ProblemDetails": {
"type": "object",
"properties": {
"type": {
"type": "string",
"nullable": true
},
"title": {
"type": "string",
"nullable": true
},
"status": {
"type": "integer",
"format": "int32",
"nullable": true
},
"detail": {
"type": "string",
"nullable": true
},
"instance": {
"type": "string",
"nullable": true
}
},
"additionalProperties": { }
},
"ReferentieResponse": {
"type": "object",
"properties": {
"referentie": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"RegistratieRequest": {
"type": "object",
"properties": {
"diplomaHerkomst": {
"type": "string",
"nullable": true
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"RegistrationDto": {
"type": "object",
"properties": {
"bigNummer": {
"type": "string",
"nullable": true
},
"naam": {
"type": "string",
"nullable": true
},
"beroep": {
"type": "string",
"nullable": true
},
"registratiedatum": {
"type": "string",
"nullable": true
},
"geboortedatum": {
"type": "string",
"nullable": true
},
"status": {
"$ref": "#/components/schemas/RegistrationStatusDto"
}
},
"additionalProperties": false
},
"RegistrationStatusDto": {
"type": "object",
"properties": {
"tag": {
"type": "string",
"nullable": true
},
"herregistratieDatum": {
"type": "string",
"nullable": true
},
"geschorstTot": {
"type": "string",
"nullable": true
},
"reden": {
"type": "string",
"nullable": true
},
"doorgehaaldOp": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"UploadCategoriesDto": {
"type": "object",
"properties": {
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentCategoryDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"UploadStatusDto": {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UploadStatusItemDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"UploadStatusItemDto": {
"type": "object",
"properties": {
"localId": {
"type": "string",
"nullable": true
},
"status": {
"type": "string",
"nullable": true
},
"documentId": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
}
}
},
"tags": [
{
"name": "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
}
]
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,212 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task DashboardView_computes_eligibility_decision()
{
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
Assert.NotNull(dto);
Assert.Equal("19012345601", dto.Registration.BigNummer);
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
Assert.True(dto.Decisions.EligibleForHerregistratie);
Assert.NotNull(dto.Decisions.HerregistratieReason);
}
[Fact]
public async Task Notes_returns_seeded_aantekeningen()
{
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
Assert.Equal(3, notes!.Count);
}
[Fact]
public async Task Brp_returns_address()
{
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
Assert.True(dto!.Gevonden);
Assert.Equal("2514 EA", dto.Adres!.Postcode);
}
[Fact]
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
{
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
Assert.NotNull(dto);
var english = dto.Diplomas.Single(d => d.Id == "d2");
Assert.Equal("Arts", english.Beroep);
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
Assert.Empty(dutch.PolicyQuestions);
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
// which the same lookup provides (maximal question set + declarable professions).
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
}
[Fact]
public async Task IntakePolicy_returns_scholing_threshold()
{
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
Assert.Equal(1000, dto!.ScholingThreshold);
}
[Fact]
public async Task Registration_with_duo_diploma_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie);
}
[Fact]
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Change_request_with_valid_address_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie);
}
[Fact]
public async Task Change_request_with_bad_postcode_is_rejected()
{
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Fact]
public async Task Health_endpoint_is_ok()
{
var res = await _client.GetAsync("/health");
res.EnsureSuccessStatusCode();
}
// --- Document upload ---
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
{
var content = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
content.Add(file, "file", fileName);
content.Add(new StringContent(categoryId), "categoryId");
content.Add(new StringContent(localId), "localId");
content.Add(new StringContent(wizardId), "wizardId");
return content;
}
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
{
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
}
[Fact]
public async Task Categories_are_server_owned_config()
{
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie");
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
}
[Fact]
public async Task Upload_then_status_reports_complete_for_known_localId()
{
var localId = Guid.NewGuid().ToString();
var doc = await Upload(localId);
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
}
[Fact]
public async Task Upload_rejects_wrong_type()
{
var res = await _client.PostAsync("/api/v1/uploads",
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
[Fact]
public async Task User_delete_succeeds_then_404()
{
var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
[Fact]
public async Task User_delete_blocked_with_409_once_linked_to_submission()
{
var doc = await Upload(Guid.NewGuid().ToString());
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
[Fact]
public async Task Admin_delete_requires_admin_role()
{
var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
req.Headers.Add("X-Admin", "true");
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
}
[Fact]
public async Task Audit_log_records_upload_and_delete_metadata_only()
{
var doc = await Upload(Guid.NewGuid().ToString());
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
}
}

View File

@@ -0,0 +1,153 @@
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
namespace BigRegister.Tests;
public class DocumentRuleTests
{
[Fact]
public void Rejects_unknown_category() =>
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
[Fact]
public void Rejects_disallowed_type()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
}
[Fact]
public void Rejects_oversized_file()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
}
[Fact]
public void Accepts_valid_file()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
}
}
public class HerregistratieRuleTests
{
private static Registration Active(DateOnly deadline) => new(
"19012345601", "Test", "Arts",
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
[Fact]
public void Eligible_within_window()
{
var (eligible, reason) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
Assert.True(eligible);
Assert.Contains("12 maanden", reason);
}
[Fact]
public void Not_eligible_before_window()
{
var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
Assert.False(eligible);
}
[Fact]
public void Eligible_on_window_boundary()
{
// window opens exactly 12 months before the deadline
var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
Assert.True(eligible);
}
[Fact]
public void Suspended_is_not_eligible()
{
var reg = Active(new DateOnly(2027, 3, 1)) with
{
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
};
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
Assert.False(eligible);
}
[Fact]
public void Status_consistency_invariant()
{
Assert.True(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
Assert.False(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd)));
}
}
public class DiplomaRuleTests
{
private static Diploma Diploma(string opleiding, bool engelstalig) =>
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
[Theory]
[InlineData("geneeskunde", "Arts")]
[InlineData("verpleegkunde", "Verpleegkundige")]
[InlineData("onbekend-programma", "Onbekend")]
public void Profession_is_derived_from_program(string opleiding, string expected) =>
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
[Fact]
public void English_diploma_requires_dutch_proficiency()
{
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
Assert.Single(questions);
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
}
[Fact]
public void Dutch_diploma_has_no_policy_questions() =>
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
[Fact]
public void Manual_diploma_gets_maximal_set()
{
var questions = DiplomaRules.ManualQuestions();
Assert.Equal(3, questions.Count);
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
questions.Select(q => q.Id));
}
[Fact]
public void Manual_professions_match_known_programs() =>
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
DiplomaRules.ManualProfessions());
}
public class SubmissionRuleTests
{
[Fact]
public void Manual_diploma_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
[Fact]
public void Duo_diploma_is_accepted() =>
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
[Fact]
public void Zero_hours_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
[Fact]
public void Worked_hours_are_accepted() =>
Assert.Null(SubmissionRules.RejectZeroUren(40));
[Theory]
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
public void Change_request_is_validated(string straat, string postcode, string? expected) =>
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
}

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
# ponytail: dev-server images (not multi-stage prod builds) — this is a demo.
# `docker compose up` → app at http://localhost:4200, Swagger at http://localhost:5000/swagger
services:
api:
image: mcr.microsoft.com/dotnet/sdk:10.0
working_dir: /src
command: dotnet run --project src/BigRegister.Api --urls http://+:5000
environment:
- ASPNETCORE_ENVIRONMENT=Development
volumes:
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
- ./backend:/src:z
- api-bin:/src/src/BigRegister.Api/bin
- api-obj:/src/src/BigRegister.Api/obj
ports:
- "5000:5000"
web:
image: node:24
working_dir: /app
# Uses the committed generated client (no codegen at startup); proxies /api → api container.
command: sh -c "npm ci && npx ng serve --host 0.0.0.0 --proxy-config proxy.conf.docker.json"
volumes:
- ./:/app:z
- web-modules:/app/node_modules
ports:
- "4200:4200"
depends_on:
- api
volumes:
api-bin:
api-obj:
web-modules:

View File

@@ -415,9 +415,14 @@ update as you type.
## 6. Connecting to a .NET backend
Today the adapters read static JSON (`mock/*.json`). Because `infrastructure/` is the only
> **Implemented.** No longer hypothetical: a minimal ASP.NET Core backend now hosts
> the business rules and serves the endpoints; the FE consumes it through an
> NSwag-generated typed client. See `backend/README.md`. The text below remains as
> the rationale for _why_ only `infrastructure/` + `contracts/` had to change.
The adapters used to read static JSON (`mock/*.json`). Because `infrastructure/` is the only
layer that touches the network — the **anti-corruption boundary** — pointing the app at a
real ASP.NET API touches _only these files_. Domain, application and UI don't change.
real ASP.NET API touched _only these files_. Domain, application and UI don't change.
The one concrete change per adapter: a **DTO** type matching the .NET response, a
`toDomain` mapper, and a real URL.

84
eslint.config.mjs Normal file
View File

@@ -0,0 +1,84 @@
import tseslint from 'typescript-eslint';
/**
* Enforces the architecture's working agreements that were previously only
* documented (CLAUDE.md): no `any`, domain/ stays framework-free, and the
* dependency direction between contexts (herregistratie → registratie → shared,
* auth → shared; shared depends on nothing). Boundary rules use path patterns on
* the import aliases, so they read as the direction statement they enforce.
*/
export default [
{
ignores: [
'dist/**',
'node_modules/**',
'storybook-static/**',
'.angular/**',
'coverage/**',
'backend/**',
// Generated client — owns its own /* eslint-disable */ header.
'src/app/shared/infrastructure/api-client.ts',
],
},
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tseslint.parser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-explicit-any': 'error' },
},
// Tests legitimately use `any` to feed invalid messages/states into reducers.
{
files: ['src/**/*.spec.ts'],
rules: { '@typescript-eslint/no-explicit-any': 'off' },
},
// domain/ = pure business rules + types. No Angular, ever.
{
files: ['src/app/**/domain/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['@angular/*', '@angular/**'], message: 'domain/ must stay framework-free (pure TS) — no Angular imports.' }] },
],
},
},
// shared/ is the base layer: it may not depend on any feature context.
// The dev-only debug panel is the sanctioned exception (it observes every store).
{
files: ['src/app/shared/**/*.ts'],
ignores: ['src/app/shared/ui/debug-state/**'],
rules: {
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*'], message: 'shared/ must not depend on a feature context.' }] },
],
},
},
// auth/ may depend only on shared.
{
files: ['src/app/auth/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['@registratie/*', '@herregistratie/*'], message: 'auth/ may depend only on shared.' }] },
],
},
},
// registratie/ may depend on shared, not on herregistratie (direction points the other way).
{
files: ['src/app/registratie/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['@herregistratie/*'], message: 'Dependencies point herregistratie → registratie → shared, never back.' }] },
],
},
},
];

29
nswag.json Normal file
View File

@@ -0,0 +1,29 @@
{
"runtime": "Net80",
"documentGenerator": {
"fromDocument": {
"url": "backend/swagger.json"
}
},
"codeGenerators": {
"openApiToTypeScriptClient": {
"className": "ApiClient",
"moduleName": "",
"namespace": "",
"template": "Fetch",
"promiseType": "Promise",
"httpClass": "HttpClient",
"useGetBaseUrlMethod": false,
"baseUrlTokenName": "API_BASE_URL",
"generateClientClasses": true,
"generateOptionalParameters": true,
"typeScriptVersion": 5.0,
"dateTimeType": "String",
"nullValue": "Undefined",
"generateDtoTypes": true,
"typeStyle": "Interface",
"markOptionalProperties": true,
"output": "src/app/shared/infrastructure/api-client.ts"
}
}
}

952
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,15 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"lint": "eslint .",
"start": "ng serve --proxy-config proxy.conf.json",
"gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"storybook": "ng run atomic-design-poc:storybook",
"build-storybook": "ng run atomic-design-poc:build-storybook",
"check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi"
"check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts' --exclude='debug-state.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi"
},
"private": true,
"packageManager": "npm@11.12.1",
@@ -26,23 +28,27 @@
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-devkit/architect": "^0.2200.0",
"@angular-devkit/build-angular": "^22.0.0",
"@angular-devkit/core": "^22.0.0",
"@angular/build": "^22.0.4",
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0",
"jsdom": "^29.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8",
"storybook": "^10.4.6",
"@storybook/angular": "^10.4.6",
"@angular/localize": "^22.0.4",
"@angular/platform-browser-dynamic": "^22.0.0",
"@compodoc/compodoc": "^1.2.1",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6",
"@angular-devkit/build-angular": "^22.0.0",
"@angular-devkit/architect": "^0.2200.0",
"@angular-devkit/core": "^22.0.0",
"@angular/platform-browser-dynamic": "^22.0.0",
"@compodoc/compodoc": "^1.2.1"
"@storybook/angular": "^10.4.6",
"eslint": "^10.6.0",
"jsdom": "^29.0.0",
"nswag": "^14.7.1",
"prettier": "^3.8.1",
"storybook": "^10.4.6",
"typescript": "~6.0.2",
"typescript-eslint": "^8.62.0",
"vitest": "^4.0.8"
},
"comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.",
"overrides": {
@@ -52,6 +58,9 @@
"ajv": "^8.20.0",
"webpack-dev-server": "^5.2.5",
"sockjs": "^0.3.24",
"uuid": "^11.1.1"
"uuid": "^11.1.1",
"eslint": {
"ajv": "^6.12.6"
}
}
}

7
proxy.conf.docker.json Normal file
View File

@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://api:5000",
"secure": false,
"changeOrigin": true
}
}

7
proxy.conf.json Normal file
View File

@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true
}
}

View File

@@ -1,8 +0,0 @@
{
"gevonden": true,
"adres": {
"straat": "Lange Voorhout 9",
"postcode": "2514 EA",
"woonplaats": "Den Haag"
}
}

View File

@@ -1,23 +0,0 @@
{
"registration": {
"bigNummer": "19012345601",
"naam": "Dr. A. (Anna) de Vries",
"beroep": "Arts",
"registratiedatum": "2012-09-01",
"geboortedatum": "1985-03-14",
"status": { "tag": "Geregistreerd", "herregistratieDatum": "2027-03-01" }
},
"person": {
"naam": "Dr. A. (Anna) de Vries",
"geboortedatum": "1985-03-14",
"adres": {
"straat": "Lange Voorhout 9",
"postcode": "2514 EA",
"woonplaats": "Den Haag"
}
},
"decisions": {
"eligibleForHerregistratie": true,
"herregistratieReason": "Registratie verloopt binnen 12 maanden (2027-03-01)."
}
}

View File

@@ -1,38 +0,0 @@
{
"diplomas": [
{
"id": "d1",
"naam": "Geneeskunde",
"instelling": "Universiteit Leiden",
"jaar": 2011,
"beroep": "Arts",
"policyQuestions": []
},
{
"id": "d2",
"naam": "Medicine (MBChB)",
"instelling": "University of Edinburgh",
"jaar": 2013,
"beroep": "Arts",
"policyQuestions": [
{ "id": "nl-taalvaardigheid", "vraag": "Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" }
]
},
{
"id": "d3",
"naam": "HBO-Verpleegkunde",
"instelling": "Hogeschool Utrecht",
"jaar": 2016,
"beroep": "Verpleegkundige",
"policyQuestions": []
}
],
"handmatig": {
"beroepen": ["Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts"],
"policyQuestions": [
{ "id": "nl-taalvaardigheid", "vraag": "Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" },
{ "id": "diploma-erkend", "vraag": "Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?", "type": "ja-nee" },
{ "id": "toelichting", "vraag": "Geef een korte toelichting op uw diploma en opleiding.", "type": "tekst" }
]
}
}

View File

@@ -1 +0,0 @@
{ "scholingThreshold": 1000 }

View File

@@ -1,5 +0,0 @@
[
{ "type": "Specialisme", "omschrijving": "Huisartsgeneeskunde", "datum": "2016-04-12" },
{ "type": "Aantekening", "omschrijving": "Erkend opleider huisartsgeneeskunde", "datum": "2019-01-08" },
{ "type": "Specialisme", "omschrijving": "Spoedeisende hulp (kaderopleiding)", "datum": "2021-06-30" }
]

View File

@@ -1,4 +1,4 @@
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
import { ApplicationConfig, LOCALE_ID, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
@@ -6,6 +6,9 @@ import localeNl from '@angular/common/locales/nl';
import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
registerLocaleData(localeNl);
@@ -13,7 +16,11 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withViewTransitions()),
provideHttpClient(withInterceptors([scenarioInterceptor])),
// Dev-only: the ?scenario= toggle must never reach a production build, where
// a query param could otherwise force errors on the live app.
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor] : [])),
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
{ provide: LOCALE_ID, useValue: 'nl' },
]
};

View File

@@ -5,11 +5,16 @@ import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out). */
/** Restore a persisted session (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one — it is
unused after login; only `naam` is shown in the chrome. */
function restore(): Session | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Session) : null;
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
@@ -35,7 +40,8 @@ export class SessionStore {
constructor() {
effect(() => {
const s = this._session();
if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
// G1: persist only `naam` — never write the BSN (national ID) to storage.
if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
else sessionStorage.removeItem(STORAGE_KEY);
});
}

View File

@@ -9,7 +9,7 @@ export class DigidAdapter {
// Swap for a real OIDC redirect flow when there's a backend.
async authenticate(bsn: string): Promise<Result<string, Session>> {
const t = bsn.trim();
if (!/^\d{9}$/.test(t)) return err('Voer een geldig BSN van 9 cijfers in.');
if (!/^\d{9}$/.test(t)) return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' });
}
}

View File

@@ -9,18 +9,16 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
selector: 'app-login-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
template: `
<form (ngSubmit)="submitted.emit(bsn)">
<app-form-field label="BSN" fieldId="bsn" description="9 cijfers (demo: vul iets in)">
<form (ngSubmit)="submitted.emit(bsn)" class="app-form app-form-panel">
<app-form-field i18n-label="@@login.bsnLabel" label="BSN" fieldId="bsn" required i18n-description="@@login.bsnDescription" description="9 cijfers (demo: vul iets in)">
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
</app-form-field>
<app-form-field label="Wachtwoord" fieldId="pw">
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
</app-form-field>
<div style="margin-top:1rem">
<app-button type="submit" variant="primary">Inloggen met DigiD</app-button>
</div>
<app-button type="submit" variant="primary" i18n="@@login.submit">Inloggen met DigiD</app-button>
</form>
`,
})

View File

@@ -9,8 +9,8 @@ import { SessionStore } from '@auth/application/session.store';
selector: 'app-login-page',
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
template: `
<app-page-shell heading="Inloggen" width="narrow"
intro="Log in op uw persoonlijke BIG-register omgeving.">
<app-page-shell i18n-heading="@@login.heading" heading="Inloggen" width="narrow"
i18n-intro="@@login.intro" intro="Log in op uw persoonlijke BIG-register omgeving.">
@if (error()) { <app-alert type="error">{{ error() }}</app-alert> }
<app-login-form (submitted)="login($event)" />
</app-page-shell>

View File

@@ -1,14 +1,15 @@
import { Result, ok, err } from '@shared/kernel/fp';
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';
/**
* The mutation/command: send a herregistratie application to the backend.
* Returns a Result so the caller can branch on success/failure without
* try/catch. ponytail: faked with a timer; swap for a real POST when there's
* an API. The "uren must be > 0" rule lets the demo show a failure path.
* 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 async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
await new Promise((r) => setTimeout(r, 800));
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
return ok(undefined);
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

@@ -0,0 +1,26 @@
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,14 +1,15 @@
import { Result, ok, err } from '@shared/kernel/fp';
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: send the intake questionnaire to the backend. Returns a Result so the
* caller branches on success/failure without try/catch. ponytail: faked with a
* timer; swap for a real POST when there's an API. The "uren must be > 0" rule
* lets the demo show the failure path.
* 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 async function submitIntake(data: ValidIntake): Promise<Result<string, void>> {
await new Promise((r) => setTimeout(r, 800));
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
return ok(undefined);
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

@@ -1,12 +0,0 @@
/**
* WIRE CONTRACT for intake policy values owned by the backend.
*
* This is the "config value" shape of moving policy off the client: instead of
* hardcoding the scholing threshold in the frontend, the backend ships the value
* and the UI applies it for instant feedback. The backend remains the AUTHORITY
* — it re-validates on submit. See docs/architecture/0001-bff-lite-decision-dtos.md.
*/
export interface IntakePolicyDto {
/** Below this many NL-hours the scholing question is required. */
scholingThreshold: number;
}

View File

@@ -1,9 +1,11 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine';
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {} });
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {} });
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 3, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
describe('wizard.machine', () => {
it('next advances only when step 1 parses', () => {
@@ -12,11 +14,18 @@ describe('wizard.machine', () => {
expect((next(editing1('4160')) as any).step).toBe(2);
});
it('submit reaches Submitting ONLY with fully valid data', () => {
expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
const good = submit(editing2('4160', '200'));
it('next advances step 2 → 3 only when punten parses', () => {
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy();
expect((next(editing2('4160', '200')) as any).step).toBe(3);
});
it('submit reaches Submitting ONLY from step 3 with fully valid data', () => {
expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten
const good = submit(editing3('4160', '200'));
expect(good.tag).toBe('Submitting');
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 });
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('next requires BOTH step-1 fields (uren and jaren)', () => {
@@ -25,13 +34,15 @@ describe('wizard.machine', () => {
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
});
it('back / resolve are no-ops from illegal states', () => {
it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => {
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
expect((back(editing3('1', '2')) as any).step).toBe(2);
expect((back(editing2('1', '2')) as any).step).toBe(1);
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
});
it('resolve maps Submitting to Submitted / Failed', () => {
const submitting = submit(editing2('4160', '200'));
const submitting = submit(editing3('4160', '200'));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
@@ -45,18 +56,32 @@ describe('reduce (message-driven)', () => {
s = reduce(s, { tag: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(2);
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(3);
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
it('blocks submit until required documents are satisfied', () => {
const cat = { categoryId: 'bewijs', label: 'Bewijs', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true };
let s = reduce(editing3('4160', '200'), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as any).errors.documenten).toBeTruthy();
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' } });
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]);
});
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
let s = reduce(reduce(editing2('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
expect(s.tag).toBe('Failed');
s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Submitting');
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 });
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('Seed mounts an arbitrary state', () => {

View File

@@ -1,5 +1,13 @@
import { Result, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
import {
UploadState,
UploadMsg,
initialUpload,
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
@@ -8,11 +16,14 @@ export interface Draft {
punten: string;
}
export type StepErrors = Partial<Record<keyof Draft | 'documenten', string>>;
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
export interface Valid {
uren: Uren;
jaren: number;
punten: number;
documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;
}
/**
@@ -22,51 +33,68 @@ export interface Valid {
* errors set" are unrepresentable — the bug class is gone by construction.
*/
export type WizardState =
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial<Record<keyof Draft, string>> }
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} };
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload };
/** Parse every field; on success hand back a Valid, else the per-field errors. */
function validate(draft: Draft): Result<Partial<Record<keyof Draft, string>>, Valid> {
function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid> {
const uren = parseUren(draft.uren);
const jaren = parseUren(draft.jaren);
const punten = parseUren(draft.punten);
const errors: Partial<Record<keyof Draft, string>> = {};
const errors: StepErrors = {};
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
if (!punten.ok) errors.punten = punten.error;
if (uren.ok && jaren.ok && punten.ok) {
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value } };
if (!requiredCategoriesSatisfied(upload)) {
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
}
if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value, documents: deliveryRefs(upload) } };
}
return { ok: false, error: errors };
}
/** Step 1 → 2: advance only when BOTH step-1 fields parse. Illegal elsewhere = no-op. */
/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */
export function next(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 1) return s;
const uren = parseUren(s.draft.uren);
const jaren = parseUren(s.draft.jaren);
const errors: Partial<Record<keyof Draft, string>> = {};
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
if (s.tag !== 'Editing') return s;
const errors: StepErrors = {};
if (s.step === 1) {
const uren = parseUren(s.draft.uren);
const jaren = parseUren(s.draft.jaren);
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
}
if (s.step === 2) {
const punten = parseUren(s.draft.punten);
if (!punten.ok) errors.punten = punten.error;
return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };
}
return s;
}
export function back(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
return { ...s, step: 1, errors: {} };
if (s.tag !== 'Editing' || s.step === 1) return s;
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
}
/** Step 2 submit: parse everything; move to Submitting only with Valid data. */
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
export function submit(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
const result = validate(s.draft);
if (s.tag !== 'Editing' || s.step !== 3) return s;
const result = validate(s.draft, s.upload);
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
}
/** Route an upload sub-message through the pure upload reducer (Editing only). */
export function upload(s: WizardState, msg: UploadMsg): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, upload: reduceUpload(s.upload, msg) };
}
/** Resolve the async submit. Only meaningful while Submitting. */
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s;
@@ -92,6 +120,7 @@ export type WizardMsg =
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Upload'; msg: UploadMsg }
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
export function reduce(s: WizardState, m: WizardMsg): WizardState {
@@ -110,6 +139,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Upload':
return upload(s, m.msg);
case 'Seed':
return m.state;
default:

View File

@@ -73,9 +73,9 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
const errors: Errors = {};
switch (step) {
case 'buitenland':
if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.';
if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`;
else if (a.buitenlandGewerkt === 'ja') {
if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.';
if (!a.land || a.land.trim() === '') errors.land = $localize`:@@validation.land:Vul een land in.`;
const u = parseUren(a.buitenlandseUren ?? '');
if (!u.ok) errors.buitenlandseUren = u.error;
}
@@ -83,7 +83,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
case 'werk': {
const u = parseUren(a.uren ?? '');
if (!u.ok) errors.uren = u.error;
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.';
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// Nascholingspunten are only asked (and required) when scholing was followed.
if (a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? '');

View File

@@ -0,0 +1,16 @@
import { Injectable, inject, resource } from '@angular/core';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
* Infrastructure adapter for the intake policy (the scholing threshold config
* value). Same shape as every other adapter — a signal `resource` over the
* generated typed client — so HTTP lives in exactly one place per concern.
*/
@Injectable({ providedIn: 'root' })
export class IntakePolicyAdapter {
private client = inject(ApiClient);
policyResource() {
return resource({ loader: () => this.client.policy() });
}
}

View File

@@ -2,13 +2,17 @@ import { Component, computed, inject, input } 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 { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
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 { 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';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -18,55 +22,62 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
the dashboard shows "in behandeling" immediately. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent, DocumentUploadComponent],
template: `
@switch (state().tag) {
@case ('Editing') {
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ step() }} van 2</p>
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
@if (step() === 1) {
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
</app-form-field>
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()">
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
</app-form-field>
<div style="display:flex;gap:0.5rem">
<app-button type="submit" variant="primary">Volgende</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
} @else {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
</app-form-field>
<div style="display:flex;gap:0.5rem">
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
<app-wizard-shell
[steps]="stepLabels"
[current]="step() - 1"
[stepTitle]="stepTitle()"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()">
@switch (step()) {
@case (1) {
<app-form-field i18n-label="@@herregWizard.urenLabel" label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren" [invalid]="!!errUren()" i18n-placeholder="@@herregWizard.urenPlaceholder" placeholder="bijv. 4160" />
</app-form-field>
<app-form-field i18n-label="@@herregWizard.jarenLabel" label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()">
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren" [invalid]="!!errJaren()" i18n-placeholder="@@herregWizard.jarenPlaceholder" placeholder="bijv. 5" />
</app-form-field>
}
@case (2) {
<app-form-field i18n-label="@@herregWizard.puntenLabel" label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten" [invalid]="!!errPunten()" i18n-placeholder="@@herregWizard.puntenPlaceholder" placeholder="bijv. 200" />
</app-form-field>
}
@case (3) {
<app-document-upload
[state]="upload()"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" />
@if (errDocumenten()) {
<app-alert type="warning">{{ errDocumenten() }}</app-alert>
}
</form>
}
}
@case ('Submitting') {
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
}
@case ('Submitted') {
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
}
@case ('Failed') {
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div>
}
}
<div wizardSuccess>
<app-alert type="ok" i18n="@@herregWizard.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
</div>
</app-wizard-shell>
`,
})
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. */
@@ -75,13 +86,44 @@ export class HerregistratieWizardComponent {
readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch;
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
// 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`];
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected uploadCtl = createUploadController({
wizardId: 'herregistratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
protected primaryLabel = computed(() => (this.step() < 3 ? $localize`:@@wizard.volgende:Volgende` : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`));
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
case 'Editing': return 'editing';
case 'Submitting': return 'submitting';
case 'Submitted': return 'submitted';
case 'Failed': return 'failed';
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.editing()?.errors ?? {};
return (Object.keys(e) as (keyof typeof e)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
@@ -90,7 +132,7 @@ export class HerregistratieWizardComponent {
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting();
}
@@ -110,7 +152,7 @@ export class HerregistratieWizardComponent {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitHerregistratie(s.data);
const r = await submitHerregistratie(this.apiClient, s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();

View File

@@ -3,9 +3,10 @@ import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200 };
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
const meta: Meta<HerregistratieWizardComponent> = {
title: 'Herregistratie/Wizard',
@@ -18,11 +19,12 @@ export default meta;
type Story = StoryObj<HerregistratieWizardComponent>;
// Each story seeds one state of the machine — one render per union variant.
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} } } };
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload } } };
export const Step1Error: Story = {
args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' } } satisfies WizardState },
args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' }, upload: initialUpload } satisfies WizardState },
};
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {} } } };
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {}, upload: initialUpload } } };
export const Step3: Story = { args: { seed: { tag: 'Editing', step: 3, draft: { uren: '4160', jaren: '5', punten: '200' }, errors: {}, upload: initialUpload } } };
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };

View File

@@ -13,18 +13,18 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
template: `
<app-page-shell heading="Herregistratie aanvragen" backLink="/dashboard">
<app-page-shell i18n-heading="@@herregistratie.heading" heading="Herregistratie aanvragen" backLink="/dashboard">
<app-async [data]="eligibility()">
<ng-template appAsyncLoaded let-eligible>
@if (eligible) {
<app-alert type="info">
<app-alert type="info" i18n="@@herregistratie.eligible">
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
</app-alert>
<div style="margin-top:1.5rem">
<div class="app-section">
<app-herregistratie-wizard />
</div>
} @else {
<app-alert type="warning">
<app-alert type="warning" i18n="@@herregistratie.notEligible">
Voor uw huidige registratiestatus is herregistratie niet mogelijk.
</app-alert>
}

View File

@@ -1,13 +1,14 @@
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { httpResource } from '@angular/common/http';
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 { RadioGroupComponent, JA_NEE } 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 { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import {
IntakeState,
@@ -20,110 +21,103 @@ import {
lageUren,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';
import { submitIntake } from '@herregistratie/application/submit-intake';
import { ApiClient } from '@shared/infrastructure/api-client';
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.
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
/** 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
answer immediately changes the remaining steps. Answers are persisted to
localStorage so a page reload keeps the user's progress. */
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
@Component({
selector: 'app-intake-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent],
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, WizardShellComponent],
template: `
@switch (state().tag) {
@case ('Answering') {
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps.length }}</p>
<form (ngSubmit)="onPrimary()" style="max-width:30rem">
@switch (step()) {
@case ('buitenland') {
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenlandGewerkt')">
<app-radio-group name="buitenland" [options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
</app-form-field>
@if (answers().buitenlandGewerkt === 'ja') {
<app-form-field label="In welk land?" fieldId="land" [error]="err('land')">
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
</app-form-field>
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" [error]="err('buitenlandseUren')">
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
</app-form-field>
}
}
@case ('werk') {
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
</app-form-field>
@if (scholingZichtbaar()) {
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholingGevolgd')">
<app-radio-group name="scholing" [options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
</app-form-field>
}
@if (answers().scholingGevolgd === 'ja') {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')">
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
</app-form-field>
}
}
@case ('review') {
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row" style="margin:1rem 0">
<div><dt>Buiten NL gewerkt</dt><dd>{{ answers().buitenlandGewerkt ?? '—' }}</dd></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div><dt>Land</dt><dd>{{ answers().land }}</dd></div>
<div><dt>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
}
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</dd></div>
@if (scholingZichtbaar()) {
<div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
}
</dl>
}
<app-wizard-shell
[steps]="stepLabels"
[current]="cursor()"
[stepTitle]="stepTitle()"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()">
@switch (step()) {
@case ('buitenland') {
<app-form-field i18n-label="@@intake.q.buitenland" label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" required [error]="err('buitenlandGewerkt')">
<app-radio-group name="buitenlandGewerkt" [options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
</app-form-field>
@if (answers().buitenlandGewerkt === 'ja') {
<app-form-field i18n-label="@@intake.q.land" label="In welk land?" fieldId="land" required [error]="err('land')">
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" i18n-placeholder="@@intake.q.landPlaceholder" placeholder="bijv. België" />
</app-form-field>
<app-form-field i18n-label="@@intake.q.buitenlandseUren" label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" required [error]="err('buitenlandseUren')">
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder" placeholder="bijv. 800" />
</app-form-field>
}
<div style="display:flex;gap:0.5rem;margin-top:1rem">
@if (cursor() > 0) {
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
}
@case ('werk') {
<app-form-field i18n-label="@@intake.q.urenNl" label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" required [error]="err('uren')">
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" i18n-placeholder="@@intake.q.urenNlPlaceholder" placeholder="bijv. 4160" />
</app-form-field>
@if (scholingZichtbaar()) {
<app-form-field i18n-label="@@intake.q.scholing" label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" required [error]="err('scholingGevolgd')">
<app-radio-group name="scholingGevolgd" [options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
</app-form-field>
}
@if (answers().scholingGevolgd === 'ja') {
<app-form-field i18n-label="@@intake.q.punten" label="Behaalde nascholingspunten" fieldId="punten" required [error]="err('punten')">
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" i18n-placeholder="@@intake.q.puntenPlaceholder" placeholder="bijv. 200" />
</app-form-field>
}
}
@case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row i18n-key="@@intake.review.buitenNl" key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'" />
@if (answers().buitenlandGewerkt === 'ja') {
<app-data-row i18n-key="@@intake.review.land" key="Land" [value]="answers().land ?? ''" />
<app-data-row i18n-key="@@intake.review.buitenlandseUren" key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''" />
}
<app-button type="submit" variant="primary">{{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}</app-button>
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
</div>
</form>
<app-data-row i18n-key="@@intake.review.urenNl" key="Uren NL" [value]="answers().uren ?? ''" />
@if (scholingZichtbaar()) {
<app-data-row i18n-key="@@intake.review.scholing" key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''" />
}
@if (answers().scholingGevolgd === 'ja') {
<app-data-row i18n-key="@@intake.review.punten" key="Nascholingspunten" [value]="answers().punten ?? ''" />
}
</dl>
}
}
@case ('Submitting') {
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
}
@case ('Submitted') {
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="restart()">Opnieuw beginnen</app-button>
<div wizardSuccess>
<app-alert type="ok" i18n="@@intake.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw">Opnieuw beginnen</app-button>
</div>
}
@case ('Failed') {
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div>
}
}
</div>
</app-wizard-shell>
`,
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private apiClient = inject(ApiClient);
private policy = inject(IntakePolicyAdapter);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
// Server-owned policy: the scholing threshold is fetched, not hardcoded. The
// backend stays the authority and re-validates on submit.
private policyRes = httpResource<IntakePolicyDto>(() => 'mock/intake-policy.json', {
defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT },
});
// Server-owned policy: the scholing threshold is fetched from the backend, not
// hardcoded. The backend stays the authority and re-validates on submit.
private policyRes = this.policy.policyResource();
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
@@ -132,7 +126,7 @@ export class IntakeWizardComponent {
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null));
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
@@ -142,35 +136,64 @@ export class IntakeWizardComponent {
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = [$localize`:@@intake.step.buitenland:Buitenland`, $localize`:@@intake.step.werk:Werk`, $localize`:@@intake.step.controle:Controle`];
private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
protected stepTitle = computed(() => this.stepTitles[this.step()]);
protected primaryLabel = computed(() => (this.step() === 'review' ? $localize`:@@intake.indienen:Aanvraag indienen` : $localize`:@@wizard.volgende:Volgende`));
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
case 'Answering': return 'editing';
case 'Submitting': return 'submitting';
case 'Submitted': return 'submitted';
case 'Failed': return 'failed';
}
});
/** Current step's field errors, flattened for the shell's error summary. The
field ids match the answer keys, so the summary anchors jump to the field. */
protected errorList = computed<WizardError[]>(() => {
const e = this.answering()?.errors ?? {};
return (Object.keys(e) as (keyof Answers)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
constructor() {
// An explicit seed (stories) wins; otherwise resume from localStorage.
// An explicit seed (stories) wins; otherwise resume from sessionStorage.
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.
// 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') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
if (s.tag === 'Answering') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else sessionStorage.removeItem(STORAGE_KEY);
});
// 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).
effect(() => {
const scholingThreshold = this.policyRes.value().scholingThreshold;
const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
});
}
private restore(): IntakeState | null {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as IntakeState;
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.
}
@@ -198,7 +221,7 @@ export class IntakeWizardComponent {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitIntake(s.data);
const r = await submitIntake(this.apiClient, s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();

View File

@@ -9,13 +9,13 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w
selector: 'app-intake-page',
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
template: `
<app-page-shell heading="Herregistratie — intake" backLink="/dashboard">
<app-alert type="info">
<app-page-shell i18n-heading="@@intake.heading" heading="Herregistratie — intake" backLink="/dashboard">
<app-alert type="info" i18n="@@intake.intro">
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
de pagina herlaadt.
</app-alert>
<div style="margin-top:1.5rem">
<div class="app-section">
<app-intake-wizard />
</div>
</app-page-shell>

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,20 @@
import { Result } from '@shared/kernel/fp';
import { Valid } from '@registratie/domain/change-request.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
/**
* Command: POST an address change to the backend (`/api/v1/change-requests`),
* which re-validates and returns a reference. Same shape as the other submit
* commands — a `Result`, never a thrown error — so the form's reduce can branch.
*/
export function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
return runSubmit(async () => {
const res = await client.changeRequests({
straat: data.straat,
postcode: data.postcode,
woonplaats: data.woonplaats,
});
return res.referentie ?? '';
}, SUBMIT_FAILED);
}

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,17 @@
import { Result, ok, err } from '@shared/kernel/fp';
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: 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 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 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.');
}
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
return ok(referentie);
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,45 @@
import { describe, it, expect } from 'vitest';
import { State, reduce, initial } from './change-request.machine';
const editingWith = (draft: Partial<{ straat: string; postcode: string; woonplaats: string }>): State => ({
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '', ...draft },
errors: {},
});
describe('change-request reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
expect(s.tag).toBe('Editing');
expect((s as Extract<State, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
});
it('Submit with an invalid draft stays Editing and reports field errors', () => {
const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
const errors = (s as Extract<State, { tag: 'Editing' }>).errors;
expect(errors.straat).toBeTruthy();
expect(errors.postcode).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' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
});
it('confirms and fails only from Submitting; Retry re-submits a failure', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' });
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', 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' });
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});

View File

@@ -0,0 +1,82 @@
import { Result, assertNever } from '@shared/kernel/fp';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
straat: string;
postcode: string;
woonplaats: string;
}
/** After parsing — postcode is the branded type, so downstream can't get a raw one. */
export interface Valid {
straat: string;
postcode: Postcode;
woonplaats: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/**
* The change-request (adreswijziging) 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.
*/
export type State =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid; referentie: string }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: State = {
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '' },
errors: {},
};
/** Parse via the value objects; 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 };
}
export type Msg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)
export function reduce(s: State, m: Msg): State {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import {
Draft,
RegistratieState,
@@ -25,6 +26,7 @@ const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
draft: { antwoorden: {}, ...draft },
cursor,
errors: {},
upload: initialUpload,
});
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
@@ -204,3 +206,31 @@ describe('reduce (message-driven happy path)', () => {
expect((s as any).data.beroep).toBe('Arts');
});
});
describe('inline document upload (beroep step)', () => {
const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true };
it('routes Upload messages through the upload reducer', () => {
const s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
expect((s as any).upload.categories).toHaveLength(1);
});
it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
s = reduce(s, { tag: 'Next' }); // beroep → controle blocked
expect(currentStep(s as any)).toBe('beroep');
expect((s as any).errors.documenten).toBeTruthy();
// choosing post delivery satisfies the requirement
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('controle');
});
it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
const done = submit(s as any);
expect(done.tag).toBe('Indienen');
expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]);
});
});

View File

@@ -1,6 +1,15 @@
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
import {
UploadState,
UploadMsg,
DeliveryChannel,
initialUpload,
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
/**
* A FIXED 3-step registration wizard. The steps never change in number (always
@@ -49,6 +58,7 @@ export interface ValidRegistratie {
diplomaHerkomst: DiplomaHerkomst;
beroep: string;
antwoorden: Record<string, string>;
documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>;
}
/** Text fields settable via SetField. */
@@ -63,17 +73,18 @@ export interface Errors {
email?: string;
correspondentie?: string;
diploma?: string;
documenten?: string;
antwoorden?: Record<string, string>;
}
export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
| { tag: 'Indienen'; data: ValidRegistratie }
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
const emptyDraft: Draft = { antwoorden: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {}, upload: initialUpload };
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
@@ -81,15 +92,15 @@ export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>):
}
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, d: Draft): Result<Errors, void> {
function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Errors, void> {
const errors: Errors = {};
switch (step) {
case 'adres': {
if (!d.straat || d.straat.trim() === '') errors.straat = 'Vul een straat en huisnummer in.';
if (!d.straat || d.straat.trim() === '') errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
const pc = parsePostcode(d.postcode ?? '');
if (!pc.ok) errors.postcode = pc.error;
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.';
if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.';
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
if (!d.correspondentie) errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// E-mail is only required when 'email' is the chosen channel.
if (d.correspondentie === 'email') {
const e = parseEmail(d.email ?? '');
@@ -100,7 +111,7 @@ function validateStep(step: StepId, d: Draft): Result<Errors, void> {
case 'beroep': {
// A diploma must be chosen (or declared manually); its beroep is then known.
if (!d.diplomaId || !d.beroep) {
errors.diploma = 'Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.';
errors.diploma = $localize`:@@validation.diploma:Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.`;
break;
}
// Every policy question the chosen diploma raised must be answered. Which
@@ -108,9 +119,13 @@ function validateStep(step: StepId, d: Draft): Result<Errors, void> {
// they're answered.
const open: Record<string, string> = {};
for (const id of d.vraagIds ?? []) {
if (!(d.antwoorden[id] ?? '').trim()) open[id] = 'Beantwoord deze vraag.';
if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
}
if (Object.keys(open).length > 0) errors.antwoorden = open;
// Required documents for this wizard attach to the beroep step (inline upload).
if (!requiredCategoriesSatisfied(upload)) {
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
}
break;
}
case 'controle':
@@ -122,10 +137,10 @@ function validateStep(step: StepId, d: Draft): Result<Errors, void> {
}
/** Parse the whole wizard into a ValidRegistratie (called on submit). */
function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
function validateAll(d: Draft, upload: UploadState): Result<Errors, ValidRegistratie> {
const errors: Errors = {};
for (const step of STEPS) {
const r = validateStep(step, d);
const r = validateStep(step, d, upload);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
@@ -147,6 +162,7 @@ function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
beroep: d.beroep,
antwoorden,
documents: deliveryRefs(upload),
});
}
@@ -197,7 +213,7 @@ export function setAntwoord(s: RegistratieState, vraagId: string, value: string)
export function next(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateStep(currentStep(s), s.draft);
const r = validateStep(currentStep(s), s.draft, s.upload);
if (!r.ok) return { ...s, errors: r.error };
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
}
@@ -216,10 +232,16 @@ export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieStat
export function submit(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateAll(s.draft);
const r = validateAll(s.draft, s.upload);
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
}
/** Route an upload sub-message through the pure upload reducer (Invullen only). */
export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, upload: reduceUpload(s.upload, msg) };
}
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
if (s.tag !== 'Indienen') return s;
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };
@@ -240,6 +262,7 @@ export type RegistratieMsg =
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Upload'; msg: UploadMsg }
| { tag: 'Seed'; state: RegistratieState };
export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {
@@ -272,6 +295,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
case 'Upload':
return upload(s, m.msg);
case 'Seed':
return m.state;
default:

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { tasksFromProfile } from './tasks';
import { Registration } from './registration';
const base: Registration = {
bigNummer: '12345678901',
naam: 'A. Tester',
beroep: 'arts',
registratiedatum: '2018-01-01',
geboortedatum: '1980-01-01',
status: { tag: 'Geregistreerd', herregistratieDatum: '2026-12-31' },
};
describe('tasksFromProfile', () => {
it('offers herregistratie when the server says eligible, with the formatted deadline', () => {
const tasks = tasksFromProfile(base, true);
expect(tasks).toHaveLength(1);
expect(tasks[0].to).toBe('/herregistratie');
expect(tasks[0].description).toContain('31 december 2026');
});
it('offers nothing when the server says not eligible', () => {
expect(tasksFromProfile(base, false)).toHaveLength(0);
});
it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } };
const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('geschorst');
expect(tasks[0].description).toBe('Onderzoek');
});
it('surfaces a notice for a struck-off registration', () => {
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } };
const tasks = tasksFromProfile(reg, false);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('doorgehaald');
});
});

View File

@@ -0,0 +1,59 @@
import { Registration } from './registration';
import { herregistratieDeadline } from './registration.policy';
/**
* What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data
* derived from the registration — no Angular. Mirrors the shared TaskItem shape.
*/
export interface PortalTask {
title: string;
description: string;
to: string;
actionLabel: string;
}
function formatNL(d: Date): string {
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
}
/**
* Derive the open tasks for a professional (pure). Eligibility is the server's
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
* it does not recompute the rule (ADR-0001). The deadline is still formatted
* client-side for the task copy (presentation, not a rule).
*/
export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] {
const tasks: PortalTask[] = [];
if (eligibleForHerregistratie) {
const deadline = herregistratieDeadline(reg);
tasks.push({
title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`,
description: deadline
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatNL(deadline)}:deadline:.`
: $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`,
to: '/herregistratie',
actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`,
});
}
if (reg.status.tag === 'Geschorst') {
tasks.push({
title: $localize`:@@task.geschorst.title:Uw registratie is geschorst`,
description: reg.status.reden,
to: '/registratie',
actionLabel: $localize`:@@task.bekijkGegevens.action:Bekijk uw gegevens`,
});
}
if (reg.status.tag === 'Doorgehaald') {
tasks.push({
title: $localize`:@@task.doorgehaald.title:Uw registratie is doorgehaald`,
description: reg.status.reden,
to: '/registratie',
actionLabel: $localize`:@@task.bekijkGegevens.action:Bekijk uw gegevens`,
});
}
return tasks;
}

View File

@@ -5,5 +5,5 @@ export type BigNummer = Brand<string, 'BigNummer'>;
export function parseBigNummer(raw: string): Result<string, BigNummer> {
const t = raw.trim();
return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err('Een BIG-nummer bestaat uit 11 cijfers.');
return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`);
}

View File

@@ -13,7 +13,7 @@ export function parseEmail(raw: string): Result<string, Email> {
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
// for instant feedback; the server re-validates.
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
return err('Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.');
return err($localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`);
}
return ok(t as Email);
}

View File

@@ -10,7 +10,7 @@ export type Postcode = Brand<string, 'Postcode'>;
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
return err('Voer een geldige postcode in, bijv. 1234 AB.');
return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);
}
// Normalise to "1234 AB" — the parser also cleans up.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);

View File

@@ -8,7 +8,7 @@ export function parseUren(raw: string): Result<string, Uren> {
const n = Number(t);
// Number('') is 0 — guard the empty string explicitly.
if (t === '' || !Number.isInteger(n) || n < 0) {
return err('Vul een geheel aantal in (0 of meer).');
return err($localize`:@@validation.uren:Vul een geheel aantal in (0 of meer).`);
}
return ok(n as Uren);
}

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,28 @@
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.
//
// SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here —
// e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the
// adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls
// (the submit-* commands) must NEVER auto-retry — and don't. Manual retry
// (resource.reload via <app-async>) covers the UX today, so backoff stays unbuilt.
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

@@ -19,20 +19,25 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
@Component({
selector: 'app-address-fields',
imports: [FormsModule, FormFieldComponent, TextInputComponent],
styles: [`
fieldset{border:0;margin:0;padding:0;min-inline-size:0}
legend{padding:0;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-md)}
app-form-field + app-form-field{display:block;margin-block-start:var(--rhc-space-max-lg)}
`],
template: `
<fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset">
<legend class="utrecht-form-fieldset__legend utrecht-form-fieldset__legend--html-legend">{{ legend() }}</legend>
<app-form-field label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" [error]="errors().straat">
<app-form-field i18n-label="@@address.straat" label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" required [error]="errors().straat">
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat"
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
name="straat" [ngModelOptions]="{ standalone: true }" />
</app-form-field>
<app-form-field label="Postcode" [fieldId]="idPrefix() + '-postcode'" [error]="errors().postcode">
<app-form-field i18n-label="@@address.postcode" label="Postcode" [fieldId]="idPrefix() + '-postcode'" required [error]="errors().postcode">
<app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode"
[ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
name="postcode" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" />
name="postcode" i18n-placeholder="@@address.postcodePlaceholder" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" />
</app-form-field>
<app-form-field label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" [error]="errors().woonplaats">
<app-form-field i18n-label="@@address.woonplaats" label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" required [error]="errors().woonplaats">
<app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats"
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
name="woonplaats" [ngModelOptions]="{ standalone: true }" />
@@ -45,6 +50,6 @@ export class AddressFieldsComponent {
errors = input<AdresErrors>({});
/** Prefix for field ids/labels — keeps them unique if two blocks ever co-exist. */
idPrefix = input('adres');
legend = input('Adres');
legend = input($localize`:@@address.legend:Adres`);
fieldChange = output<{ key: keyof AdresValue; value: string }>();
}

View File

@@ -1,60 +1,95 @@
import { Component, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Component, computed, inject, input } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AddressFieldsComponent, AdresValue } from '@registratie/ui/address-fields/address-fields.component';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
import { submitChangeRequest } from '@registratie/application/submit-change-request';
import { ApiClient } from '@shared/infrastructure/api-client';
/** A submitted change request carries a *parsed* postcode (branded Postcode),
not a raw string — downstream code can't receive an unvalidated one. */
export interface ChangeRequest {
street: string;
zip: Postcode;
city: string;
}
/** Organism: change-request (adreswijziging) form. Reuses the same form-field
molecule + text-input/button atoms as the login form. Field errors come
straight from the parser's Result — no parallel "is it valid" flag to drift. */
/**
* 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.
*/
@Component({
selector: 'app-change-request-form',
imports: [FormsModule, ButtonComponent, HeadingComponent, AddressFieldsComponent],
imports: [ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
template: `
<app-heading [level]="2">Adreswijziging doorgeven</app-heading>
<form (ngSubmit)="onSubmit()" style="max-width:28rem">
<app-address-fields
idPrefix="cr"
[value]="{ straat: street, postcode: zip, woonplaats: city }"
[errors]="{ straat: streetError(), postcode: zipError() }"
(fieldChange)="onAdres($event)" />
<div style="margin-top:1rem">
<app-button type="submit" variant="primary">Wijziging indienen</app-button>
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@changeRequest.success">
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht.
</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })" i18n="@@changeRequest.nieuwe">Nieuwe wijziging doorgeven</app-button>
</div>
</form>
} @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Adreswijziging doorgeven</app-heading>
<form (ngSubmit)="onSubmit()" class="app-form app-form-panel app-section">
<app-address-fields
idPrefix="cr"
[value]="adres()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" />
@if (failedError()) {
<app-alert type="error"><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container> {{ failedError() }}</app-alert>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
</form>
}
`,
})
export class ChangeRequestFormComponent {
street = '';
zip = '';
city = '';
streetError = signal('');
zipError = signal('');
submitted = output<ChangeRequest>();
private apiClient = inject(ApiClient);
private store = createStore<State, Msg>(initial, reduce);
onAdres(e: { key: keyof AdresValue; value: string }) {
if (e.key === 'straat') this.street = e.value;
else if (e.key === 'postcode') this.zip = e.value;
else this.city = e.value;
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<State>(initial);
readonly state = this.store.model;
protected dispatch = this.store.dispatch;
protected readonly submitLabel = $localize`:@@changeRequest.submit:Wijziging indienen`;
protected readonly submitBezigLabel = $localize`:@@changeRequest.submitBezig:Bezig met indienen…`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed<AdresErrors>(() => 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>(() => {
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
});
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
}
onSubmit() {
const street = this.street.trim();
this.streetError.set(street ? '' : 'Vul straat en huisnummer in.');
this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
const postcode = parsePostcode(this.zip);
this.zipError.set(postcode.ok ? '' : postcode.error);
if (!street || !postcode.ok) return;
this.submitted.emit({ street, zip: postcode.value, city: this.city.trim() });
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await submitChangeRequest(this.apiClient, s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/angular';
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';
const validData = { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' };
const meta: Meta<ChangeRequestFormComponent> = {
title: 'Organisms/Change Request Form',
component: ChangeRequestFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
};
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: {} } } };
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.' },
},
},
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };

View File

@@ -1,78 +1,133 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { CardComponent } from '@shared/ui/card/card.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
import { SideNavComponent, NavItem } from '@shared/layout/side-nav/side-nav.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { Registration } from '@registratie/domain/registration';
import { tasksFromProfile } from '@registratie/domain/tasks';
/** Page: "Mijn overzicht" — the portal home, following the NL Design System
"Mijn omgeving" pattern (side nav + "Wat moet ik regelen" + "Mijn zaken"). */
@Component({
selector: 'app-dashboard-page',
imports: [
PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,
DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent,
DataRowComponent, CardComponent, TaskListComponent, SideNavComponent, ...ASYNC,
RegistrationSummaryComponent, RegistrationTableComponent,
],
template: `
<app-page-shell heading="Mijn BIG-registratie">
@if (store.pendingHerregistratie()) {
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
}
<app-page-shell i18n-heading="@@dashboard.heading" heading="Mijn overzicht" i18n-intro="@@dashboard.intro" intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken.">
<div class="app-overview">
<app-side-nav [items]="nav" />
<!-- 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 class="app-stack">
@if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie">Uw herregistratie-aanvraag is in behandeling.</app-alert>
}
<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)" />
</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>
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
@let tasks = tasksFor($any(p).registration);
<section>
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen">Wat moet ik regelen</app-heading>
@if (tasks.length) {
<app-task-list class="app-section" [tasks]="tasks" />
} @else {
<p class="rhc-paragraph app-text-subtle" i18n="@@dashboard.nietsOpenstaan">U heeft op dit moment niets openstaan.</p>
}
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie">Mijn registratie</app-heading>
<div class="app-section">
<app-registration-summary [reg]="$any(p).registration" />
</div>
<app-card class="app-section" i18n-heading="@@dashboard.persoonsgegevens" heading="Persoonsgegevens (BRP)">
<dl class="rhc-data-summary rhc-data-summary--row">
<app-data-row i18n-key="@@dashboard.straat" key="Straat" [value]="$any(p).person.adres.straat" />
<app-data-row i18n-key="@@dashboard.postcode" key="Postcode" [value]="$any(p).person.adres.postcode" />
<app-data-row i18n-key="@@dashboard.woonplaats" key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
</dl>
</app-card>
</section>
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<section>
<app-heading [level]="2" i18n="@@dashboard.specialismen">Specialismen en aantekeningen</app-heading>
<div class="app-section">
<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 app-text-subtle" i18n="@@dashboard.geenSpecialismen">U heeft nog geen specialismen of aantekeningen.</p>
</ng-template>
</app-async>
</div>
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<ul class="app-card-grid app-section">
@for (a of acties; track a.to) {
<li>
<app-card [heading]="a.titel">
<p class="rhc-paragraph">{{ a.tekst }}</p>
<app-link [to]="a.to">{{ a.actie }} →</app-link>
</app-card>
</li>
}
</ul>
</section>
</div>
</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>
</app-page-shell>
`,
})
export class DashboardPage {
protected store = inject(BigProfileStore);
/** Server-computed eligibility (rendered, not recomputed). */
private readonly eligible = computed(() => {
const d = this.store.decisions();
return d.tag === 'Success' && d.value.eligibleForHerregistratie;
});
protected tasksFor(reg: Registration) {
return tasksFromProfile(reg, this.eligible());
}
/** Portal sections (navigation, not actions). */
protected readonly nav: NavItem[] = [
{ label: $localize`:@@dashboard.nav.overzicht:Overzicht`, to: '/dashboard' },
{ label: $localize`:@@dashboard.nav.gegevens:Mijn gegevens`, to: '/registratie' },
{ label: $localize`:@@dashboard.nav.herregistratie:Herregistratie`, to: '/herregistratie' },
{ label: $localize`:@@dashboard.nav.inschrijven:Inschrijven`, to: '/registreren' },
{ label: $localize`:@@dashboard.nav.concepten:Functionele patronen`, to: '/concepts' },
];
/** Primary transactional actions, as a card grid. */
protected readonly acties = [
{ to: '/registreren', titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`, tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`, actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving` },
{ to: '/herregistratie', titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`, tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`, actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan` },
{ to: '/intake', titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`, tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`, actie: $localize`:@@dashboard.actie.intake.actie:Start intake` },
{ to: '/registratie', titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`, tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`, actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens` },
];
}

View File

@@ -1,17 +1,17 @@
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 { RadioGroupComponent, JA_NEE } 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';
import { whenTag } from '@shared/kernel/fp';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
@@ -28,58 +28,74 @@ import {
STEPS,
} from '@registratie/domain/registratie-wizard.machine';
import { submitRegistratie } from '@registratie/application/submit-registratie';
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 } from '@shared/upload/upload.machine';
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' }];
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
const STORAGE_KEY = 'registratie-v2'; // ponytail: bump the suffix if the persisted shape changes; no migration.
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
];
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
draft via an effect; the DUO diploma list renders through <app-async>; choosing
a diploma reveals its server-derived beroep. The draft is persisted to
localStorage so a reload keeps the user's progress. Built entirely from existing
sessionStorage so a reload keeps the user's progress (cleared on tab close). Built from existing
atoms/molecules. */
@Component({
selector: 'app-registratie-wizard',
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent,
AddressFieldsComponent, ...ASYNC,
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
AddressFieldsComponent, DocumentUploadComponent, ...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()"
i18n-submittingLabel="@@regWizard.submitting" 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 {
@switch (adresStatus()) {
@case ('gevonden') {
<app-alert type="info">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
<app-alert type="info" i18n="@@regWizard.brpGevonden">Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan.</app-alert>
}
@case ('geen') {
<app-alert type="warning">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
<app-alert type="warning" i18n="@@regWizard.brpGeen">We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in.</app-alert>
}
@case ('fout') {
<app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
<app-alert type="warning" i18n="@@regWizard.brpFout">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
}
}
<app-address-fields
[value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }"
[errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }"
(fieldChange)="set($event.key, $event.value)" />
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')">
<app-form-field i18n-label="@@regWizard.correspondentieLabel" label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" required [error]="err('correspondentie')">
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
</app-form-field>
@if (draft().correspondentie === 'email') {
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')">
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
<app-form-field i18n-label="@@regWizard.emailLabel" label="E-mailadres" fieldId="email" required [error]="err('email')">
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" i18n-placeholder="@@regWizard.emailPlaceholder" placeholder="naam@voorbeeld.nl" />
</app-form-field>
}
}
@@ -87,20 +103,20 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data>
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" [error]="err('diploma')">
<app-form-field i18n-label="@@regWizard.diplomaLabel" label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" required [error]="err('diploma')">
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
<app-form-field label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing">Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld.</app-alert>
<app-form-field i18n-label="@@regWizard.beroepLabel" label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
</app-form-field>
} @else if (draft().beroep) {
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
<app-data-row i18n-key="@@regWizard.beroepAfgeleid" key="Beroep (afgeleid uit diploma)" [value]="draft().beroep ?? ''" />
</dl>
}
@@ -120,58 +136,54 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[state]="upload()"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" />
@if (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
}
}
@case ('controle') {
<app-alert type="info">Controleer uw gegevens en dien de registratie in.</app-alert>
<app-alert type="info" i18n="@@regWizard.controleer">Controleer uw gegevens en dien de registratie in.</app-alert>
<dl class="rhc-data-summary rhc-data-summary--row app-section">
<app-data-row key="Adres" [value]="adresSamenvatting()" />
<app-data-row key="Herkomst adres" [value]="adresHerkomstLabel()" />
<app-data-row key="Correspondentie" [value]="correspondentieLabel()" />
<app-data-row i18n-key="@@regWizard.summary.adres" key="Adres" [value]="adresSamenvatting()" />
<app-data-row i18n-key="@@regWizard.summary.herkomstAdres" key="Herkomst adres" [value]="adresHerkomstLabel()" />
<app-data-row i18n-key="@@regWizard.summary.correspondentie" key="Correspondentie" [value]="correspondentieLabel()" />
@if (draft().correspondentie === 'email') {
<app-data-row key="E-mailadres" [value]="draft().email ?? ''" />
<app-data-row i18n-key="@@regWizard.summary.email" key="E-mailadres" [value]="draft().email ?? ''" />
}
<app-data-row key="Beroep" [value]="draft().beroep ?? ''" />
<app-data-row key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
<app-data-row i18n-key="@@regWizard.summary.beroep" key="Beroep" [value]="draft().beroep ?? ''" />
<app-data-row i18n-key="@@regWizard.summary.herkomstDiploma" key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
@for (item of samenvattingVragen(); track item.vraag) {
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
}
</dl>
<div class="app-button-row app-section">
<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>
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 0 })" i18n="@@regWizard.adresWijzigen">Adres wijzigen</app-button>
<app-button type="button" variant="subtle" (click)="dispatch({ tag: 'GaNaarStap', cursor: 1 })" i18n="@@regWizard.diplomaWijzigen">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') {
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
<div wizardSuccess>
<app-alert type="ok" i18n="@@regWizard.success">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>
<app-button variant="secondary" (click)="restart()" i18n="@@regWizard.nieuweRegistratie">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();
@@ -181,29 +193,56 @@ export class RegistratieWizardComponent {
seed = input<RegistratieState>(initial);
readonly kanalen = KANALEN;
readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper
private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];
readonly stepLabels = [$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`]; // short labels for the stepper
private stepTitles = [$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`, $localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`, $localize`:@@regWizard.title.controle:Controleren en indienen`];
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));
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
protected uploadCtl = createUploadController({
wizardId: 'registratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
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 : ''));
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
protected primaryLabel = computed(() => (this.step() === 'controle' ? $localize`:@@regWizard.indienen:Registratie indienen` : $localize`:@@wizard.volgende:Volgende`));
protected errorMessage = computed(() => $localize`:@@regWizard.indienenMislukt: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(', ');
});
// Readable labels for the controle summary (instead of raw enum values).
protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig']));
protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));
protected adresHerkomstLabel = computed(() => ({ brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`, handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd` }[this.draft().adresHerkomst ?? 'handmatig']));
protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
@@ -234,7 +273,7 @@ export class RegistratieWizardComponent {
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') => this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
@@ -246,7 +285,7 @@ export class RegistratieWizardComponent {
protected diplomaOptions = (data: DuoLookupDto) => [
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam}${d.instelling} (${d.jaar})` })),
{ value: HANDMATIG, label: 'Mijn diploma staat er niet bij' },
{ value: HANDMATIG, label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij` },
];
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
@@ -276,14 +315,15 @@ export class RegistratieWizardComponent {
}
constructor() {
// An explicit seed (stories) wins; otherwise resume from localStorage.
// An explicit seed (stories) wins; otherwise resume from sessionStorage.
const seeded = this.seed();
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
// Persist only while filling in; clear once the flow is done.
// Persist only while filling in; clear once the flow is done. G1: sessionStorage
// (not localStorage) — the draft holds address/email and must not outlive the tab.
effect(() => {
const s = this.state();
if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
if (s.tag === 'Invullen') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else sessionStorage.removeItem(STORAGE_KEY);
});
// Prefill the address from the BRP lookup as it arrives. Track only the resource
// value; untrack the dispatch (it reads the state signal, which would otherwise
@@ -302,25 +342,16 @@ 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 {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as RegistratieState;
const parsed = JSON.parse(raw) as RegistratieState;
return parsed?.tag === 'Invullen' ? parsed : null; // G2: only resume a known shape.
} catch {
return null; // ponytail: corrupt entry -> start fresh, no migration.
}
@@ -350,7 +381,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 });
}

View File

@@ -3,12 +3,13 @@ import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { RegistratieWizardComponent } from './registratie-wizard.component';
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial<Draft> = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' };
const filled: Partial<Draft> = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] };
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} });
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {}, upload: initialUpload });
const validData: ValidRegistratie = {
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
@@ -18,6 +19,7 @@ const validData: ValidRegistratie = {
diplomaHerkomst: 'duo',
beroep: 'Arts',
antwoorden: {},
documents: [],
};
const meta: Meta<RegistratieWizardComponent> = {

View File

@@ -10,10 +10,10 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r
imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],
template: `
<app-page-shell
i18n-heading="@@registratie.heading"
heading="Inschrijven in het BIG-register"
backLink="/dashboard"
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
<app-alert type="info">
backLink="/dashboard">
<app-alert type="info" i18n="@@registratie.intro">
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
als u de pagina herlaadt.

View File

@@ -1,6 +1,5 @@
import { Component, inject, signal } from '@angular/core';
import { Component, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
@@ -10,11 +9,11 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
@Component({
selector: 'app-registration-detail-page',
imports: [
PageShellComponent, AlertComponent, SkeletonComponent, ...ASYNC,
PageShellComponent, SkeletonComponent, ...ASYNC,
RegistrationSummaryComponent, ChangeRequestFormComponent,
],
template: `
<app-page-shell heading="Mijn gegevens" backLink="/dashboard">
<app-page-shell i18n-heading="@@registratieDetail.heading" heading="Mijn gegevens" backLink="/dashboard">
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(p).registration" />
@@ -24,17 +23,12 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
</ng-template>
</app-async>
<div style="margin-top:2rem">
@if (submitted()) {
<app-alert type="ok">Uw adreswijziging is ontvangen. U ontvangt binnen 5 werkdagen bericht.</app-alert>
} @else {
<app-change-request-form (submitted)="submitted.set(true)" />
}
<div class="app-section">
<app-change-request-form />
</div>
</app-page-shell>
`,
})
export class RegistrationDetailPage {
protected store = inject(BigProfileStore);
submitted = signal(false);
}

View File

@@ -4,37 +4,38 @@ import { Registration } from '@registratie/domain/registration';
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { CardComponent } from '@shared/ui/card/card.component';
/** Organism: registration summary card. Composes data-row molecules + status-badge atom. */
@Component({
selector: 'app-registration-summary',
imports: [DatePipe, DataRowComponent, StatusBadgeComponent],
imports: [DatePipe, DataRowComponent, StatusBadgeComponent, CardComponent],
template: `
<div class="rhc-card rhc-card--default">
<app-card>
<dl class="rhc-data-summary rhc-data-summary--row">
<app-data-row key="BIG-nummer" [value]="reg().bigNummer" />
<app-data-row key="Naam" [value]="reg().naam" />
<app-data-row key="Beroep" [value]="reg().beroep" />
<app-data-row key="Status">
<app-data-row i18n-key="@@summary.bigNummer" key="BIG-nummer" [value]="reg().bigNummer" />
<app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam" />
<app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep" />
<app-data-row i18n-key="@@summary.status" key="Status">
<app-status-badge [label]="label()" [color]="color()" />
</app-data-row>
<app-data-row key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" />
<app-data-row i18n-key="@@summary.registratiedatum" key="Registratiedatum" [value]="reg().registratiedatum | date:'longDate'" />
<!-- Each status variant renders only the row its own data supports. -->
@switch (reg().status.tag) {
@case ('Geregistreerd') {
<app-data-row key="Uiterste herregistratie" [value]="$any(reg().status).herregistratieDatum | date:'longDate'" />
<app-data-row i18n-key="@@summary.uiterste" key="Uiterste herregistratie" [value]="$any(reg().status).herregistratieDatum | date:'longDate'" />
}
@case ('Geschorst') {
<app-data-row key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'" />
<app-data-row key="Reden" [value]="$any(reg().status).reden" />
<app-data-row i18n-key="@@summary.geschorstTot" key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'" />
<app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden" />
}
@case ('Doorgehaald') {
<app-data-row key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'" />
<app-data-row key="Reden" [value]="$any(reg().status).reden" />
<app-data-row i18n-key="@@summary.doorgehaaldOp" key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'" />
<app-data-row i18n-key="@@summary.reden" key="Reden" [value]="$any(reg().status).reden" />
}
}
</dl>
</div>
</app-card>
`,
})
export class RegistrationSummaryComponent {

View File

@@ -11,9 +11,9 @@ import { Aantekening } from '@registratie/domain/registration';
<table class="utrecht-table utrecht-table--html-table utrecht-table--alternate-row-color">
<thead>
<tr>
<th class="utrecht-table__header-cell">Type</th>
<th class="utrecht-table__header-cell">Omschrijving</th>
<th class="utrecht-table__header-cell">Datum</th>
<th class="utrecht-table__header-cell" i18n="@@table.type">Type</th>
<th class="utrecht-table__header-cell" i18n="@@table.omschrijving">Omschrijving</th>
<th class="utrecht-table__header-cell" i18n="@@table.datum">Datum</th>
</tr>
</thead>
<tbody>

View File

@@ -0,0 +1,14 @@
import { InjectionToken, Signal } from '@angular/core';
/**
* A shared seam for the chrome to show "who is logged in" + log out, WITHOUT
* shared/ depending on the auth context (the import-direction rule forbids that).
* Auth provides this token at the app root (see app.config.ts); the shared header
* injects it. SessionStore satisfies this shape structurally.
*/
export interface SessionPort {
readonly session: Signal<{ naam: string } | null>;
logout(): void;
}
export const SESSION_PORT = new InjectionToken<SessionPort>('SESSION_PORT');

View File

@@ -0,0 +1,32 @@
import { ApplicationRef, effect } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { createStore } from './store';
describe('createStore', () => {
it('applies the pure update on dispatch', () => {
const store = createStore(0, (n: number, m: number) => n + m);
store.dispatch(5);
store.dispatch(3);
expect(store.model()).toBe(8);
});
// Regression: an effect that dispatches must NOT re-run because of its own write.
// dispatch used to read `model()` reactively (`set(update(model(), msg))`), so an
// effect calling dispatch subscribed to `model` and looped forever, livelocking the
// main thread (crashed the upload wizards). With `.update` the read is untracked.
it('dispatch from inside an effect does not self-loop', () => {
const store = createStore(0, (n: number, _m: 'inc') => n + 1);
let runs = 0;
TestBed.runInInjectionContext(() => {
effect(() => {
runs++;
if (runs < 100) store.dispatch('inc'); // bounded so the buggy version can't hang the test
});
});
TestBed.inject(ApplicationRef).tick(); // flush effects
expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it
expect(store.model()).toBe(1);
});
});

View File

@@ -24,6 +24,10 @@ export function createStore<Model, Msg>(
const model = signal(init);
return {
model: model.asReadonly(),
dispatch: (msg) => model.set(update(model(), msg)),
// Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`:
// dispatch is a command and must never subscribe its caller to `model`. Reading
// `model()` here inside an effect that also dispatches makes the effect depend on
// its own write and livelock the main thread (crashed the upload wizards).
dispatch: (msg) => model.update((m) => update(m, msg)),
};
}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { runSubmit } from './submit';
describe('runSubmit', () => {
it('folds a resolved call into ok(value)', async () => {
const r = await runSubmit(async () => 'BIG-123', 'fallback');
expect(r).toEqual({ ok: true, value: 'BIG-123' });
});
it('maps a ProblemDetails rejection to err(detail)', async () => {
const r = await runSubmit(async () => {
throw { detail: 'Aanvraag afgewezen.' };
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
});
it('falls back when the rejection has no detail', async () => {
const r = await runSubmit(async () => {
throw new Error('network');
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'fallback' });
});
});

View File

@@ -0,0 +1,20 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { problemDetail } from '@shared/infrastructure/api-error';
/**
* Run a mutating API call and fold it into a `Result` — the one place the
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
* its own payload mapping. The backend re-validates and returns a 422
* ProblemDetails on rejection, surfaced here as the error string.
*/
export async function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
try {
return ok(await fn());
} catch (e) {
return err(problemDetail(e, fallback));
}
}
// Single shared default for a failed submit; the @@id dedupes it at the
// translation layer.
export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`;

View File

@@ -0,0 +1,67 @@
import { Provider } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom, timeout, TimeoutError } from 'rxjs';
import { ApiClient, ProblemDetails } from './api-client';
import { environment } from '../../../environments/environment';
/** Single place every API call passes through: the seam for cross-cutting concerns. */
const REQUEST_TIMEOUT_MS = 10_000;
/**
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
* client expects, so every API call flows through HttpClient interceptors (the
* `?scenario=` toggle) and the cross-cutting concerns below. The generated client
* is the only place HTTP shapes are known; this is the only place it meets
* Angular's HTTP stack — i.e. the one seam to add:
* - timeout (done — REQUEST_TIMEOUT_MS),
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
* - idempotency key for writes (done — Idempotency-Key; a real retry would thread
* a STABLE key per logical submit so re-sends dedupe; here it's per-attempt),
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
* - retry/backoff: wrap the pipe with rxjs `retry({ count, delay })` here.
*/
function httpClientFetch(http: HttpClient) {
return {
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const headers: Record<string, string> = {
...((init?.headers ?? {}) as Record<string, string>),
'X-Correlation-Id': crypto.randomUUID(),
};
if (method !== 'GET') headers['Idempotency-Key'] = crypto.randomUUID();
try {
const res = await firstValueFrom(
http
.request(method, url as string, {
body: init?.body as string | undefined,
headers,
observe: 'response',
responseType: 'text',
})
.pipe(timeout(REQUEST_TIMEOUT_MS)),
);
return new Response(res.body ?? '', { status: res.status || 200 });
} catch (e) {
if (e instanceof TimeoutError) return new Response('', { status: 504 });
const err = e as HttpErrorResponse;
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
// request reports status 0, which `new Response` rejects).
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
return new Response(body, { status });
}
},
};
}
/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the
* environment (relative '' in dev → proxy; configurable per deployment). */
export function provideApiClient(): Provider {
return {
provide: ApiClient,
useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
deps: [HttpClient],
};
}
export type { ProblemDetails };

View File

@@ -0,0 +1,796 @@
//----------------------
// <auto-generated>
// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org)
// </auto-generated>
//----------------------
/* eslint-disable */
// ReSharper disable InconsistentNaming
export class ApiClient {
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
private baseUrl: string;
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
this.http = http ? http : window as any;
this.baseUrl = baseUrl ?? "";
}
/**
* @return OK
*/
health(): Promise<void> {
let url_ = this.baseUrl + "/health";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processHealth(_response);
});
}
protected processHealth(response: Response): Promise<void> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
return;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<void>(null as any);
}
/**
* @return OK
*/
ready(): Promise<void> {
let url_ = this.baseUrl + "/health/ready";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processReady(_response);
});
}
protected processReady(response: Response): Promise<void> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
return;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<void>(null as any);
}
/**
* @return OK
*/
dashboardView(): Promise<DashboardViewDto> {
let url_ = this.baseUrl + "/api/v1/dashboard-view";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processDashboardView(_response);
});
}
protected processDashboardView(response: Response): Promise<DashboardViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DashboardViewDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<DashboardViewDto>(null as any);
}
/**
* @return OK
*/
notes(): Promise<AantekeningDto[]> {
let url_ = this.baseUrl + "/api/v1/notes";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processNotes(_response);
});
}
protected processNotes(response: Response): Promise<AantekeningDto[]> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AantekeningDto[];
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<AantekeningDto[]>(null as any);
}
/**
* @return OK
*/
address(): Promise<BrpAddressDto> {
let url_ = this.baseUrl + "/api/v1/brp/address";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processAddress(_response);
});
}
protected processAddress(response: Response): Promise<BrpAddressDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BrpAddressDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BrpAddressDto>(null as any);
}
/**
* @return OK
*/
diplomas(): Promise<DuoLookupDto> {
let url_ = this.baseUrl + "/api/v1/duo/diplomas";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processDiplomas(_response);
});
}
protected processDiplomas(response: Response): Promise<DuoLookupDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DuoLookupDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<DuoLookupDto>(null as any);
}
/**
* @return OK
*/
policy(): Promise<IntakePolicyDto> {
let url_ = this.baseUrl + "/api/v1/intake/policy";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processPolicy(_response);
});
}
protected processPolicy(response: Response): Promise<IntakePolicyDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as IntakePolicyDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<IntakePolicyDto>(null as any);
}
/**
* @return OK
*/
registrations(body: RegistratieRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/registrations";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processRegistrations(_response);
});
}
protected processRegistrations(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
herregistraties(body: HerregistratieRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/herregistraties";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processHerregistraties(_response);
});
}
protected processHerregistraties(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
intakes(body: IntakeRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/intakes";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processIntakes(_response);
});
}
protected processIntakes(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
changeRequests(body: ChangeRequestRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/v1/change-requests";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processChangeRequests(_response);
});
}
protected processChangeRequests(response: Response): Promise<ReferentieResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
return result200;
});
} else if (status === 422) {
return response.text().then((_responseText) => {
let result422: any = null;
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ReferentieResponse>(null as any);
}
/**
* @return OK
*/
categories(wizardId: string): Promise<UploadCategoriesDto> {
let url_ = this.baseUrl + "/api/v1/uploads/categories?";
if (wizardId === undefined || wizardId === null)
throw new globalThis.Error("The parameter 'wizardId' must be defined and cannot be null.");
else
url_ += "wizardId=" + encodeURIComponent("" + wizardId) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processCategories(_response);
});
}
protected processCategories(response: Response): Promise<UploadCategoriesDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UploadCategoriesDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<UploadCategoriesDto>(null as any);
}
/**
* @param localIds (optional)
* @return OK
*/
status(localIds?: string | undefined): Promise<UploadStatusDto> {
let url_ = this.baseUrl + "/api/v1/uploads/status?";
if (localIds === null)
throw new globalThis.Error("The parameter 'localIds' cannot be null.");
else if (localIds !== undefined)
url_ += "localIds=" + encodeURIComponent("" + localIds) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processStatus(_response);
});
}
protected processStatus(response: Response): Promise<UploadStatusDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UploadStatusDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<UploadStatusDto>(null as any);
}
/**
* @return No Content
*/
uploads(documentId: string): Promise<void> {
let url_ = this.baseUrl + "/api/v1/uploads/{documentId}";
if (documentId === undefined || documentId === null)
throw new globalThis.Error("The parameter 'documentId' must be defined.");
url_ = url_.replace("{documentId}", encodeURIComponent("" + documentId));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "DELETE",
headers: {
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processUploads(_response);
});
}
protected processUploads(response: Response): Promise<void> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 204) {
return response.text().then((_responseText) => {
return;
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status === 409) {
return response.text().then((_responseText) => {
let result409: any = null;
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Conflict", status, _responseText, _headers, result409);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<void>(null as any);
}
/**
* @return No Content
*/
uploads2(documentId: string): Promise<void> {
let url_ = this.baseUrl + "/api/v1/admin/uploads/{documentId}";
if (documentId === undefined || documentId === null)
throw new globalThis.Error("The parameter 'documentId' must be defined.");
url_ = url_.replace("{documentId}", encodeURIComponent("" + documentId));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "DELETE",
headers: {
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processUploads2(_response);
});
}
protected processUploads2(response: Response): Promise<void> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 204) {
return response.text().then((_responseText) => {
return;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
return throwException("Forbidden", status, _responseText, _headers);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<void>(null as any);
}
}
export interface AantekeningDto {
type?: string | undefined;
omschrijving?: string | undefined;
datum?: string | undefined;
}
export interface AdresDto {
straat?: string | undefined;
postcode?: string | undefined;
woonplaats?: string | undefined;
}
export interface BrpAddressDto {
gevonden?: boolean;
adres?: AdresDto;
}
export interface ChangeRequestRequest {
straat?: string | undefined;
postcode?: string | undefined;
woonplaats?: string | undefined;
}
export interface DashboardViewDto {
registration?: RegistrationDto;
person?: PersonDto;
decisions?: HerregistratieDecisionsDto;
}
export interface DocumentCategoryDto {
categoryId?: string | undefined;
label?: string | undefined;
description?: string | undefined;
required?: boolean;
acceptedTypes?: string[] | undefined;
maxSizeMb?: number;
multiple?: boolean;
allowPostDelivery?: boolean;
}
export interface DocumentRefDto {
categoryId?: string | undefined;
channel?: string | undefined;
documentId?: string | undefined;
}
export interface DuoDiplomaDto {
id?: string | undefined;
naam?: string | undefined;
instelling?: string | undefined;
jaar?: number;
beroep?: string | undefined;
policyQuestions?: PolicyQuestionDto[] | undefined;
}
export interface DuoLookupDto {
diplomas?: DuoDiplomaDto[] | undefined;
handmatig?: ManualDiplomaPolicyDto;
}
export interface HerregistratieDecisionsDto {
eligibleForHerregistratie?: boolean;
herregistratieReason?: string | undefined;
}
export interface HerregistratieRequest {
uren?: number;
documents?: DocumentRefDto[] | undefined;
}
export interface IntakePolicyDto {
scholingThreshold?: number;
}
export interface IntakeRequest {
uren?: number;
}
export interface ManualDiplomaPolicyDto {
beroepen?: string[] | undefined;
policyQuestions?: PolicyQuestionDto[] | undefined;
}
export interface PersonDto {
naam?: string | undefined;
geboortedatum?: string | undefined;
adres?: AdresDto;
}
export interface PolicyQuestionDto {
id?: string | undefined;
vraag?: string | undefined;
type?: string | undefined;
}
export interface ProblemDetails {
type?: string | undefined;
title?: string | undefined;
status?: number | undefined;
detail?: string | undefined;
instance?: string | undefined;
[key: string]: any;
}
export interface ReferentieResponse {
referentie?: string | undefined;
}
export interface RegistratieRequest {
diplomaHerkomst?: string | undefined;
documents?: DocumentRefDto[] | undefined;
}
export interface RegistrationDto {
bigNummer?: string | undefined;
naam?: string | undefined;
beroep?: string | undefined;
registratiedatum?: string | undefined;
geboortedatum?: string | undefined;
status?: RegistrationStatusDto;
}
export interface RegistrationStatusDto {
tag?: string | undefined;
herregistratieDatum?: string | undefined;
geschorstTot?: string | undefined;
reden?: string | undefined;
doorgehaaldOp?: string | undefined;
}
export interface UploadCategoriesDto {
categories?: DocumentCategoryDto[] | undefined;
}
export interface UploadStatusDto {
results?: UploadStatusItemDto[] | undefined;
}
export interface UploadStatusItemDto {
localId?: string | undefined;
status?: string | undefined;
documentId?: string | undefined;
}
export class SwaggerException extends Error {
override message: string;
status: number;
response: string;
headers: { [key: string]: any; };
result: any;
constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) {
super();
this.message = message;
this.status = status;
this.response = response;
this.headers = headers;
this.result = result;
}
protected isSwaggerException = true;
static isSwaggerException(obj: any): obj is SwaggerException {
return obj.isSwaggerException === true;
}
}
function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any {
if (result !== null && result !== undefined)
throw result;
else
throw new SwaggerException(message, status, response, headers, null);
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { problemDetail, problemFieldErrors } from './api-error';
describe('problemDetail', () => {
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe('Afgewezen: 0 uren.');
});
it('falls back when there is no detail', () => {
expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
expect(problemDetail(undefined, 'fallback')).toBe('fallback');
});
});
describe('problemFieldErrors (G4 seam)', () => {
it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => {
expect(problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }))
.toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
});
it('returns {} when there is no errors envelope (the current backend shape)', () => {
expect(problemFieldErrors({ detail: 'one banner' })).toEqual({});
expect(problemFieldErrors(new Error('boom'))).toEqual({});
expect(problemFieldErrors(undefined)).toEqual({});
});
});

View File

@@ -0,0 +1,36 @@
import { ProblemDetails } from './api-client';
/**
* Extract a human-readable message from a rejected API call. A 4xx/5xx with a
* ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
* object; anything else falls back to the given message.
*/
export function problemDetail(e: unknown, fallback: string): string {
if (e && typeof e === 'object' && 'detail' in e) {
const detail = (e as ProblemDetails).detail;
if (typeof detail === 'string' && detail) return detail;
}
return fallback;
}
/**
* SEAM (G4): map a server validation envelope to field-level errors.
*
* ASP.NET's ValidationProblemDetails carries `errors: { field: string[] }`. The
* backend today returns only `detail` (one banner message), so this returns `{}`.
* When the backend starts sending `errors`, a machine's `SubmitFailed` handler can
* merge this into its own `errors` map — the field-keyed shape the wizards already
* render — so a rejection shows inline per field, not just as a banner. The
* consumer hook is the only thing left to wire; the contract boundary lives here.
*/
export function problemFieldErrors(e: unknown): Record<string, string> {
if (!e || typeof e !== 'object' || !('errors' in e)) return {};
const errors = (e as { errors?: unknown }).errors;
if (!errors || typeof errors !== 'object') return {};
const out: Record<string, string> = {};
for (const [field, msgs] of Object.entries(errors as Record<string, unknown>)) {
const first = Array.isArray(msgs) ? msgs[0] : msgs;
if (typeof first === 'string') out[field] = first;
}
return out;
}

View File

@@ -4,12 +4,12 @@ import { delay } from 'rxjs/operators';
import { currentScenario } from './scenario';
/**
* Demo-only: rewrites the timing/outcome of mock data requests based on
* Demo-only: rewrites the timing/outcome of API data requests based on
* ?scenario= so loading / empty / error states can be shown on demand.
* Real requests are untouched.
* Non-API requests are untouched.
*/
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('mock/')) return next(req);
if (!req.url.includes('/api/')) return next(req);
switch (currentScenario()) {
case 'slow':
@@ -17,7 +17,8 @@ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
case 'loading':
return next(req).pipe(delay(600_000)); // effectively never resolves
case 'empty':
return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));
// '[]' so the typed client parses it to an empty array (notes → Empty state).
return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
case 'error':
return timer(400).pipe(
switchMap(() => throwError(() =>

View File

@@ -1,6 +1,15 @@
export type Scenario = 'default' | 'slow' | 'loading' | 'empty' | 'error';
export type Scenario =
| 'default'
| 'slow'
| 'loading'
| 'empty'
| 'error'
// upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
// interceptor — these are simulated in upload.adapter.ts instead):
| 'upload-slow'
| 'upload-fail';
const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error'];
const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error', 'upload-slow', 'upload-fail'];
/** Reads ?scenario= from the URL so a demo can force each async state. */
export function currentScenario(): Scenario {

View File

@@ -21,3 +21,13 @@ export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
only through an explicit cast — so a smart constructor is the only minter. */
export type Brand<T, B extends string> = T & { readonly __brand: B };
/** Narrow a tagged union to one variant by its `tag`, or null. The single place
the cast lives — TS can't narrow through a runtime tag argument, so callers get
`whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */
export function whenTag<U extends { tag: string }, K extends U['tag']>(
u: U,
tag: K,
): Extract<U, { tag: K }> | null {
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
}

Some files were not shown because too many files have changed in this diff Show More