Merge feat/dotnet-backend: .NET backend + upload feature (+ tab-crash fix)
This commit is contained in:
48
.github/workflows/ci.yml
vendored
Normal file
48
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
44
CLAUDE.md
44
CLAUDE.md
@@ -8,15 +8,21 @@ update this file.
|
|||||||
|
|
||||||
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
||||||
view their registration, apply for re-registration). Angular 22, standalone,
|
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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # ng serve → http://localhost:4200
|
npm start # ng serve (proxies /api → backend) → http://localhost:4200
|
||||||
npm test # vitest
|
npm test # vitest
|
||||||
|
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
|
||||||
npm run build # ng build (must stay green)
|
npm run build # ng build (must stay green)
|
||||||
npm run storybook # component library by atomic layer
|
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).
|
`.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
|
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
|
||||||
constructor DI (constructor only for `effect()`/template-ref injection).
|
constructor DI (constructor only for `effect()`/template-ref injection).
|
||||||
- Angular-native control flow `@if/@for`; native `httpResource` for fetching;
|
- Angular-native control flow `@if/@for`; fetch via `resource({ loader })` over the
|
||||||
`withViewTransitions()` for page transitions (header/footer have stable
|
generated `ApiClient` inside an `infrastructure/*.adapter.ts` (one place HTTP lives),
|
||||||
`view-transition-name`, excluded from the fade).
|
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:
|
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
||||||
[authGuard]` on protected routes (`app.routes.ts`).
|
[authGuard]` on protected routes (`app.routes.ts`).
|
||||||
- Theming is one import — `src/styles.scss` pulls the RHC palette; no hand-written theme.
|
- 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 toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
|
||||||
(`scenario.interceptor.ts`) to see every async state.
|
on data pages (`scenario.interceptor.ts`) to see every async state.
|
||||||
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
||||||
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
`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)
|
## Adding a feature (recipe)
|
||||||
|
|
||||||
@@ -129,5 +155,5 @@ dispatch messages). Worked example: the intake wizard (`herregistratie/`).
|
|||||||
|
|
||||||
## Out of scope (POC, don't build unprompted)
|
## Out of scope (POC, don't build unprompted)
|
||||||
|
|
||||||
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo,
|
Real auth/DigiD, NgRx, licensed Rijkshuisstijl font/logo,
|
||||||
runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen.
|
runtime DTO validation on every endpoint, multi-tab session sync.
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -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
|
app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where
|
||||||
the UI can never reach an inconsistent state.
|
the UI can never reach an inconsistent state.
|
||||||
|
|
||||||
> Demo / POC — no real data, no real login. Free **Fira Sans** stands in for the
|
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
|
||||||
> licensed Rijksoverheid font and a text wordmark for the logo.
|
> 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
|
## 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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start # app → http://localhost:4200
|
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
|
||||||
npm run storybook # component library, organized by atomic layer
|
# 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**.
|
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
|
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
|
||||||
> appear based on earlier answers and progress survives a page reload, plus a visual
|
> 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)
|
## State management (no impossible states)
|
||||||
|
|
||||||
Data fetching uses Angular's native, signal-based **`httpResource`** (no NgRx,
|
Data fetching uses Angular's native, signal-based **`resource`** over the generated
|
||||||
no extra dependency). `core/registration.service.ts` exposes resources that carry
|
typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
|
||||||
`status()`, `value()`, `error()`, `hasValue()` and `reload()` as signals.
|
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
|
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
|
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`).
|
control flow `@if/@for`).
|
||||||
- Styling: `@rijkshuisstijl-community/{design-tokens,components-css}` (Utrecht + RHC CSS,
|
- Styling: `@rijkshuisstijl-community/{design-tokens,components-css}` (Utrecht + RHC CSS,
|
||||||
pre-themed Rijkshuisstijl) — imported in `src/styles.scss`, no hand-written theme.
|
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
|
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
||||||
Angular 22; the builder runs fine (build verified).
|
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.
|
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
|
||||||
|
|
||||||
### Deliberately out of scope (POC)
|
### 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.)
|
||||||
|
|||||||
27
angular.json
27
angular.json
@@ -30,9 +30,8 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": ["src/styles.scss"],
|
||||||
"src/styles.scss"
|
"polyfills": ["@angular/localize/init"]
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -48,7 +47,13 @@
|
|||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
@@ -79,12 +84,7 @@
|
|||||||
"configDir": ".storybook",
|
"configDir": ".storybook",
|
||||||
"browserTarget": "atomic-design-poc:build",
|
"browserTarget": "atomic-design-poc:build",
|
||||||
"compodoc": true,
|
"compodoc": true,
|
||||||
"compodocArgs": [
|
"compodocArgs": ["-e", "json", "-d", "."],
|
||||||
"-e",
|
|
||||||
"json",
|
|
||||||
"-d",
|
|
||||||
"."
|
|
||||||
],
|
|
||||||
"port": 6006
|
"port": 6006
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -94,12 +94,7 @@
|
|||||||
"configDir": ".storybook",
|
"configDir": ".storybook",
|
||||||
"browserTarget": "atomic-design-poc:build",
|
"browserTarget": "atomic-design-poc:build",
|
||||||
"compodoc": true,
|
"compodoc": true,
|
||||||
"compodocArgs": [
|
"compodocArgs": ["-e", "json", "-d", "."],
|
||||||
"-e",
|
|
||||||
"json",
|
|
||||||
"-d",
|
|
||||||
"."
|
|
||||||
],
|
|
||||||
"outputDir": "storybook-static"
|
"outputDir": "storybook-static"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
8
backend/BigRegister.slnx
Normal file
8
backend/BigRegister.slnx
Normal 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
112
backend/README.md
Normal 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
13
backend/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"swashbuckle.aspnetcore.cli": {
|
||||||
|
"version": "10.2.3",
|
||||||
|
"commands": [
|
||||||
|
"swagger"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/BigRegister.Api/BigRegister.Api.csproj
Normal file
13
backend/src/BigRegister.Api/BigRegister.Api.csproj
Normal 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>
|
||||||
73
backend/src/BigRegister.Api/Contracts/Dtos.cs
Normal file
73
backend/src/BigRegister.Api/Contracts/Dtos.cs
Normal 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);
|
||||||
37
backend/src/BigRegister.Api/Contracts/Mappers.cs
Normal file
37
backend/src/BigRegister.Api/Contracts/Mappers.cs
Normal 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);
|
||||||
|
}
|
||||||
101
backend/src/BigRegister.Api/Data/DocumentStore.cs
Normal file
101
backend/src/BigRegister.Api/Data/DocumentStore.cs
Normal 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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/src/BigRegister.Api/Data/SeedData.cs
Normal file
42
backend/src/BigRegister.Api/Data/SeedData.cs
Normal 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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
22
backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs
Normal file
22
backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs
Normal 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);
|
||||||
68
backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs
Normal file
68
backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs
Normal file
11
backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs
Normal 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;
|
||||||
|
}
|
||||||
5
backend/src/BigRegister.Api/Domain/People/Person.cs
Normal file
5
backend/src/BigRegister.Api/Domain/People/Person.cs
Normal 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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
185
backend/src/BigRegister.Api/Program.cs
Normal file
185
backend/src/BigRegister.Api/Program.cs
Normal 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 { }
|
||||||
15
backend/src/BigRegister.Api/Properties/launchSettings.json
Normal file
15
backend/src/BigRegister.Api/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/BigRegister.Api/appsettings.Development.json
Normal file
8
backend/src/BigRegister.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/BigRegister.Api/appsettings.json
Normal file
9
backend/src/BigRegister.Api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
860
backend/swagger.json
Normal file
860
backend/swagger.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
Normal file
26
backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
Normal 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>
|
||||||
212
backend/tests/BigRegister.Tests/EndpointTests.cs
Normal file
212
backend/tests/BigRegister.Tests/EndpointTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
153
backend/tests/BigRegister.Tests/RuleTests.cs
Normal file
153
backend/tests/BigRegister.Tests/RuleTests.cs
Normal 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
34
docker-compose.yml
Normal 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:
|
||||||
@@ -415,9 +415,14 @@ update as you type.
|
|||||||
|
|
||||||
## 6. Connecting to a .NET backend
|
## 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
|
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
|
The one concrete change per adapter: a **DTO** type matching the .NET response, a
|
||||||
`toDomain` mapper, and a real URL.
|
`toDomain` mapper, and a real URL.
|
||||||
|
|||||||
84
eslint.config.mjs
Normal file
84
eslint.config.mjs
Normal 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
29
nswag.json
Normal 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
952
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -3,13 +3,15 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"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",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"storybook": "ng run atomic-design-poc:storybook",
|
"storybook": "ng run atomic-design-poc:storybook",
|
||||||
"build-storybook": "ng run atomic-design-poc:build-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,
|
"private": true,
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
@@ -26,23 +28,27 @@
|
|||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/build": "^22.0.4",
|
||||||
"@angular/cli": "^22.0.4",
|
"@angular/cli": "^22.0.4",
|
||||||
"@angular/compiler-cli": "^22.0.0",
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
"jsdom": "^29.0.0",
|
"@angular/localize": "^22.0.4",
|
||||||
"prettier": "^3.8.1",
|
"@angular/platform-browser-dynamic": "^22.0.0",
|
||||||
"typescript": "~6.0.2",
|
"@compodoc/compodoc": "^1.2.1",
|
||||||
"vitest": "^4.0.8",
|
|
||||||
"storybook": "^10.4.6",
|
|
||||||
"@storybook/angular": "^10.4.6",
|
|
||||||
"@storybook/addon-a11y": "^10.4.6",
|
"@storybook/addon-a11y": "^10.4.6",
|
||||||
"@storybook/addon-docs": "^10.4.6",
|
"@storybook/addon-docs": "^10.4.6",
|
||||||
"@storybook/addon-onboarding": "^10.4.6",
|
"@storybook/addon-onboarding": "^10.4.6",
|
||||||
"@angular-devkit/build-angular": "^22.0.0",
|
"@storybook/angular": "^10.4.6",
|
||||||
"@angular-devkit/architect": "^0.2200.0",
|
"eslint": "^10.6.0",
|
||||||
"@angular-devkit/core": "^22.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"@angular/platform-browser-dynamic": "^22.0.0",
|
"nswag": "^14.7.1",
|
||||||
"@compodoc/compodoc": "^1.2.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.",
|
"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": {
|
"overrides": {
|
||||||
@@ -52,6 +58,9 @@
|
|||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"webpack-dev-server": "^5.2.5",
|
"webpack-dev-server": "^5.2.5",
|
||||||
"sockjs": "^0.3.24",
|
"sockjs": "^0.3.24",
|
||||||
"uuid": "^11.1.1"
|
"uuid": "^11.1.1",
|
||||||
|
"eslint": {
|
||||||
|
"ajv": "^6.12.6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
proxy.conf.docker.json
Normal file
7
proxy.conf.docker.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://api:5000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
7
proxy.conf.json
Normal file
7
proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:5000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"gevonden": true,
|
|
||||||
"adres": {
|
|
||||||
"straat": "Lange Voorhout 9",
|
|
||||||
"postcode": "2514 EA",
|
|
||||||
"woonplaats": "Den Haag"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{ "scholingThreshold": 1000 }
|
|
||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
@@ -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 { provideRouter, withViewTransitions } from '@angular/router';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { registerLocaleData } from '@angular/common';
|
import { registerLocaleData } from '@angular/common';
|
||||||
@@ -6,6 +6,9 @@ import localeNl from '@angular/common/locales/nl';
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
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);
|
registerLocaleData(localeNl);
|
||||||
|
|
||||||
@@ -13,7 +16,11 @@ export const appConfig: ApplicationConfig = {
|
|||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes, withViewTransitions()),
|
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' },
|
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import { DigidAdapter } from '../infrastructure/digid.adapter';
|
|||||||
|
|
||||||
const STORAGE_KEY = 'session-v1';
|
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 {
|
function restore(): Session | null {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -35,7 +40,8 @@ export class SessionStore {
|
|||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const s = this._session();
|
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);
|
else sessionStorage.removeItem(STORAGE_KEY);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class DigidAdapter {
|
|||||||
// Swap for a real OIDC redirect flow when there's a backend.
|
// Swap for a real OIDC redirect flow when there's a backend.
|
||||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||||
const t = bsn.trim();
|
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' });
|
return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,16 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
|||||||
selector: 'app-login-form',
|
selector: 'app-login-form',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
<form (ngSubmit)="submitted.emit(bsn)">
|
<form (ngSubmit)="submitted.emit(bsn)" class="app-form app-form-panel">
|
||||||
<app-form-field label="BSN" fieldId="bsn" description="9 cijfers (demo: vul iets in)">
|
<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-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
|
||||||
</app-form-field>
|
</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-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
|
||||||
<div style="margin-top:1rem">
|
<app-button type="submit" variant="primary" i18n="@@login.submit">Inloggen met DigiD</app-button>
|
||||||
<app-button type="submit" variant="primary">Inloggen met DigiD</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { SessionStore } from '@auth/application/session.store';
|
|||||||
selector: 'app-login-page',
|
selector: 'app-login-page',
|
||||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell heading="Inloggen" width="narrow"
|
<app-page-shell i18n-heading="@@login.heading" heading="Inloggen" width="narrow"
|
||||||
intro="Log in op uw persoonlijke BIG-register omgeving.">
|
i18n-intro="@@login.intro" intro="Log in op uw persoonlijke BIG-register omgeving.">
|
||||||
@if (error()) { <app-alert type="error">{{ error() }}</app-alert> }
|
@if (error()) { <app-alert type="error">{{ error() }}</app-alert> }
|
||||||
<app-login-form (submitted)="login($event)" />
|
<app-login-form (submitted)="login($event)" />
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
|
|||||||
@@ -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 { 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.
|
* Command: POST a herregistratie application to the backend (`/api/herregistraties`).
|
||||||
* Returns a Result so the caller can branch on success/failure without
|
* The "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced
|
||||||
* try/catch. ponytail: faked with a timer; swap for a real POST when there's
|
* as the error by `runSubmit`.
|
||||||
* an API. The "uren must be > 0" rule lets the demo show a failure path.
|
|
||||||
*/
|
*/
|
||||||
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
|
export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
return runSubmit<void>(async () => {
|
||||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
await client.herregistraties({ uren: data.uren, documents: data.documents });
|
||||||
return ok(undefined);
|
}, SUBMIT_FAILED);
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/app/herregistratie/application/submit-intake.spec.ts
Normal file
26
src/app/herregistratie/application/submit-intake.spec.ts
Normal 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') });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { 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
|
* Command: POST the intake questionnaire to the backend (`/api/intakes`). The
|
||||||
* caller branches on success/failure without try/catch. ponytail: faked with a
|
* "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced as
|
||||||
* timer; swap for a real POST when there's an API. The "uren must be > 0" rule
|
* the error by `runSubmit`.
|
||||||
* lets the demo show the failure path.
|
|
||||||
*/
|
*/
|
||||||
export async function submitIntake(data: ValidIntake): Promise<Result<string, void>> {
|
export function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
return runSubmit<void>(async () => {
|
||||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
await client.intakes({ uren: data.uren });
|
||||||
return ok(undefined);
|
}, SUBMIT_FAILED);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ok, err } from '@shared/kernel/fp';
|
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';
|
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 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: {} });
|
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', () => {
|
describe('wizard.machine', () => {
|
||||||
it('next advances only when step 1 parses', () => {
|
it('next advances only when step 1 parses', () => {
|
||||||
@@ -12,11 +14,18 @@ describe('wizard.machine', () => {
|
|||||||
expect((next(editing1('4160')) as any).step).toBe(2);
|
expect((next(editing1('4160')) as any).step).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submit reaches Submitting ONLY with fully valid data', () => {
|
it('next advances step 2 → 3 only when punten parses', () => {
|
||||||
expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
|
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
|
||||||
const good = submit(editing2('4160', '200'));
|
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.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)', () => {
|
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
|
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(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
|
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolve maps Submitting to Submitted / Failed', () => {
|
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, ok(undefined)).tag).toBe('Submitted');
|
||||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||||
});
|
});
|
||||||
@@ -45,18 +56,32 @@ describe('reduce (message-driven)', () => {
|
|||||||
s = reduce(s, { tag: 'Next' });
|
s = reduce(s, { tag: 'Next' });
|
||||||
expect(s.tag === 'Editing' && s.step).toBe(2);
|
expect(s.tag === 'Editing' && s.step).toBe(2);
|
||||||
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
|
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' });
|
s = reduce(s, { tag: 'Submit' });
|
||||||
expect(s.tag).toBe('Submitting');
|
expect(s.tag).toBe('Submitting');
|
||||||
s = reduce(s, { tag: 'SubmitConfirmed' });
|
s = reduce(s, { tag: 'SubmitConfirmed' });
|
||||||
expect(s.tag).toBe('Submitted');
|
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', () => {
|
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');
|
expect(s.tag).toBe('Failed');
|
||||||
s = reduce(s, { tag: 'Retry' });
|
s = reduce(s, { tag: 'Retry' });
|
||||||
expect(s.tag).toBe('Submitting');
|
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', () => {
|
it('Seed mounts an arbitrary state', () => {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Result, assertNever } from '@shared/kernel/fp';
|
import { Result, assertNever } from '@shared/kernel/fp';
|
||||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
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). */
|
/** What the user is typing (raw, possibly invalid). */
|
||||||
export interface Draft {
|
export interface Draft {
|
||||||
@@ -8,11 +16,14 @@ export interface Draft {
|
|||||||
punten: string;
|
punten: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StepErrors = Partial<Record<keyof Draft | 'documenten', string>>;
|
||||||
|
|
||||||
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
|
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
|
||||||
export interface Valid {
|
export interface Valid {
|
||||||
uren: Uren;
|
uren: Uren;
|
||||||
jaren: number;
|
jaren: number;
|
||||||
punten: 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.
|
* errors set" are unrepresentable — the bug class is gone by construction.
|
||||||
*/
|
*/
|
||||||
export type WizardState =
|
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: 'Submitting'; data: Valid }
|
||||||
| { tag: 'Submitted'; data: Valid }
|
| { tag: 'Submitted'; data: Valid }
|
||||||
| { tag: 'Failed'; data: Valid; error: string };
|
| { 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. */
|
/** 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 uren = parseUren(draft.uren);
|
||||||
const jaren = parseUren(draft.jaren);
|
const jaren = parseUren(draft.jaren);
|
||||||
const punten = parseUren(draft.punten);
|
const punten = parseUren(draft.punten);
|
||||||
const errors: Partial<Record<keyof Draft, string>> = {};
|
const errors: StepErrors = {};
|
||||||
if (!uren.ok) errors.uren = uren.error;
|
if (!uren.ok) errors.uren = uren.error;
|
||||||
if (!jaren.ok) errors.jaren = jaren.error;
|
if (!jaren.ok) errors.jaren = jaren.error;
|
||||||
if (!punten.ok) errors.punten = punten.error;
|
if (!punten.ok) errors.punten = punten.error;
|
||||||
if (uren.ok && jaren.ok && punten.ok) {
|
if (!requiredCategoriesSatisfied(upload)) {
|
||||||
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value } };
|
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 };
|
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 {
|
export function next(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing' || s.step !== 1) return s;
|
if (s.tag !== 'Editing') return s;
|
||||||
const uren = parseUren(s.draft.uren);
|
const errors: StepErrors = {};
|
||||||
const jaren = parseUren(s.draft.jaren);
|
if (s.step === 1) {
|
||||||
const errors: Partial<Record<keyof Draft, string>> = {};
|
const uren = parseUren(s.draft.uren);
|
||||||
if (!uren.ok) errors.uren = uren.error;
|
const jaren = parseUren(s.draft.jaren);
|
||||||
if (!jaren.ok) errors.jaren = jaren.error;
|
if (!uren.ok) errors.uren = uren.error;
|
||||||
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
|
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 {
|
export function back(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing' || s.step !== 2) return s;
|
if (s.tag !== 'Editing' || s.step === 1) return s;
|
||||||
return { ...s, step: 1, errors: {} };
|
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 {
|
export function submit(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing' || s.step !== 2) return s;
|
if (s.tag !== 'Editing' || s.step !== 3) return s;
|
||||||
const result = validate(s.draft);
|
const result = validate(s.draft, s.upload);
|
||||||
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
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. */
|
/** Resolve the async submit. Only meaningful while Submitting. */
|
||||||
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
|
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
|
||||||
if (s.tag !== 'Submitting') return s;
|
if (s.tag !== 'Submitting') return s;
|
||||||
@@ -92,6 +120,7 @@ export type WizardMsg =
|
|||||||
| { tag: 'Retry' }
|
| { tag: 'Retry' }
|
||||||
| { tag: 'SubmitConfirmed' }
|
| { tag: 'SubmitConfirmed' }
|
||||||
| { tag: 'SubmitFailed'; error: string }
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
|
| { tag: 'Upload'; msg: UploadMsg }
|
||||||
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
|
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
|
||||||
|
|
||||||
export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
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;
|
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||||
case 'SubmitFailed':
|
case 'SubmitFailed':
|
||||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||||
|
case 'Upload':
|
||||||
|
return upload(s, m.msg);
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
|||||||
const errors: Errors = {};
|
const errors: Errors = {};
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 'buitenland':
|
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') {
|
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 ?? '');
|
const u = parseUren(a.buitenlandseUren ?? '');
|
||||||
if (!u.ok) errors.buitenlandseUren = u.error;
|
if (!u.ok) errors.buitenlandseUren = u.error;
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
|||||||
case 'werk': {
|
case 'werk': {
|
||||||
const u = parseUren(a.uren ?? '');
|
const u = parseUren(a.uren ?? '');
|
||||||
if (!u.ok) errors.uren = u.error;
|
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.
|
// Nascholingspunten are only asked (and required) when scholing was followed.
|
||||||
if (a.scholingGevolgd === 'ja') {
|
if (a.scholingGevolgd === 'ja') {
|
||||||
const p = parseUren(a.punten ?? '');
|
const p = parseUren(a.punten ?? '');
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,17 @@ import { Component, computed, inject, input } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.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 { 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 { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
||||||
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
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
|
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
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. */
|
the dashboard shows "in behandeling" immediately. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-herregistratie-wizard',
|
selector: 'app-herregistratie-wizard',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent, DocumentUploadComponent],
|
||||||
template: `
|
template: `
|
||||||
@switch (state().tag) {
|
<app-wizard-shell
|
||||||
@case ('Editing') {
|
[steps]="stepLabels"
|
||||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ step() }} van 2</p>
|
[current]="step() - 1"
|
||||||
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
|
[stepTitle]="stepTitle()"
|
||||||
@if (step() === 1) {
|
[status]="shellStatus()"
|
||||||
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
|
[primaryLabel]="primaryLabel()"
|
||||||
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
[canGoBack]="step() > 1"
|
||||||
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
|
[errors]="errorList()"
|
||||||
</app-form-field>
|
[errorMessage]="errorMessage()"
|
||||||
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()">
|
(primary)="onPrimary()"
|
||||||
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
(cancel)="restart()"
|
||||||
</app-form-field>
|
(retry)="onRetry()">
|
||||||
<div style="display:flex;gap:0.5rem">
|
|
||||||
<app-button type="submit" variant="primary">Volgende</app-button>
|
@switch (step()) {
|
||||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
@case (1) {
|
||||||
</div>
|
<app-form-field i18n-label="@@herregWizard.urenLabel" label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
|
||||||
} @else {
|
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
name="uren" [invalid]="!!errUren()" i18n-placeholder="@@herregWizard.urenPlaceholder" placeholder="bijv. 4160" />
|
||||||
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
</app-form-field>
|
||||||
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
|
<app-form-field i18n-label="@@herregWizard.jarenLabel" label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()">
|
||||||
</app-form-field>
|
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||||
<div style="display:flex;gap:0.5rem">
|
name="jaren" [invalid]="!!errJaren()" i18n-placeholder="@@herregWizard.jarenPlaceholder" placeholder="bijv. 5" />
|
||||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
</app-form-field>
|
||||||
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
|
}
|
||||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
@case (2) {
|
||||||
</div>
|
<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>
|
<div wizardSuccess>
|
||||||
}
|
<app-alert type="ok" i18n="@@herregWizard.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||||
@case ('Submitted') {
|
</div>
|
||||||
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
</app-wizard-shell>
|
||||||
}
|
|
||||||
@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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class HerregistratieWizardComponent {
|
export class HerregistratieWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
|
private apiClient = inject(ApiClient);
|
||||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** 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
|
readonly state = this.store.model; // public so the showcase can highlight the live state
|
||||||
protected dispatch = this.store.dispatch;
|
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 step = computed(() => this.editing()?.step ?? 1);
|
||||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
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 errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
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() {
|
constructor() {
|
||||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||||
@@ -90,7 +132,7 @@ export class HerregistratieWizardComponent {
|
|||||||
onPrimary() {
|
onPrimary() {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Editing') return;
|
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();
|
this.runIfSubmitting();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +152,7 @@ export class HerregistratieWizardComponent {
|
|||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Submitting') return;
|
if (s.tag !== 'Submitting') return;
|
||||||
this.profile.beginHerregistratie();
|
this.profile.beginHerregistratie();
|
||||||
const r = await submitHerregistratie(s.data);
|
const r = await submitHerregistratie(this.apiClient, s.data);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
this.profile.confirmHerregistratie();
|
this.profile.confirmHerregistratie();
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { applicationConfig } from '@storybook/angular';
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
||||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||||
|
import { initialUpload } from '@shared/upload/upload.machine';
|
||||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
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> = {
|
const meta: Meta<HerregistratieWizardComponent> = {
|
||||||
title: 'Herregistratie/Wizard',
|
title: 'Herregistratie/Wizard',
|
||||||
@@ -18,11 +19,12 @@ export default meta;
|
|||||||
type Story = StoryObj<HerregistratieWizardComponent>;
|
type Story = StoryObj<HerregistratieWizardComponent>;
|
||||||
|
|
||||||
// Each story seeds one state of the machine — one render per union variant.
|
// 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 = {
|
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 Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||||
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||||
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
|
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
|
|||||||
selector: 'app-herregistratie-page',
|
selector: 'app-herregistratie-page',
|
||||||
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
|
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
|
||||||
template: `
|
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()">
|
<app-async [data]="eligibility()">
|
||||||
<ng-template appAsyncLoaded let-eligible>
|
<ng-template appAsyncLoaded let-eligible>
|
||||||
@if (eligible) {
|
@if (eligible) {
|
||||||
<app-alert type="info">
|
<app-alert type="info" i18n="@@herregistratie.eligible">
|
||||||
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
|
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
|
||||||
</app-alert>
|
</app-alert>
|
||||||
<div style="margin-top:1.5rem">
|
<div class="app-section">
|
||||||
<app-herregistratie-wizard />
|
<app-herregistratie-wizard />
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<app-alert type="warning">
|
<app-alert type="warning" i18n="@@herregistratie.notEligible">
|
||||||
Voor uw huidige registratiestatus is herregistratie niet mogelijk.
|
Voor uw huidige registratiestatus is herregistratie niet mogelijk.
|
||||||
</app-alert>
|
</app-alert>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.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 { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.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 { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import {
|
import {
|
||||||
IntakeState,
|
IntakeState,
|
||||||
@@ -20,110 +21,103 @@ import {
|
|||||||
lageUren,
|
lageUren,
|
||||||
SCHOLING_THRESHOLD_DEFAULT,
|
SCHOLING_THRESHOLD_DEFAULT,
|
||||||
} from '@herregistratie/domain/intake.machine';
|
} from '@herregistratie/domain/intake.machine';
|
||||||
import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';
|
|
||||||
import { submitIntake } from '@herregistratie/application/submit-intake';
|
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 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
|
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||||
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
||||||
from the answers via `visibleSteps`, never stored — so editing an earlier
|
from the answers via `visibleSteps`, never stored — so editing an earlier
|
||||||
answer immediately changes the remaining steps. Answers are persisted to
|
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({
|
@Component({
|
||||||
selector: 'app-intake-wizard',
|
selector: 'app-intake-wizard',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent],
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, WizardShellComponent],
|
||||||
template: `
|
template: `
|
||||||
@switch (state().tag) {
|
<app-wizard-shell
|
||||||
@case ('Answering') {
|
[steps]="stepLabels"
|
||||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps.length }}</p>
|
[current]="cursor()"
|
||||||
<form (ngSubmit)="onPrimary()" style="max-width:30rem">
|
[stepTitle]="stepTitle()"
|
||||||
@switch (step()) {
|
[status]="shellStatus()"
|
||||||
@case ('buitenland') {
|
[primaryLabel]="primaryLabel()"
|
||||||
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenlandGewerkt')">
|
[canGoBack]="cursor() > 0"
|
||||||
<app-radio-group name="buitenland" [options]="jaNee"
|
[errors]="errorList()"
|
||||||
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
[errorMessage]="errorMessage()"
|
||||||
</app-form-field>
|
(primary)="onPrimary()"
|
||||||
@if (answers().buitenlandGewerkt === 'ja') {
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
<app-form-field label="In welk land?" fieldId="land" [error]="err('land')">
|
(cancel)="restart()"
|
||||||
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
|
(retry)="onRetry()">
|
||||||
</app-form-field>
|
|
||||||
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" [error]="err('buitenlandseUren')">
|
@switch (step()) {
|
||||||
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
|
@case ('buitenland') {
|
||||||
</app-form-field>
|
<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)" />
|
||||||
@case ('werk') {
|
</app-form-field>
|
||||||
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
|
@if (answers().buitenlandGewerkt === 'ja') {
|
||||||
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
|
<app-form-field i18n-label="@@intake.q.land" label="In welk land?" fieldId="land" required [error]="err('land')">
|
||||||
</app-form-field>
|
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" i18n-placeholder="@@intake.q.landPlaceholder" placeholder="bijv. België" />
|
||||||
@if (scholingZichtbaar()) {
|
</app-form-field>
|
||||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholingGevolgd')">
|
<app-form-field i18n-label="@@intake.q.buitenlandseUren" label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" required [error]="err('buitenlandseUren')">
|
||||||
<app-radio-group name="scholing" [options]="jaNee"
|
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder" placeholder="bijv. 800" />
|
||||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
</app-form-field>
|
||||||
</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>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
<div style="display:flex;gap:0.5rem;margin-top:1rem">
|
}
|
||||||
@if (cursor() > 0) {
|
@case ('werk') {
|
||||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
<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-data-row i18n-key="@@intake.review.urenNl" key="Uren NL" [value]="answers().uren ?? ''" />
|
||||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
@if (scholingZichtbaar()) {
|
||||||
</div>
|
<app-data-row i18n-key="@@intake.review.scholing" key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''" />
|
||||||
</form>
|
}
|
||||||
|
@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>
|
<div wizardSuccess>
|
||||||
}
|
<app-alert type="ok" i18n="@@intake.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||||
@case ('Submitted') {
|
<div class="app-section">
|
||||||
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw">Opnieuw beginnen</app-button>
|
||||||
<div style="margin-top:1rem">
|
|
||||||
<app-button variant="secondary" (click)="restart()">Opnieuw beginnen</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
@case ('Failed') {
|
</app-wizard-shell>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class IntakeWizardComponent {
|
export class IntakeWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
|
private apiClient = inject(ApiClient);
|
||||||
|
private policy = inject(IntakePolicyAdapter);
|
||||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
||||||
|
|
||||||
// Server-owned policy: the scholing threshold is fetched, not hardcoded. The
|
// Server-owned policy: the scholing threshold is fetched from the backend, not
|
||||||
// backend stays the authority and re-validates on submit.
|
// hardcoded. The backend stays the authority and re-validates on submit.
|
||||||
private policyRes = httpResource<IntakePolicyDto>(() => 'mock/intake-policy.json', {
|
private policyRes = this.policy.policyResource();
|
||||||
defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<IntakeState>(initial);
|
seed = input<IntakeState>(initial);
|
||||||
@@ -132,7 +126,7 @@ export class IntakeWizardComponent {
|
|||||||
readonly state = this.store.model;
|
readonly state = this.store.model;
|
||||||
readonly dispatch = this.store.dispatch;
|
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. */
|
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
||||||
readonly steps = STEPS;
|
readonly steps = STEPS;
|
||||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||||
@@ -142,35 +136,64 @@ export class IntakeWizardComponent {
|
|||||||
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
|
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
|
||||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
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 err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
||||||
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
|
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories) wins; otherwise resume from localStorage.
|
// An explicit seed (stories) wins; otherwise resume from sessionStorage.
|
||||||
const seeded = this.seed();
|
const seeded = this.seed();
|
||||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
|
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(() => {
|
effect(() => {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
if (s.tag === 'Answering') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||||
else localStorage.removeItem(STORAGE_KEY);
|
else sessionStorage.removeItem(STORAGE_KEY);
|
||||||
});
|
});
|
||||||
// Apply the server-owned threshold into machine state as it arrives. Track
|
// Apply the server-owned threshold into machine state as it arrives. Track
|
||||||
// only the policy value; untrack the dispatch (it reads the state signal
|
// only the policy value; untrack the dispatch (it reads the state signal
|
||||||
// internally, which would otherwise make this effect loop on its own write).
|
// internally, which would otherwise make this effect loop on its own write).
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const scholingThreshold = this.policyRes.value().scholingThreshold;
|
const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
|
||||||
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private restore(): IntakeState | null {
|
private restore(): IntakeState | null {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null; // ponytail: corrupt entry -> start fresh, no migration.
|
return null; // ponytail: corrupt entry -> start fresh, no migration.
|
||||||
}
|
}
|
||||||
@@ -198,7 +221,7 @@ export class IntakeWizardComponent {
|
|||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Submitting') return;
|
if (s.tag !== 'Submitting') return;
|
||||||
this.profile.beginHerregistratie();
|
this.profile.beginHerregistratie();
|
||||||
const r = await submitIntake(s.data);
|
const r = await submitIntake(this.apiClient, s.data);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
this.profile.confirmHerregistratie();
|
this.profile.confirmHerregistratie();
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w
|
|||||||
selector: 'app-intake-page',
|
selector: 'app-intake-page',
|
||||||
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
|
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell heading="Herregistratie — intake" backLink="/dashboard">
|
<app-page-shell i18n-heading="@@intake.heading" heading="Herregistratie — intake" backLink="/dashboard">
|
||||||
<app-alert type="info">
|
<app-alert type="info" i18n="@@intake.intro">
|
||||||
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw
|
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw
|
||||||
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
|
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
|
||||||
de pagina herlaadt.
|
de pagina herlaadt.
|
||||||
</app-alert>
|
</app-alert>
|
||||||
<div style="margin-top:1.5rem">
|
<div class="app-section">
|
||||||
<app-intake-wizard />
|
<app-intake-wizard />
|
||||||
</div>
|
</div>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export class BigProfileStore {
|
|||||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
||||||
|
|
||||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
|
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
|
||||||
fromResource(this.aantekeningenRes, (v) => v.length === 0),
|
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 -----------
|
// --- Optimistic herregistratie state, shared with the dashboard -----------
|
||||||
private pending = signal(false);
|
private pending = signal(false);
|
||||||
|
|||||||
20
src/app/registratie/application/submit-change-request.ts
Normal file
20
src/app/registratie/application/submit-change-request.ts
Normal 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);
|
||||||
|
}
|
||||||
27
src/app/registratie/application/submit-registratie.spec.ts
Normal file
27
src/app/registratie/application/submit-registratie.spec.ts
Normal 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') });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { 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
|
* Command: POST the completed registration to the backend (`/api/registrations`),
|
||||||
* domain command to create/finalize the registration aggregate. Returns a Result
|
* which re-validates and decides. The rule that a manually entered diploma cannot
|
||||||
* so the caller branches on success/failure without try/catch; on success it
|
* be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On
|
||||||
* yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap
|
* success it yields the server-generated confirmation reference (PRD §9).
|
||||||
* for a real POST when there's an API. The "manually entered diploma" rule lets
|
|
||||||
* the demo show the failure path.
|
|
||||||
*/
|
*/
|
||||||
export async function submitRegistratie(data: ValidRegistratie): Promise<Result<string, string>> {
|
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
return runSubmit(async () => {
|
||||||
if (data.diplomaHerkomst === 'handmatig') {
|
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst, documents: data.documents });
|
||||||
return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.');
|
return res.referentie ?? '';
|
||||||
}
|
}, SUBMIT_FAILED);
|
||||||
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
|
|
||||||
return ok(referentie);
|
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/app/registratie/domain/change-request.machine.spec.ts
Normal file
45
src/app/registratie/domain/change-request.machine.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
src/app/registratie/domain/change-request.machine.ts
Normal file
82
src/app/registratie/domain/change-request.machine.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ok, err } from '@shared/kernel/fp';
|
import { ok, err } from '@shared/kernel/fp';
|
||||||
|
import { initialUpload } from '@shared/upload/upload.machine';
|
||||||
import {
|
import {
|
||||||
Draft,
|
Draft,
|
||||||
RegistratieState,
|
RegistratieState,
|
||||||
@@ -25,6 +26,7 @@ const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
|
|||||||
draft: { antwoorden: {}, ...draft },
|
draft: { antwoorden: {}, ...draft },
|
||||||
cursor,
|
cursor,
|
||||||
errors: {},
|
errors: {},
|
||||||
|
upload: initialUpload,
|
||||||
});
|
});
|
||||||
|
|
||||||
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
|
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');
|
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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||||
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||||
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
|
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
|
* A FIXED 3-step registration wizard. The steps never change in number (always
|
||||||
@@ -49,6 +58,7 @@ export interface ValidRegistratie {
|
|||||||
diplomaHerkomst: DiplomaHerkomst;
|
diplomaHerkomst: DiplomaHerkomst;
|
||||||
beroep: string;
|
beroep: string;
|
||||||
antwoorden: Record<string, string>;
|
antwoorden: Record<string, string>;
|
||||||
|
documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Text fields settable via SetField. */
|
/** Text fields settable via SetField. */
|
||||||
@@ -63,17 +73,18 @@ export interface Errors {
|
|||||||
email?: string;
|
email?: string;
|
||||||
correspondentie?: string;
|
correspondentie?: string;
|
||||||
diploma?: string;
|
diploma?: string;
|
||||||
|
documenten?: string;
|
||||||
antwoorden?: Record<string, string>;
|
antwoorden?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RegistratieState =
|
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: 'Indienen'; data: ValidRegistratie }
|
||||||
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
|
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
|
||||||
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
||||||
|
|
||||||
const emptyDraft: Draft = { antwoorden: {} };
|
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). */
|
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||||
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
|
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. */
|
/** 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 = {};
|
const errors: Errors = {};
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 'adres': {
|
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 ?? '');
|
const pc = parsePostcode(d.postcode ?? '');
|
||||||
if (!pc.ok) errors.postcode = pc.error;
|
if (!pc.ok) errors.postcode = pc.error;
|
||||||
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.';
|
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
|
||||||
if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.';
|
if (!d.correspondentie) errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||||
// E-mail is only required when 'email' is the chosen channel.
|
// E-mail is only required when 'email' is the chosen channel.
|
||||||
if (d.correspondentie === 'email') {
|
if (d.correspondentie === 'email') {
|
||||||
const e = parseEmail(d.email ?? '');
|
const e = parseEmail(d.email ?? '');
|
||||||
@@ -100,7 +111,7 @@ function validateStep(step: StepId, d: Draft): Result<Errors, void> {
|
|||||||
case 'beroep': {
|
case 'beroep': {
|
||||||
// A diploma must be chosen (or declared manually); its beroep is then known.
|
// A diploma must be chosen (or declared manually); its beroep is then known.
|
||||||
if (!d.diplomaId || !d.beroep) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
// Every policy question the chosen diploma raised must be answered. Which
|
// 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.
|
// they're answered.
|
||||||
const open: Record<string, string> = {};
|
const open: Record<string, string> = {};
|
||||||
for (const id of d.vraagIds ?? []) {
|
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;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'controle':
|
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). */
|
/** 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 = {};
|
const errors: Errors = {};
|
||||||
for (const step of STEPS) {
|
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 (!r.ok) Object.assign(errors, r.error);
|
||||||
}
|
}
|
||||||
if (Object.keys(errors).length > 0) return err(errors);
|
if (Object.keys(errors).length > 0) return err(errors);
|
||||||
@@ -147,6 +162,7 @@ function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
|
|||||||
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
|
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
|
||||||
beroep: d.beroep,
|
beroep: d.beroep,
|
||||||
antwoorden,
|
antwoorden,
|
||||||
|
documents: deliveryRefs(upload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +213,7 @@ export function setAntwoord(s: RegistratieState, vraagId: string, value: string)
|
|||||||
|
|
||||||
export function next(s: RegistratieState): RegistratieState {
|
export function next(s: RegistratieState): RegistratieState {
|
||||||
if (s.tag !== 'Invullen') return s;
|
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 };
|
if (!r.ok) return { ...s, errors: r.error };
|
||||||
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
|
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 {
|
export function submit(s: RegistratieState): RegistratieState {
|
||||||
if (s.tag !== 'Invullen') return s;
|
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 };
|
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 {
|
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
|
||||||
if (s.tag !== 'Indienen') return s;
|
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 };
|
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: 'Retry' }
|
||||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||||
| { tag: 'SubmitFailed'; error: string }
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
|
| { tag: 'Upload'; msg: UploadMsg }
|
||||||
| { tag: 'Seed'; state: RegistratieState };
|
| { tag: 'Seed'; state: RegistratieState };
|
||||||
|
|
||||||
export function reduce(s: RegistratieState, m: RegistratieMsg): 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;
|
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
|
||||||
case 'SubmitFailed':
|
case 'SubmitFailed':
|
||||||
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
||||||
|
case 'Upload':
|
||||||
|
return upload(s, m.msg);
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
default:
|
default:
|
||||||
|
|||||||
40
src/app/registratie/domain/tasks.spec.ts
Normal file
40
src/app/registratie/domain/tasks.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/app/registratie/domain/tasks.ts
Normal file
59
src/app/registratie/domain/tasks.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,5 +5,5 @@ export type BigNummer = Brand<string, 'BigNummer'>;
|
|||||||
|
|
||||||
export function parseBigNummer(raw: string): Result<string, BigNummer> {
|
export function parseBigNummer(raw: string): Result<string, BigNummer> {
|
||||||
const t = raw.trim();
|
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.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function parseEmail(raw: string): Result<string, Email> {
|
|||||||
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
|
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
|
||||||
// for instant feedback; the server re-validates.
|
// for instant feedback; the server re-validates.
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
|
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);
|
return ok(t as Email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type Postcode = Brand<string, 'Postcode'>;
|
|||||||
export function parsePostcode(raw: string): Result<string, Postcode> {
|
export function parsePostcode(raw: string): Result<string, Postcode> {
|
||||||
const t = raw.trim().toUpperCase();
|
const t = raw.trim().toUpperCase();
|
||||||
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
|
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.
|
// Normalise to "1234 AB" — the parser also cleans up.
|
||||||
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
|
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function parseUren(raw: string): Result<string, Uren> {
|
|||||||
const n = Number(t);
|
const n = Number(t);
|
||||||
// Number('') is 0 — guard the empty string explicitly.
|
// Number('') is 0 — guard the empty string explicitly.
|
||||||
if (t === '' || !Number.isInteger(n) || n < 0) {
|
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);
|
return ok(n as Uren);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
import { Aantekening, AantekeningType } from '../domain/registration';
|
||||||
import { Aantekening } from '../domain/registration';
|
import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
||||||
* resources (Angular's httpResource); each returns a Resource with
|
* resources (Angular's `resource` over the generated typed client); each returns
|
||||||
* status()/value()/error()/reload(). Call from an injection context
|
* a Resource with status()/value()/error()/reload(). Call from an injection
|
||||||
* (a field initializer in the store).
|
* context (a field initializer in the store).
|
||||||
*
|
*
|
||||||
* Note: registration + person are now served via the aggregated dashboard-view
|
* Note: registration + person are now served via the aggregated dashboard-view
|
||||||
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
|
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BigRegisterAdapter {
|
export class BigRegisterAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
aantekeningenResource() {
|
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 ?? '' };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
|
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
|
* Infrastructure adapter for the BRP address lookup, reached only through our own
|
||||||
* ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
|
* ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET
|
||||||
* is a static mock; pointing at a real backend touches only this file + the DTO.
|
* backend (`GET /api/brp/address`) via the generated typed client.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BrpAdapter {
|
export class BrpAdapter {
|
||||||
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
|
private client = inject(ApiClient);
|
||||||
// parseBrpAddress validates it.
|
|
||||||
|
// The value is untrusted JSON until parseBrpAddress validates it.
|
||||||
adresResource() {
|
adresResource() {
|
||||||
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
|
return resource({ loader: () => this.client.address() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
|
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.
|
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
||||||
* ONE call returns registration + person + server-computed decisions.
|
* ONE call returns registration + person + server-computed decisions. The data
|
||||||
* (In this POC the endpoint is a static mock; the decisions are precomputed to
|
* comes from the .NET backend (`GET /api/dashboard-view`) via the generated typed
|
||||||
* stand in for what the backend would compute.)
|
* client; the decisions (e.g. herregistratie eligibility) are computed there.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DashboardViewAdapter {
|
export class DashboardViewAdapter {
|
||||||
// Typed as the DTO for ergonomics, but the value is still untrusted JSON —
|
private client = inject(ApiClient);
|
||||||
// parseDashboardView validates it at the boundary before the app uses it.
|
|
||||||
|
// 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() {
|
dashboardViewResource() {
|
||||||
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
|
return resource({ loader: () => this.client.dashboardView() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
|
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
|
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
|
||||||
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
|
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
|
||||||
* user's diplomas (each with its server-computed beroep + policy questions) and
|
* 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
|
* the manual-entry fallback policy. The frontend renders; it does not derive.
|
||||||
* this POC the endpoint is a static mock.
|
* Data comes from the .NET backend (`GET /api/duo/diplomas`) via the typed client.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DuoAdapter {
|
export class DuoAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
diplomasResource() {
|
diplomasResource() {
|
||||||
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
|
return resource({ loader: () => this.client.diplomas() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,20 +19,25 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-address-fields',
|
selector: 'app-address-fields',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent],
|
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: `
|
template: `
|
||||||
<fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset">
|
<fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset">
|
||||||
<legend class="utrecht-form-fieldset__legend utrecht-form-fieldset__legend--html-legend">{{ legend() }}</legend>
|
<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"
|
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat"
|
||||||
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
|
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
|
||||||
name="straat" [ngModelOptions]="{ standalone: true }" />
|
name="straat" [ngModelOptions]="{ standalone: true }" />
|
||||||
</app-form-field>
|
</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"
|
<app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode"
|
||||||
[ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
|
[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>
|
||||||
<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"
|
<app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats"
|
||||||
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
|
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
|
||||||
name="woonplaats" [ngModelOptions]="{ standalone: true }" />
|
name="woonplaats" [ngModelOptions]="{ standalone: true }" />
|
||||||
@@ -45,6 +50,6 @@ export class AddressFieldsComponent {
|
|||||||
errors = input<AdresErrors>({});
|
errors = input<AdresErrors>({});
|
||||||
/** Prefix for field ids/labels — keeps them unique if two blocks ever co-exist. */
|
/** Prefix for field ids/labels — keeps them unique if two blocks ever co-exist. */
|
||||||
idPrefix = input('adres');
|
idPrefix = input('adres');
|
||||||
legend = input('Adres');
|
legend = input($localize`:@@address.legend:Adres`);
|
||||||
fieldChange = output<{ key: keyof AdresValue; value: string }>();
|
fieldChange = output<{ key: keyof AdresValue; value: string }>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,95 @@
|
|||||||
import { Component, output, signal } from '@angular/core';
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
import { AddressFieldsComponent, AdresValue } from '@registratie/ui/address-fields/address-fields.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
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. */
|
* Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
|
||||||
export interface ChangeRequest {
|
* wizards — all state in one signal driven by the pure `reduce`
|
||||||
street: string;
|
* (change-request.machine.ts), submitted via a `submit-*` command returning
|
||||||
zip: Postcode;
|
* `Result`. Renders the shared `<app-address-fields>`; the server re-validates.
|
||||||
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. */
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-change-request-form',
|
selector: 'app-change-request-form',
|
||||||
imports: [FormsModule, ButtonComponent, HeadingComponent, AddressFieldsComponent],
|
imports: [ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
|
||||||
template: `
|
template: `
|
||||||
<app-heading [level]="2">Adreswijziging doorgeven</app-heading>
|
@if (state().tag === 'Submitted') {
|
||||||
<form (ngSubmit)="onSubmit()" style="max-width:28rem">
|
<app-alert type="ok" i18n="@@changeRequest.success">
|
||||||
<app-address-fields
|
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht.
|
||||||
idPrefix="cr"
|
</app-alert>
|
||||||
[value]="{ straat: street, postcode: zip, woonplaats: city }"
|
<div class="app-section">
|
||||||
[errors]="{ straat: streetError(), postcode: zipError() }"
|
<app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })" i18n="@@changeRequest.nieuwe">Nieuwe wijziging doorgeven</app-button>
|
||||||
(fieldChange)="onAdres($event)" />
|
|
||||||
<div style="margin-top:1rem">
|
|
||||||
<app-button type="submit" variant="primary">Wijziging indienen</app-button>
|
|
||||||
</div>
|
</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 {
|
export class ChangeRequestFormComponent {
|
||||||
street = '';
|
private apiClient = inject(ApiClient);
|
||||||
zip = '';
|
private store = createStore<State, Msg>(initial, reduce);
|
||||||
city = '';
|
|
||||||
streetError = signal('');
|
|
||||||
zipError = signal('');
|
|
||||||
submitted = output<ChangeRequest>();
|
|
||||||
|
|
||||||
onAdres(e: { key: keyof AdresValue; value: string }) {
|
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||||
if (e.key === 'straat') this.street = e.value;
|
seed = input<State>(initial);
|
||||||
else if (e.key === 'postcode') this.zip = e.value;
|
|
||||||
else this.city = e.value;
|
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() {
|
onSubmit() {
|
||||||
const street = this.street.trim();
|
this.dispatch({ tag: 'Submit' });
|
||||||
this.streetError.set(street ? '' : 'Vul straat en huisnummer in.');
|
this.runIfSubmitting();
|
||||||
|
}
|
||||||
|
|
||||||
const postcode = parsePostcode(this.zip);
|
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
|
||||||
this.zipError.set(postcode.ok ? '' : postcode.error);
|
private async runIfSubmitting() {
|
||||||
|
const s = this.state();
|
||||||
if (!street || !postcode.ok) return;
|
if (s.tag !== 'Submitting') return;
|
||||||
this.submitted.emit({ street, zip: postcode.value, city: this.city.trim() });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' } } };
|
||||||
@@ -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 { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.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 { ASYNC } from '@shared/ui/async/async.component';
|
||||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||||
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
|
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
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({
|
@Component({
|
||||||
selector: 'app-dashboard-page',
|
selector: 'app-dashboard-page',
|
||||||
imports: [
|
imports: [
|
||||||
PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,
|
PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,
|
||||||
DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent,
|
DataRowComponent, CardComponent, TaskListComponent, SideNavComponent, ...ASYNC,
|
||||||
|
RegistrationSummaryComponent, RegistrationTableComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell heading="Mijn BIG-registratie">
|
<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.">
|
||||||
@if (store.pendingHerregistratie()) {
|
<div class="app-overview">
|
||||||
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
<app-side-nav [items]="nav" />
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ONE state for two combined services (BIG-register + BRP). -->
|
<div class="app-stack">
|
||||||
<app-async [data]="store.profile()">
|
@if (store.pendingHerregistratie()) {
|
||||||
<ng-template appAsyncLoaded let-p>
|
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
||||||
<app-registration-summary [reg]="$any(p).registration" />
|
}
|
||||||
<div class="rhc-card rhc-card--default" style="margin-top:1rem">
|
|
||||||
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
|
|
||||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
|
||||||
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
|
|
||||||
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
|
|
||||||
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template appAsyncLoading>
|
|
||||||
<app-skeleton height="2.5rem" [count]="6" />
|
|
||||||
</ng-template>
|
|
||||||
</app-async>
|
|
||||||
|
|
||||||
<div style="margin-top:2rem">
|
<app-async [data]="store.profile()">
|
||||||
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
<ng-template appAsyncLoaded let-p>
|
||||||
<app-async [data]="store.aantekeningen()">
|
@let tasks = tasksFor($any(p).registration);
|
||||||
<ng-template appAsyncLoaded let-r>
|
|
||||||
<app-registration-table [rows]="$any(r)" />
|
<section>
|
||||||
</ng-template>
|
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen">Wat moet ik regelen</app-heading>
|
||||||
<ng-template appAsyncLoading>
|
@if (tasks.length) {
|
||||||
<app-skeleton height="2.5rem" [count]="3" />
|
<app-task-list class="app-section" [tasks]="tasks" />
|
||||||
</ng-template>
|
} @else {
|
||||||
<ng-template appAsyncEmpty>
|
<p class="rhc-paragraph app-text-subtle" i18n="@@dashboard.nietsOpenstaan">U heeft op dit moment niets openstaan.</p>
|
||||||
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
|
}
|
||||||
</ng-template>
|
</section>
|
||||||
</app-async>
|
|
||||||
|
<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>
|
</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>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class DashboardPage {
|
export class DashboardPage {
|
||||||
protected store = inject(BigProfileStore);
|
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` },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.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 { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.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 { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.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 { ASYNC } from '@shared/ui/async/async.component';
|
||||||
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||||
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
||||||
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
||||||
@@ -28,58 +28,74 @@ import {
|
|||||||
STEPS,
|
STEPS,
|
||||||
} from '@registratie/domain/registratie-wizard.machine';
|
} from '@registratie/domain/registratie-wizard.machine';
|
||||||
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
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 STORAGE_KEY = 'registratie-v2'; // ponytail: bump the suffix if the persisted shape changes; no migration.
|
||||||
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
|
const KANALEN = [
|
||||||
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
{ 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"
|
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
||||||
|
|
||||||
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
/** 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
|
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
|
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
|
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. */
|
atoms/molecules. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registratie-wizard',
|
selector: 'app-registratie-wizard',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
||||||
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent,
|
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
|
||||||
AddressFieldsComponent, ...ASYNC,
|
AddressFieldsComponent, DocumentUploadComponent, ...ASYNC,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@switch (state().tag) {
|
<app-wizard-shell
|
||||||
@case ('Invullen') {
|
[steps]="stepLabels"
|
||||||
<app-stepper class="app-section" [steps]="stepLabels" [current]="cursor()" />
|
[current]="cursor()"
|
||||||
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
|
[stepTitle]="stepTitle()"
|
||||||
<form (ngSubmit)="onPrimary()" class="app-form">
|
[status]="shellStatus()"
|
||||||
@switch (step()) {
|
[primaryLabel]="primaryLabel()"
|
||||||
@case ('adres') {
|
[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') {
|
@if (adresStatus() === 'laden') {
|
||||||
<app-skeleton height="2.5rem" [count]="4" />
|
<app-skeleton height="2.5rem" [count]="4" />
|
||||||
} @else {
|
} @else {
|
||||||
@switch (adresStatus()) {
|
@switch (adresStatus()) {
|
||||||
@case ('gevonden') {
|
@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') {
|
@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') {
|
@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
|
<app-address-fields
|
||||||
[value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }"
|
[value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }"
|
||||||
[errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }"
|
[errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }"
|
||||||
(fieldChange)="set($event.key, $event.value)" />
|
(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')"
|
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
|
||||||
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
|
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
@if (draft().correspondentie === 'email') {
|
@if (draft().correspondentie === 'email') {
|
||||||
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')">
|
<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" placeholder="naam@voorbeeld.nl" />
|
<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>
|
</app-form-field>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,20 +103,20 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
@case ('beroep') {
|
@case ('beroep') {
|
||||||
<app-async [data]="lookupRd()">
|
<app-async [data]="lookupRd()">
|
||||||
<ng-template appAsyncLoaded let-data>
|
<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')"
|
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
|
||||||
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
|
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
|
||||||
@if (handmatigActief()) {
|
@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-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 label="Voor welk beroep wilt u zich registreren?" fieldId="hm-beroep" [error]="err('diploma')">
|
<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')"
|
<app-radio-group name="hm-beroep" [options]="beroepOptions($any(data))" [invalid]="!!err('diploma')"
|
||||||
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
|
[ngModel]="draft().beroep ?? ''" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
} @else if (draft().beroep) {
|
} @else if (draft().beroep) {
|
||||||
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
<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>
|
</dl>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,58 +136,54 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
<app-skeleton height="2.5rem" [count]="3" />
|
<app-skeleton height="2.5rem" [count]="3" />
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</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') {
|
@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">
|
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
||||||
<app-data-row key="Adres" [value]="adresSamenvatting()" />
|
<app-data-row i18n-key="@@regWizard.summary.adres" key="Adres" [value]="adresSamenvatting()" />
|
||||||
<app-data-row key="Herkomst adres" [value]="adresHerkomstLabel()" />
|
<app-data-row i18n-key="@@regWizard.summary.herkomstAdres" key="Herkomst adres" [value]="adresHerkomstLabel()" />
|
||||||
<app-data-row key="Correspondentie" [value]="correspondentieLabel()" />
|
<app-data-row i18n-key="@@regWizard.summary.correspondentie" key="Correspondentie" [value]="correspondentieLabel()" />
|
||||||
@if (draft().correspondentie === 'email') {
|
@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 i18n-key="@@regWizard.summary.beroep" key="Beroep" [value]="draft().beroep ?? ''" />
|
||||||
<app-data-row key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
|
<app-data-row i18n-key="@@regWizard.summary.herkomstDiploma" key="Herkomst diploma" [value]="diplomaHerkomstLabel()" />
|
||||||
@for (item of samenvattingVragen(); track item.vraag) {
|
@for (item of samenvattingVragen(); track item.vraag) {
|
||||||
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
|
<app-data-row [key]="item.vraag" [value]="item.antwoord" />
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
<div class="app-button-row app-section">
|
<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: 0 })" i18n="@@regWizard.adresWijzigen">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: 1 })" i18n="@@regWizard.diplomaWijzigen">Diploma wijzigen</app-button>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
@case ('Ingediend') {
|
|
||||||
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
|
|
||||||
<div class="app-section">
|
<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>
|
</div>
|
||||||
}
|
</div>
|
||||||
@case ('Mislukt') {
|
</app-wizard-shell>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class RegistratieWizardComponent {
|
export class RegistratieWizardComponent {
|
||||||
private brp = inject(BrpAdapter);
|
private brp = inject(BrpAdapter);
|
||||||
private duo = inject(DuoAdapter);
|
private duo = inject(DuoAdapter);
|
||||||
|
private apiClient = inject(ApiClient);
|
||||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
||||||
|
|
||||||
protected adresRes = this.brp.adresResource();
|
protected adresRes = this.brp.adresResource();
|
||||||
@@ -181,29 +193,56 @@ export class RegistratieWizardComponent {
|
|||||||
seed = input<RegistratieState>(initial);
|
seed = input<RegistratieState>(initial);
|
||||||
|
|
||||||
readonly kanalen = KANALEN;
|
readonly kanalen = KANALEN;
|
||||||
readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper
|
readonly stepLabels = [$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`]; // short labels for the stepper
|
||||||
private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen'];
|
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 state = this.store.model;
|
||||||
readonly dispatch = this.store.dispatch;
|
readonly dispatch = this.store.dispatch;
|
||||||
|
|
||||||
/** Focus target: the step heading receives focus on step change (a11y). */
|
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
|
||||||
private stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
|
|
||||||
|
|
||||||
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
|
|
||||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
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 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 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 referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||||
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
|
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(() => {
|
protected adresSamenvatting = computed(() => {
|
||||||
const d = this.draft();
|
const d = this.draft();
|
||||||
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||||
});
|
});
|
||||||
// Readable labels for the controle summary (instead of raw enum values).
|
// 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 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: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post']));
|
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: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig']));
|
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
|
/** 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
|
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;
|
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 vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
|
||||||
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
|
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
|
||||||
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
||||||
@@ -246,7 +285,7 @@ export class RegistratieWizardComponent {
|
|||||||
|
|
||||||
protected diplomaOptions = (data: DuoLookupDto) => [
|
protected diplomaOptions = (data: DuoLookupDto) => [
|
||||||
...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })),
|
...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 }));
|
protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b }));
|
||||||
@@ -276,14 +315,15 @@ export class RegistratieWizardComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories) wins; otherwise resume from localStorage.
|
// An explicit seed (stories) wins; otherwise resume from sessionStorage.
|
||||||
const seeded = this.seed();
|
const seeded = this.seed();
|
||||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
|
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(() => {
|
effect(() => {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
if (s.tag === 'Invullen') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||||
else localStorage.removeItem(STORAGE_KEY);
|
else sessionStorage.removeItem(STORAGE_KEY);
|
||||||
});
|
});
|
||||||
// Prefill the address from the BRP lookup as it arrives. Track only the resource
|
// 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
|
// 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
|
// A11y: focus management (step heading on step change, error summary on a
|
||||||
// cursor(), which is value-stable across keystrokes — so typing never steals
|
// failed submit) now lives in the shared WizardShellComponent.
|
||||||
// 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());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private restore(): RegistratieState | null {
|
private restore(): RegistratieState | null {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null; // ponytail: corrupt entry -> start fresh, no migration.
|
return null; // ponytail: corrupt entry -> start fresh, no migration.
|
||||||
}
|
}
|
||||||
@@ -350,7 +381,7 @@ export class RegistratieWizardComponent {
|
|||||||
private async runIfIndienen() {
|
private async runIfIndienen() {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Indienen') return;
|
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 });
|
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
|
||||||
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { applicationConfig } from '@storybook/angular';
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { RegistratieWizardComponent } from './registratie-wizard.component';
|
import { RegistratieWizardComponent } from './registratie-wizard.component';
|
||||||
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
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';
|
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 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 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 = {
|
const validData: ValidRegistratie = {
|
||||||
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
|
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
|
||||||
@@ -18,6 +19,7 @@ const validData: ValidRegistratie = {
|
|||||||
diplomaHerkomst: 'duo',
|
diplomaHerkomst: 'duo',
|
||||||
beroep: 'Arts',
|
beroep: 'Arts',
|
||||||
antwoorden: {},
|
antwoorden: {},
|
||||||
|
documents: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta: Meta<RegistratieWizardComponent> = {
|
const meta: Meta<RegistratieWizardComponent> = {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r
|
|||||||
imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],
|
imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell
|
<app-page-shell
|
||||||
|
i18n-heading="@@registratie.heading"
|
||||||
heading="Inschrijven in het BIG-register"
|
heading="Inschrijven in het BIG-register"
|
||||||
backLink="/dashboard"
|
backLink="/dashboard">
|
||||||
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
|
<app-alert type="info" i18n="@@registratie.intro">
|
||||||
<app-alert type="info">
|
|
||||||
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
|
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
|
||||||
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
|
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
|
||||||
als u de pagina herlaadt.
|
als u de pagina herlaadt.
|
||||||
|
|||||||
@@ -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 { 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 { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||||
@@ -10,11 +9,11 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-detail-page',
|
selector: 'app-registration-detail-page',
|
||||||
imports: [
|
imports: [
|
||||||
PageShellComponent, AlertComponent, SkeletonComponent, ...ASYNC,
|
PageShellComponent, SkeletonComponent, ...ASYNC,
|
||||||
RegistrationSummaryComponent, ChangeRequestFormComponent,
|
RegistrationSummaryComponent, ChangeRequestFormComponent,
|
||||||
],
|
],
|
||||||
template: `
|
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()">
|
<app-async [data]="store.profile()">
|
||||||
<ng-template appAsyncLoaded let-p>
|
<ng-template appAsyncLoaded let-p>
|
||||||
<app-registration-summary [reg]="$any(p).registration" />
|
<app-registration-summary [reg]="$any(p).registration" />
|
||||||
@@ -24,17 +23,12 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</app-async>
|
||||||
|
|
||||||
<div style="margin-top:2rem">
|
<div class="app-section">
|
||||||
@if (submitted()) {
|
<app-change-request-form />
|
||||||
<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>
|
</div>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class RegistrationDetailPage {
|
export class RegistrationDetailPage {
|
||||||
protected store = inject(BigProfileStore);
|
protected store = inject(BigProfileStore);
|
||||||
submitted = signal(false);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,37 +4,38 @@ import { Registration } from '@registratie/domain/registration';
|
|||||||
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
|
import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.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. */
|
/** Organism: registration summary card. Composes data-row molecules + status-badge atom. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-summary',
|
selector: 'app-registration-summary',
|
||||||
imports: [DatePipe, DataRowComponent, StatusBadgeComponent],
|
imports: [DatePipe, DataRowComponent, StatusBadgeComponent, CardComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="rhc-card rhc-card--default">
|
<app-card>
|
||||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
<dl class="rhc-data-summary rhc-data-summary--row">
|
||||||
<app-data-row key="BIG-nummer" [value]="reg().bigNummer" />
|
<app-data-row i18n-key="@@summary.bigNummer" key="BIG-nummer" [value]="reg().bigNummer" />
|
||||||
<app-data-row key="Naam" [value]="reg().naam" />
|
<app-data-row i18n-key="@@summary.naam" key="Naam" [value]="reg().naam" />
|
||||||
<app-data-row key="Beroep" [value]="reg().beroep" />
|
<app-data-row i18n-key="@@summary.beroep" key="Beroep" [value]="reg().beroep" />
|
||||||
<app-data-row key="Status">
|
<app-data-row i18n-key="@@summary.status" key="Status">
|
||||||
<app-status-badge [label]="label()" [color]="color()" />
|
<app-status-badge [label]="label()" [color]="color()" />
|
||||||
</app-data-row>
|
</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. -->
|
<!-- Each status variant renders only the row its own data supports. -->
|
||||||
@switch (reg().status.tag) {
|
@switch (reg().status.tag) {
|
||||||
@case ('Geregistreerd') {
|
@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') {
|
@case ('Geschorst') {
|
||||||
<app-data-row key="Geschorst tot" [value]="$any(reg().status).geschorstTot | date:'longDate'" />
|
<app-data-row i18n-key="@@summary.geschorstTot" 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.reden" key="Reden" [value]="$any(reg().status).reden" />
|
||||||
}
|
}
|
||||||
@case ('Doorgehaald') {
|
@case ('Doorgehaald') {
|
||||||
<app-data-row key="Doorgehaald op" [value]="$any(reg().status).doorgehaaldOp | date:'longDate'" />
|
<app-data-row i18n-key="@@summary.doorgehaaldOp" 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.reden" key="Reden" [value]="$any(reg().status).reden" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</app-card>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class RegistrationSummaryComponent {
|
export class RegistrationSummaryComponent {
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { Aantekening } from '@registratie/domain/registration';
|
|||||||
<table class="utrecht-table utrecht-table--html-table utrecht-table--alternate-row-color">
|
<table class="utrecht-table utrecht-table--html-table utrecht-table--alternate-row-color">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="utrecht-table__header-cell">Type</th>
|
<th class="utrecht-table__header-cell" i18n="@@table.type">Type</th>
|
||||||
<th class="utrecht-table__header-cell">Omschrijving</th>
|
<th class="utrecht-table__header-cell" i18n="@@table.omschrijving">Omschrijving</th>
|
||||||
<th class="utrecht-table__header-cell">Datum</th>
|
<th class="utrecht-table__header-cell" i18n="@@table.datum">Datum</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
14
src/app/shared/application/session.port.ts
Normal file
14
src/app/shared/application/session.port.ts
Normal 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');
|
||||||
32
src/app/shared/application/store.spec.ts
Normal file
32
src/app/shared/application/store.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,10 @@ export function createStore<Model, Msg>(
|
|||||||
const model = signal(init);
|
const model = signal(init);
|
||||||
return {
|
return {
|
||||||
model: model.asReadonly(),
|
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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/app/shared/application/submit.spec.ts
Normal file
23
src/app/shared/application/submit.spec.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/app/shared/application/submit.ts
Normal file
20
src/app/shared/application/submit.ts
Normal 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.`;
|
||||||
67
src/app/shared/infrastructure/api-client.provider.ts
Normal file
67
src/app/shared/infrastructure/api-client.provider.ts
Normal 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 };
|
||||||
796
src/app/shared/infrastructure/api-client.ts
Normal file
796
src/app/shared/infrastructure/api-client.ts
Normal 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);
|
||||||
|
}
|
||||||
27
src/app/shared/infrastructure/api-error.spec.ts
Normal file
27
src/app/shared/infrastructure/api-error.spec.ts
Normal 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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/app/shared/infrastructure/api-error.ts
Normal file
36
src/app/shared/infrastructure/api-error.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import { delay } from 'rxjs/operators';
|
|||||||
import { currentScenario } from './scenario';
|
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.
|
* ?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) => {
|
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (!req.url.includes('mock/')) return next(req);
|
if (!req.url.includes('/api/')) return next(req);
|
||||||
|
|
||||||
switch (currentScenario()) {
|
switch (currentScenario()) {
|
||||||
case 'slow':
|
case 'slow':
|
||||||
@@ -17,7 +17,8 @@ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
case 'loading':
|
case 'loading':
|
||||||
return next(req).pipe(delay(600_000)); // effectively never resolves
|
return next(req).pipe(delay(600_000)); // effectively never resolves
|
||||||
case 'empty':
|
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':
|
case 'error':
|
||||||
return timer(400).pipe(
|
return timer(400).pipe(
|
||||||
switchMap(() => throwError(() =>
|
switchMap(() => throwError(() =>
|
||||||
|
|||||||
@@ -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. */
|
/** Reads ?scenario= from the URL so a demo can force each async state. */
|
||||||
export function currentScenario(): Scenario {
|
export function currentScenario(): Scenario {
|
||||||
|
|||||||
@@ -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
|
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
|
||||||
only through an explicit cast — so a smart constructor is the only minter. */
|
only through an explicit cast — so a smart constructor is the only minter. */
|
||||||
export type Brand<T, B extends string> = T & { readonly __brand: B };
|
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
Reference in New Issue
Block a user