Architect-review remediation: enforce conventions, prod-safe tooling, one form idiom, resilience seams

Acts on the showcase review. Four workstreams; all tests green
(npm run lint, 70 FE tests, ng build, 33 backend tests).

Enforcement + CI:
- eslint.config.mjs bans `any` and enforces layer/context boundaries
  (domain ≠ Angular; herregistratie → registratie → shared, auth → shared);
  `npm run lint` added; ajv 6 scoped to ESLint via nested override.
- .github/workflows/ci.yml: FE lint+check:tokens+test+build, backend dotnet test,
  and an API-client drift check.

One form idiom (the headline finding):
- change-request-form converged onto the wizard pattern — change-request.machine.ts
  (Model/Msg/reduce + value objects) + submit-change-request.ts (Result) + a real
  POST /api/v1/change-requests (server re-validates). Spec + story added; the detail
  page no longer holds an ad-hoc success signal.

Resilience/observability seam:
- api-client.provider.ts: request timeout, X-Correlation-Id, Idempotency-Key for
  writes; comments naming the retry/auth seams.
- Backend logs correlation id + a no-PII submit-audit line; /api/v1 prefix +
  backward-compat note; client regenerated.

Quick wins:
- Dev tooling excluded from prod: scenario.interceptor wired only under isDevMode()
  (?scenario= inert in prod); debug panel @if(isDev) (tree-shaken out).
- src/environments + apiBaseUrl into provideApiClient (angular.json fileReplacements).
- Backend /health + /health/ready.
- Debug view PII-minimised (redactProfile: name/address/DOB redacted, BIG masked).
- IntakePolicyAdapter (removes inline resource in the intake wizard).
- README de-staled; CLAUDE.md gains EN/NL + forms-one-idiom + lint/CI notes.
- Stories: text-input, link, data-row, site-header, site-footer, change-request-form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 08:25:51 +02:00
parent cf570a8132
commit d08f3877f7
35 changed files with 1803 additions and 145 deletions

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

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

View File

@@ -17,6 +17,7 @@ through an NSwag-generated typed client. The FE renders the backend's decisions.
```bash ```bash
npm start # ng serve (proxies /api → backend) → 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 npm run gen:api # regenerate the typed client from the backend OpenAPI doc
@@ -114,16 +115,29 @@ 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.
- **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)

View File

@@ -6,20 +6,35 @@ register of healthcare professionals, run by CIBG). It looks like an NL Design S
app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where 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.)

View File

@@ -48,7 +48,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,

View File

@@ -51,7 +51,17 @@ cd backend && dotnet test # rule unit tests + endpoint integration tests
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) | | POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) | | POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) |
Rejections use **ProblemDetails (RFC 7807)** with status **422**. 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/`) ## Where the rules live (`src/BigRegister.Api/Domain/`)

View File

@@ -51,5 +51,6 @@ public sealed record IntakePolicyDto(int ScholingThreshold);
public sealed record RegistratieRequest(string DiplomaHerkomst); public sealed record RegistratieRequest(string DiplomaHerkomst);
public sealed record IntakeRequest(int Uren); public sealed record IntakeRequest(int Uren);
public sealed record HerregistratieRequest(int Uren); public sealed record HerregistratieRequest(int Uren);
public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats);
public sealed record ReferentieResponse(string Referentie); public sealed record ReferentieResponse(string Referentie);

View File

@@ -1,3 +1,5 @@
using System.Text.RegularExpressions;
namespace BigRegister.Domain.Submissions; namespace BigRegister.Domain.Submissions;
/// <summary> /// <summary>
@@ -17,6 +19,18 @@ public static class SubmissionRules
public static string? RejectZeroUren(int uren) => public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null; 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() => public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally. // ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000); "BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);

View File

@@ -29,7 +29,13 @@ app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseCors(SpaCors); app.UseCors(SpaCors);
var api = app.MapGroup("/api"); // 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. --- // --- GET: screen-shaped reads. Decisions are computed here, never on the client. ---
@@ -58,37 +64,49 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
// --- POST: submits. The server is the authority; it re-validates and decides. --- // --- POST: submits. The server is the authority; it re-validates and decides. ---
api.MapPost("/registrations", (RegistratieRequest req) => api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
{ Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst)))
var reject = SubmissionRules.RejectRegistratie(req.DiplomaHerkomst);
return reject is null
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
})
.Produces<ReferentieResponse>() .Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity); .ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/herregistraties", (HerregistratieRequest req) => api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
{ Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren)))
var reject = SubmissionRules.RejectZeroUren(req.Uren);
return reject is null
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
})
.Produces<ReferentieResponse>() .Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity); .ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/intakes", (IntakeRequest req) => api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
{ Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren)))
var reject = SubmissionRules.RejectZeroUren(req.Uren); .Produces<ReferentieResponse>()
return reject is null .ProducesProblem(StatusCodes.Status422UnprocessableEntity);
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
}) Submit(ctx, "adreswijziging", SubmissionRules.RejectChangeRequest(req.Straat, req.Postcode)))
.Produces<ReferentieResponse>() .Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity); .ProducesProblem(StatusCodes.Status422UnprocessableEntity);
app.Run(); app.Run();
// 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)
{
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);
}
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. // Exposed so the integration tests can spin up the app with WebApplicationFactory.
public partial class Program { } public partial class Program { }

View File

@@ -5,7 +5,31 @@
"version": "v1" "version": "v1"
}, },
"paths": { "paths": {
"/api/dashboard-view": { "/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": { "get": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -24,7 +48,7 @@
} }
} }
}, },
"/api/notes": { "/api/v1/notes": {
"get": { "get": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -46,7 +70,7 @@
} }
} }
}, },
"/api/brp/address": { "/api/v1/brp/address": {
"get": { "get": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -65,7 +89,7 @@
} }
} }
}, },
"/api/duo/diplomas": { "/api/v1/duo/diplomas": {
"get": { "get": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -84,7 +108,7 @@
} }
} }
}, },
"/api/intake/policy": { "/api/v1/intake/policy": {
"get": { "get": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -103,7 +127,7 @@
} }
} }
}, },
"/api/registrations": { "/api/v1/registrations": {
"post": { "post": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -142,7 +166,7 @@
} }
} }
}, },
"/api/herregistraties": { "/api/v1/herregistraties": {
"post": { "post": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -181,7 +205,7 @@
} }
} }
}, },
"/api/intakes": { "/api/v1/intakes": {
"post": { "post": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -219,6 +243,45 @@
} }
} }
} }
},
"/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"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@@ -271,6 +334,24 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"ChangeRequestRequest": {
"type": "object",
"properties": {
"straat": {
"type": "string",
"nullable": true
},
"postcode": {
"type": "string",
"nullable": true
},
"woonplaats": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"DashboardViewDto": { "DashboardViewDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -12,7 +12,7 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
[Fact] [Fact]
public async Task DashboardView_computes_eligibility_decision() public async Task DashboardView_computes_eligibility_decision()
{ {
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/dashboard-view"); var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
Assert.NotNull(dto); Assert.NotNull(dto);
Assert.Equal("19012345601", dto.Registration.BigNummer); Assert.Equal("19012345601", dto.Registration.BigNummer);
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag); Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
@@ -24,14 +24,14 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
[Fact] [Fact]
public async Task Notes_returns_seeded_aantekeningen() public async Task Notes_returns_seeded_aantekeningen()
{ {
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/notes"); var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
Assert.Equal(3, notes!.Count); Assert.Equal(3, notes!.Count);
} }
[Fact] [Fact]
public async Task Brp_returns_address() public async Task Brp_returns_address()
{ {
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/brp/address"); var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
Assert.True(dto!.Gevonden); Assert.True(dto!.Gevonden);
Assert.Equal("2514 EA", dto.Adres!.Postcode); Assert.Equal("2514 EA", dto.Adres!.Postcode);
} }
@@ -39,7 +39,7 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
[Fact] [Fact]
public async Task Duo_lookup_carries_server_decided_questions_and_professions() public async Task Duo_lookup_carries_server_decided_questions_and_professions()
{ {
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/duo/diplomas"); var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
Assert.NotNull(dto); Assert.NotNull(dto);
var english = dto.Diplomas.Single(d => d.Id == "d2"); var english = dto.Diplomas.Single(d => d.Id == "d2");
@@ -59,14 +59,14 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
[Fact] [Fact]
public async Task IntakePolicy_returns_scholing_threshold() public async Task IntakePolicy_returns_scholing_threshold()
{ {
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/intake/policy"); var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
Assert.Equal(1000, dto!.ScholingThreshold); Assert.Equal(1000, dto!.ScholingThreshold);
} }
[Fact] [Fact]
public async Task Registration_with_duo_diploma_succeeds() public async Task Registration_with_duo_diploma_succeeds()
{ {
var res = await _client.PostAsJsonAsync("/api/registrations", new RegistratieRequest("duo")); var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>(); var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie); Assert.StartsWith("BIG-2026-", body!.Referentie);
@@ -75,14 +75,14 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
[Fact] [Fact]
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details() public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
{ {
var res = await _client.PostAsJsonAsync("/api/registrations", new RegistratieRequest("handmatig")); var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString()); Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
} }
[Theory] [Theory]
[InlineData("/api/intakes")] [InlineData("/api/v1/intakes")]
[InlineData("/api/herregistraties")] [InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route) public async Task Zero_hours_submission_is_rejected(string route)
{ {
var res = await _client.PostAsJsonAsync(route, new { uren = 0 }); var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
@@ -90,11 +90,36 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
} }
[Theory] [Theory]
[InlineData("/api/intakes")] [InlineData("/api/v1/intakes")]
[InlineData("/api/herregistraties")] [InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route) public async Task Worked_hours_submission_succeeds(string route)
{ {
var res = await _client.PostAsJsonAsync(route, new { uren = 40 }); var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
res.EnsureSuccessStatusCode(); 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();
}
} }

View File

@@ -114,4 +114,11 @@ public class SubmissionRuleTests
[Fact] [Fact]
public void Worked_hours_are_accepted() => public void Worked_hours_are_accepted() =>
Assert.Null(SubmissionRules.RejectZeroUren(40)); 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));
} }

84
eslint.config.mjs Normal file
View File

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

819
package-lock.json generated
View File

@@ -32,11 +32,13 @@
"@storybook/addon-docs": "^10.4.6", "@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6", "@storybook/angular": "^10.4.6",
"eslint": "^10.6.0",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"nswag": "^14.7.1", "nswag": "^14.7.1",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"storybook": "^10.4.6", "storybook": "^10.4.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.62.0",
"vitest": "^4.0.8" "vitest": "^4.0.8"
} }
}, },
@@ -3746,6 +3748,113 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
"integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@exodus/bytes": { "node_modules/@exodus/bytes": {
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
@@ -3795,6 +3904,72 @@
"hono": "^4" "hono": "^4"
} }
}, },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.22"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@inquirer/ansi": { "node_modules/@inquirer/ansi": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
@@ -8224,6 +8399,13 @@
"@types/estree": "*" "@types/estree": "*"
} }
}, },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -8396,6 +8578,236 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
"integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.62.0",
"@typescript-eslint/type-utils": "8.62.0",
"@typescript-eslint/utils": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.62.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz",
"integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.0",
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
"integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.62.0",
"@typescript-eslint/types": "^8.62.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
"integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
"integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
"integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0",
"@typescript-eslint/utils": "8.62.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
"integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.62.0",
"@typescript-eslint/tsconfig-utils": "8.62.0",
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/visitor-keys": "8.62.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
"integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.62.0",
"@typescript-eslint/types": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@utrecht/accordion-css": { "node_modules/@utrecht/accordion-css": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@utrecht/accordion-css/-/accordion-css-3.0.1.tgz", "resolved": "https://registry.npmjs.org/@utrecht/accordion-css/-/accordion-css-3.0.1.tgz",
@@ -9723,6 +10135,16 @@
"acorn": "^8.14.0" "acorn": "^8.14.0"
} }
}, },
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adjust-sourcemap-loader": { "node_modules/adjust-sourcemap-loader": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz",
@@ -11457,6 +11879,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -11949,6 +12378,78 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
"dev": true,
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.6.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.2.0",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -11963,6 +12464,90 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": { "node_modules/esprima": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -11977,6 +12562,29 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"estraverse": "^5.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esquery/node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esrecurse": { "node_modules/esrecurse": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -12273,6 +12881,20 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-string-truncated-width": { "node_modules/fast-string-truncated-width": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
@@ -12358,6 +12980,19 @@
} }
} }
}, },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -12420,6 +13055,27 @@
"flat": "cli.js" "flat": "cli.js"
} }
}, },
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -13424,6 +14080,16 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/ignore-walk": { "node_modules/ignore-walk": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz",
@@ -13475,6 +14141,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/indent-string": { "node_modules/indent-string": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -13940,6 +14616,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": { "node_modules/json-parse-even-better-errors": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz",
@@ -13964,6 +14647,13 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -14017,6 +14707,16 @@
"source-map-support": "^0.5.5" "source-map-support": "^0.5.5"
} }
}, },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": { "node_modules/kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -14102,6 +14802,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/license-webpack-plugin": { "node_modules/license-webpack-plugin": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz",
@@ -14964,6 +15678,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true,
"license": "MIT"
},
"node_modules/needle": { "node_modules/needle": {
"version": "3.5.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz",
@@ -15427,6 +16148,24 @@
"opencollective-postinstall": "index.js" "opencollective-postinstall": "index.js"
} }
}, },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
"levn": "^0.4.1",
"prelude-ls": "^1.2.1",
"type-check": "^0.4.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/ora": { "node_modules/ora": {
"version": "9.4.0", "version": "9.4.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz",
@@ -16253,6 +16992,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.8.4", "version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
@@ -18545,6 +19294,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-dedent": { "node_modules/ts-dedent": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
@@ -18684,6 +19446,19 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@@ -18738,6 +19513,30 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.62.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz",
"integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@typescript-eslint/typescript-estree": "8.62.0",
"@typescript-eslint/utils": "8.62.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uglify-js": { "node_modules/uglify-js": {
"version": "3.19.3", "version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -18887,6 +19686,16 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -20067,6 +20876,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/wordwrap": { "node_modules/wordwrap": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",

View File

@@ -3,6 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"lint": "eslint .",
"start": "ng serve --proxy-config proxy.conf.json", "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", "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",
@@ -39,11 +40,13 @@
"@storybook/addon-docs": "^10.4.6", "@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6", "@storybook/angular": "^10.4.6",
"eslint": "^10.6.0",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"nswag": "^14.7.1", "nswag": "^14.7.1",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"storybook": "^10.4.6", "storybook": "^10.4.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.62.0",
"vitest": "^4.0.8" "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.",
@@ -54,6 +57,7 @@
"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" }
} }
} }

View File

@@ -1,4 +1,4 @@
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, LOCALE_ID, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router'; import { 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';
@@ -14,7 +14,9 @@ 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(), provideApiClient(),
{ provide: LOCALE_ID, useValue: 'nl' }, { provide: LOCALE_ID, useValue: 'nl' },
] ]

View File

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

View File

@@ -1,4 +1,4 @@
import { Component, computed, effect, inject, input, resource, untracked } 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';
@@ -22,6 +22,7 @@ import {
} from '@herregistratie/domain/intake.machine'; } from '@herregistratie/domain/intake.machine';
import { submitIntake } from '@herregistratie/application/submit-intake'; import { submitIntake } from '@herregistratie/application/submit-intake';
import { ApiClient } from '@shared/infrastructure/api-client'; 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' }]; const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
@@ -111,12 +112,12 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
export class IntakeWizardComponent { export class IntakeWizardComponent {
private profile = inject(BigProfileStore); private profile = inject(BigProfileStore);
private apiClient = inject(ApiClient); 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 from the backend // Server-owned policy: the scholing threshold is fetched from the backend, not
// (`GET /api/intake/policy`), not hardcoded. The backend stays the authority // hardcoded. The backend stays the authority and re-validates on submit.
// and re-validates on submit. private policyRes = this.policy.policyResource();
private policyRes = resource({ loader: () => this.apiClient.policy() });
/** 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);

View File

@@ -0,0 +1,22 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { Valid } from '@registratie/domain/change-request.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { problemDetail } from '@shared/infrastructure/api-error';
/**
* 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 async function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
try {
const res = await client.changeRequests({
straat: data.straat,
postcode: data.postcode,
woonplaats: data.woonplaats,
});
return ok(res.referentie ?? '');
} catch (e) {
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
}
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { State, reduce, initial } from './change-request.machine';
const editingWith = (draft: Partial<{ straat: string; postcode: string; woonplaats: string }>): State => ({
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '', ...draft },
errors: {},
});
describe('change-request reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
expect(s.tag).toBe('Editing');
expect((s as Extract<State, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
});
it('Submit with an invalid draft stays Editing and reports field errors', () => {
const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
const errors = (s as Extract<State, { tag: 'Editing' }>).errors;
expect(errors.straat).toBeTruthy();
expect(errors.postcode).toBeTruthy();
});
it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => {
const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
});
it('confirms and fails only from Submitting; Retry re-submits a failure', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' });
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});

View File

@@ -0,0 +1,82 @@
import { Result, assertNever } from '@shared/kernel/fp';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
straat: string;
postcode: string;
woonplaats: string;
}
/** After parsing — postcode is the branded type, so downstream can't get a raw one. */
export interface Valid {
straat: string;
postcode: Postcode;
woonplaats: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/**
* The change-request (adreswijziging) form as one tagged union — the SAME idiom
* as the wizards, just single-step. `draft`/`errors` exist only while Editing;
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
* an invalid draft, a success screen with errors) are unrepresentable.
*/
export type State =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid; referentie: string }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: State = {
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '' },
errors: {},
};
/** Parse via the value objects; on success hand back a Valid, else per-field errors. */
function validate(draft: Draft): Result<Errors, Valid> {
const straat = draft.straat.trim();
const postcode = parsePostcode(draft.postcode);
const errors: Errors = {};
if (!straat) errors.straat = 'Vul straat en huisnummer in.';
if (!postcode.ok) errors.postcode = postcode.error;
if (straat && postcode.ok) {
return { ok: true, value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() } };
}
return { ok: false, error: errors };
}
export type Msg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)
export function reduce(s: State, m: Msg): State {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}

View File

@@ -1,60 +1,93 @@
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 { 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">
<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 }"
[errors]="{ straat: streetError(), postcode: zipError() }"
(fieldChange)="onAdres($event)" />
<div style="margin-top:1rem"> <div style="margin-top:1rem">
<app-button type="submit" variant="primary">Wijziging indienen</app-button> <app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })">Nieuwe wijziging doorgeven</app-button>
</div> </div>
</form> } @else {
<app-heading [level]="2">Adreswijziging doorgeven</app-heading>
<form (ngSubmit)="onSubmit()" style="max-width:28rem">
<app-address-fields
idPrefix="cr"
[value]="adres()"
[errors]="errors()"
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" />
@if (failedError()) {
<div style="margin-top:1rem"><app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert></div>
}
<div style="margin-top:1rem">
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }}
</app-button>
</div>
</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;
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<State, { tag: 'Editing' }>) : null));
protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {});
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<State, { tag: 'Failed' }>).error : ''));
protected referentie = computed(() => (this.state().tag === 'Submitted' ? (this.state() as Extract<State, { tag: '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 });
} }
} }

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { ChangeRequestFormComponent } from './change-request-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const validData = { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' };
const meta: Meta<ChangeRequestFormComponent> = {
title: 'Organisms/Change Request Form',
component: ChangeRequestFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
};
export default meta;
type Story = StoryObj<ChangeRequestFormComponent>;
// One render per state of the machine.
export const Empty: Story = { args: { seed: { tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '' }, errors: {} } } };
export const WithErrors: Story = {
args: {
seed: {
tag: 'Editing',
draft: { straat: '', postcode: 'nope', woonplaats: '' },
errors: { straat: 'Vul straat en huisnummer in.', postcode: 'Voer een geldige postcode in, bijv. 1234 AB.' },
},
},
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData, referentie: 'BIG-2026-123456' } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };

View File

@@ -1,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,7 +9,7 @@ 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: `
@@ -25,16 +24,11 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
</app-async> </app-async>
<div style="margin-top:2rem"> <div style="margin-top:2rem">
@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);
} }

View File

@@ -1,31 +1,48 @@
import { Provider } from '@angular/core'; import { Provider } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom, timeout, TimeoutError } from 'rxjs';
import { ApiClient, ProblemDetails } from './api-client'; 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 * Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
* client expects, so every API call flows through HttpClient interceptors the * client expects, so every API call flows through HttpClient interceptors (the
* `?scenario=` toggle (scenario.interceptor.ts) and any future auth header. The * `?scenario=` toggle) and the cross-cutting concerns below. The generated client
* generated client is the only place HTTP shapes are known; this is the only * is the only place HTTP shapes are known; this is the only place it meets
* place it meets Angular's HTTP stack. * 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) { function httpClientFetch(http: HttpClient) {
return { return {
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> { async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase(); const method = (init?.method ?? 'GET').toUpperCase();
const headers = (init?.headers ?? {}) as Record<string, string>; 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 { try {
const res = await firstValueFrom( const res = await firstValueFrom(
http.request(method, url as string, { http
body: init?.body as string | undefined, .request(method, url as string, {
headers, body: init?.body as string | undefined,
observe: 'response', headers,
responseType: 'text', observe: 'response',
}), responseType: 'text',
})
.pipe(timeout(REQUEST_TIMEOUT_MS)),
); );
return new Response(res.body ?? '', { status: res.status || 200 }); return new Response(res.body ?? '', { status: res.status || 200 });
} catch (e) { } catch (e) {
if (e instanceof TimeoutError) return new Response('', { status: 504 });
const err = e as HttpErrorResponse; const err = e as HttpErrorResponse;
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {}); const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
// ponytail: clamp to a Response-constructible status (an aborted/interceptor // ponytail: clamp to a Response-constructible status (an aborted/interceptor
@@ -37,11 +54,12 @@ function httpClientFetch(http: HttpClient) {
}; };
} }
/** Provide a root ApiClient that talks through HttpClient (relative `/api` base URL). */ /** 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 { export function provideApiClient(): Provider {
return { return {
provide: ApiClient, provide: ApiClient,
useFactory: (http: HttpClient) => new ApiClient('', httpClientFetch(http)), useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
deps: [HttpClient], deps: [HttpClient],
}; };
} }

View File

@@ -17,11 +17,77 @@ export class ApiClient {
this.baseUrl = baseUrl ?? ""; 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 * @return OK
*/ */
dashboardView(): Promise<DashboardViewDto> { dashboardView(): Promise<DashboardViewDto> {
let url_ = this.baseUrl + "/api/dashboard-view"; let url_ = this.baseUrl + "/api/v1/dashboard-view";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = { let options_: RequestInit = {
@@ -57,7 +123,7 @@ export class ApiClient {
* @return OK * @return OK
*/ */
notes(): Promise<AantekeningDto[]> { notes(): Promise<AantekeningDto[]> {
let url_ = this.baseUrl + "/api/notes"; let url_ = this.baseUrl + "/api/v1/notes";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = { let options_: RequestInit = {
@@ -93,7 +159,7 @@ export class ApiClient {
* @return OK * @return OK
*/ */
address(): Promise<BrpAddressDto> { address(): Promise<BrpAddressDto> {
let url_ = this.baseUrl + "/api/brp/address"; let url_ = this.baseUrl + "/api/v1/brp/address";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = { let options_: RequestInit = {
@@ -129,7 +195,7 @@ export class ApiClient {
* @return OK * @return OK
*/ */
diplomas(): Promise<DuoLookupDto> { diplomas(): Promise<DuoLookupDto> {
let url_ = this.baseUrl + "/api/duo/diplomas"; let url_ = this.baseUrl + "/api/v1/duo/diplomas";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = { let options_: RequestInit = {
@@ -165,7 +231,7 @@ export class ApiClient {
* @return OK * @return OK
*/ */
policy(): Promise<IntakePolicyDto> { policy(): Promise<IntakePolicyDto> {
let url_ = this.baseUrl + "/api/intake/policy"; let url_ = this.baseUrl + "/api/v1/intake/policy";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = { let options_: RequestInit = {
@@ -201,7 +267,7 @@ export class ApiClient {
* @return OK * @return OK
*/ */
registrations(body: RegistratieRequest): Promise<ReferentieResponse> { registrations(body: RegistratieRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/registrations"; let url_ = this.baseUrl + "/api/v1/registrations";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body); const content_ = JSON.stringify(body);
@@ -247,7 +313,7 @@ export class ApiClient {
* @return OK * @return OK
*/ */
herregistraties(body: HerregistratieRequest): Promise<ReferentieResponse> { herregistraties(body: HerregistratieRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/herregistraties"; let url_ = this.baseUrl + "/api/v1/herregistraties";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body); const content_ = JSON.stringify(body);
@@ -293,7 +359,7 @@ export class ApiClient {
* @return OK * @return OK
*/ */
intakes(body: IntakeRequest): Promise<ReferentieResponse> { intakes(body: IntakeRequest): Promise<ReferentieResponse> {
let url_ = this.baseUrl + "/api/intakes"; let url_ = this.baseUrl + "/api/v1/intakes";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body); const content_ = JSON.stringify(body);
@@ -334,6 +400,52 @@ export class ApiClient {
} }
return Promise.resolve<ReferentieResponse>(null as any); 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);
}
} }
export interface AantekeningDto { export interface AantekeningDto {
@@ -353,6 +465,12 @@ export interface BrpAddressDto {
adres?: AdresDto; adres?: AdresDto;
} }
export interface ChangeRequestRequest {
straat?: string | undefined;
postcode?: string | undefined;
woonplaats?: string | undefined;
}
export interface DashboardViewDto { export interface DashboardViewDto {
registration?: RegistrationDto; registration?: RegistrationDto;
person?: PersonDto; person?: PersonDto;

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, isDevMode } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component'; import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component'; import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
@@ -27,7 +27,11 @@ import { DebugStateComponent } from '@shared/ui/debug-state/debug-state.componen
</main> </main>
<app-site-footer /> <app-site-footer />
</div> </div>
<app-debug-state /> @if (isDev) {
<app-debug-state />
}
`, `,
}) })
export class ShellComponent {} export class ShellComponent {
protected readonly isDev = isDevMode();
}

View File

@@ -0,0 +1,12 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { SiteFooterComponent } from './site-footer.component';
const meta: Meta<SiteFooterComponent> = {
title: 'Layout/Site Footer',
component: SiteFooterComponent,
render: () => ({ template: `<app-site-footer />` }),
};
export default meta;
type Story = StoryObj<SiteFooterComponent>;
export const Default: Story = {};

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { SiteHeaderComponent } from './site-header.component';
const meta: Meta<SiteHeaderComponent> = {
title: 'Layout/Site Header',
component: SiteHeaderComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
template: `<app-site-header [subtitle]="subtitle" />`,
}),
args: { subtitle: 'Mijn omgeving' },
};
export default meta;
type Story = StoryObj<SiteHeaderComponent>;
export const Default: Story = {};

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { DataRowComponent } from './data-row.component';
const meta: Meta<DataRowComponent> = {
title: 'Molecules/Data Row',
component: DataRowComponent,
render: (args) => ({
props: args,
// Rows live inside an RHC data-summary list (dt/dd); wrap so it renders in context.
template: `<dl class="rhc-data-summary"><app-data-row [key]="key" [value]="value" /></dl>`,
}),
args: { key: 'BIG-nummer', value: '19012345601' },
};
export default meta;
type Story = StoryObj<DataRowComponent>;
export const Default: Story = {};
export const Empty: Story = { args: { key: 'Tweede naam', value: '' } };

View File

@@ -3,7 +3,8 @@ import { JsonPipe } from '@angular/common';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
import { Session } from '@auth/domain/session'; import { Session } from '@auth/domain/session';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { maskBsn } from './mask'; import { map } from '@shared/application/remote-data';
import { maskBsn, redactProfile } from './mask';
/** /**
* Dev-only "show the current Model" panel (Elm-debugger style, read-only). * Dev-only "show the current Model" panel (Elm-debugger style, read-only).
@@ -48,9 +49,11 @@ export class DebugStateComponent {
// on construction, so we must not instantiate it until the dev asks to look. // on construction, so we must not instantiate it until the dev asks to look.
private profileStore?: BigProfileStore; private profileStore?: BigProfileStore;
// PII is redacted/masked here (see mask.ts): the panel inspects state SHAPE,
// never personal data — a deliberate habit for a PII-handling app.
protected readonly snapshot = computed(() => ({ protected readonly snapshot = computed(() => ({
session: maskSession(this.session.session()), session: maskSession(this.session.session()),
profile: this.profileStore?.profile(), profile: this.profileStore ? map(this.profileStore.profile(), redactProfile) : undefined,
decisions: this.profileStore?.decisions(), decisions: this.profileStore?.decisions(),
aantekeningen: this.profileStore?.aantekeningen(), aantekeningen: this.profileStore?.aantekeningen(),
pendingHerregistratie: this.profileStore?.pendingHerregistratie(), pendingHerregistratie: this.profileStore?.pendingHerregistratie(),

View File

@@ -1,5 +1,34 @@
import { BigProfile } from '@registratie/domain/big-profile';
const REDACTED = 'redacted';
/** Keep the last `keep` characters, mask the rest. */
function maskTail(value: string, keep: number): string {
if (value.length <= keep) return '*'.repeat(value.length);
return '*'.repeat(value.length - keep) + value.slice(-keep);
}
/** Redact a BSN for the dev state view: keep the last 3 digits, mask the rest. */ /** Redact a BSN for the dev state view: keep the last 3 digits, mask the rest. */
export function maskBsn(bsn: string): string { export function maskBsn(bsn: string): string {
if (bsn.length <= 3) return '*'.repeat(bsn.length); return maskTail(bsn, 3);
return '*'.repeat(bsn.length - 3) + bsn.slice(-3); }
/**
* Data minimisation for the dev "show the Model" panel: keep the structural /
* decision-relevant fields (status, beroep, dates of registration) but redact
* direct personal identifiers (name, address, date of birth) and mask the BIG
* number. The panel is for inspecting state SHAPE, never for reading PII.
*/
export function redactProfile(p: BigProfile): unknown {
return {
registration: {
bigNummer: maskTail(p.registration.bigNummer, 3),
naam: REDACTED,
beroep: p.registration.beroep,
registratiedatum: p.registration.registratiedatum,
geboortedatum: REDACTED,
status: p.registration.status,
},
person: { naam: REDACTED, geboortedatum: REDACTED, adres: REDACTED },
};
} }

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { LinkComponent } from './link.component';
const meta: Meta<LinkComponent> = {
title: 'Atoms/Link',
component: LinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
template: `<app-link [to]="to">Naar het dashboard</app-link>`,
}),
args: { to: '/dashboard' },
};
export default meta;
type Story = StoryObj<LinkComponent>;
export const Default: Story = {};

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { TextInputComponent } from './text-input.component';
const meta: Meta<TextInputComponent> = {
title: 'Atoms/Text Input',
component: TextInputComponent,
render: (args) => ({
props: args,
template: `<app-text-input [type]="type" [placeholder]="placeholder" [invalid]="invalid" [inputId]="inputId" />`,
}),
args: { inputId: 'demo', placeholder: 'Bijv. 1234 AB' },
};
export default meta;
type Story = StoryObj<TextInputComponent>;
export const Default: Story = {};
export const Invalid: Story = { args: { invalid: true } };
export const Password: Story = { args: { type: 'password', placeholder: '' } };

View File

@@ -0,0 +1,10 @@
/**
* Production environment. `apiBaseUrl` stays relative ('') for a same-origin /
* reverse-proxy deployment; set it to the API origin (e.g. 'https://api.example.nl')
* when the SPA and backend are served from different hosts. This is the single
* place the deployed API location is configured.
*/
export const environment = {
production: true,
apiBaseUrl: '',
};

View File

@@ -0,0 +1,10 @@
/**
* Default (development) environment. `apiBaseUrl` is empty so requests are
* relative to the current origin — in dev the ng-serve proxy forwards /api to the
* backend (proxy.conf.json). Swapped for environment.prod.ts in production builds
* (angular.json fileReplacements).
*/
export const environment = {
production: false,
apiBaseUrl: '',
};