diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..a978b78
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 16e7123..c430636 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,6 +17,7 @@ through an NSwag-generated typed client. The FE renders the backend's decisions.
```bash
npm start # ng serve (proxies /api → backend) → http://localhost:4200
npm test # vitest
+npm run lint # eslint — enforces `any`-free code + import/layer boundaries
npm run build # ng build (must stay green)
npm run storybook # component library by atomic layer
npm run gen:api # regenerate the typed client from the backend OpenAPI doc
@@ -114,16 +115,29 @@ not heavy component tests.
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
constructor DI (constructor only for `effect()`/template-ref injection).
-- Angular-native control flow `@if/@for`; native `httpResource` for fetching;
- `withViewTransitions()` for page transitions (header/footer have stable
- `view-transition-name`, excluded from the fade).
+- Angular-native control flow `@if/@for`; fetch via `resource({ loader })` over the
+ generated `ApiClient` inside an `infrastructure/*.adapter.ts` (one place HTTP lives),
+ with a `parse*` boundary; `withViewTransitions()` for page transitions (header/footer
+ have stable `view-transition-name`, excluded from the fade).
+- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
+ `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
+ `*.machine.ts`). Pick the language by which side of the seam the code is on.
+- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
+ (Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
+ same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
+ fields + ad-hoc error signals.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`).
- Theming is one import — `src/styles.scss` pulls the RHC palette; no hand-written theme.
-- Scenario toggle: `?scenario=slow|loading|empty|error` on data pages
- (`scenario.interceptor.ts`) to see every async state.
+- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
+ on data pages (`scenario.interceptor.ts`) to see every async state.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
+- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
+ on `any` and on illegal imports — `domain/` importing Angular, or a context importing
+ "upward" (the `herregistratie → registratie → shared`, `auth → shared` direction).
+ CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend
+ `dotnet test`, and an API-client drift check.
## Adding a feature (recipe)
diff --git a/README.md b/README.md
index c79091c..86416df 100644
--- a/README.md
+++ b/README.md
@@ -6,20 +6,35 @@ register of healthcare professionals, run by CIBG). It looks like an NL Design S
app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where
the UI can never reach an inconsistent state.
-> Demo / POC — no real data, no real login. Free **Fira Sans** stands in for the
-> licensed Rijksoverheid font and a text wordmark for the logo.
+> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
+> business rules and data *are* served by a real **ASP.NET Core backend**
+> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
+> is demonstrable, not hand-waved. Free **Fira Sans** stands in for the licensed
+> Rijksoverheid font and a text wordmark for the logo.
---
## Run it
+```bash
+docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
+```
+
+Or run the two halves separately:
+
```bash
npm install
-npm start # app → http://localhost:4200
-npm run storybook # component library, organized by atomic layer
+npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
+# in another terminal:
+cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
+
+npm run storybook # component library, organized by atomic layer
+npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
```
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
+The backend hosts the business rules (profession derivation, policy questions,
+eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
> appear based on earlier answers and progress survives a page reload, plus a visual
@@ -96,9 +111,12 @@ import to re-theme the whole app — no component changes.
## State management (no impossible states)
-Data fetching uses Angular's native, signal-based **`httpResource`** (no NgRx,
-no extra dependency). `core/registration.service.ts` exposes resources that carry
-`status()`, `value()`, `error()`, `hasValue()` and `reload()` as signals.
+Data fetching uses Angular's native, signal-based **`resource`** over the generated
+typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
+exposes a resource that carries `status()`, `value()`, `error()` and `reload()` as
+signals, and a `parse*` function validates the response at the trust boundary
+(DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed
+decisions the FE renders rather than recomputes (see ADR-0001).
The molecule **``** turns those signals into UI. It renders **exactly one** of
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
@@ -142,7 +160,10 @@ degrade to an instant navigation.
control flow `@if/@for`).
- Styling: `@rijkshuisstijl-community/{design-tokens,components-css}` (Utrecht + RHC CSS,
pre-themed Rijkshuisstijl) — imported in `src/styles.scss`, no hand-written theme.
-- Mock data: JSON in `public/mock/`, timing/outcome shaped by `core/scenario.interceptor.ts`.
+- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
+ contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
+ The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
+ **dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified).
@@ -157,4 +178,5 @@ Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC)
-Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl fonts/logo.
+Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
+NgRx, licensed Rijkshuisstijl fonts/logo. (The backend itself *is* implemented.)
diff --git a/angular.json b/angular.json
index a74f761..aa0e737 100644
--- a/angular.json
+++ b/angular.json
@@ -48,7 +48,13 @@
"maximumError": "8kB"
}
],
- "outputHashing": "all"
+ "outputHashing": "all",
+ "fileReplacements": [
+ {
+ "replace": "src/environments/environment.ts",
+ "with": "src/environments/environment.prod.ts"
+ }
+ ]
},
"development": {
"optimization": false,
diff --git a/backend/README.md b/backend/README.md
index 80f91d6..7392dff 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -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/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/`)
diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs
index 5b2df82..dd30bf7 100644
--- a/backend/src/BigRegister.Api/Contracts/Dtos.cs
+++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs
@@ -51,5 +51,6 @@ public sealed record IntakePolicyDto(int ScholingThreshold);
public sealed record RegistratieRequest(string DiplomaHerkomst);
public sealed record IntakeRequest(int Uren);
public sealed record HerregistratieRequest(int Uren);
+public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats);
public sealed record ReferentieResponse(string Referentie);
diff --git a/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs
index 42557b8..de4940b 100644
--- a/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs
+++ b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs
@@ -1,3 +1,5 @@
+using System.Text.RegularExpressions;
+
namespace BigRegister.Domain.Submissions;
///
@@ -17,6 +19,18 @@ public static class SubmissionRules
public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
+ private static readonly Regex PostcodePattern =
+ new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ // RULE: a change request needs a street and a well-formed Dutch postcode. The
+ // server re-validates format authoritatively (the FE check is UX-only).
+ public static string? RejectChangeRequest(string straat, string postcode)
+ {
+ if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
+ if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
+ return null;
+ }
+
public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index 5730da8..0549c30 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -29,7 +29,13 @@ app.UseSwagger();
app.UseSwaggerUI();
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. ---
@@ -58,37 +64,49 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
// --- POST: submits. The server is the authority; it re-validates and decides. ---
-api.MapPost("/registrations", (RegistratieRequest req) =>
-{
- var reject = SubmissionRules.RejectRegistratie(req.DiplomaHerkomst);
- return reject is null
- ? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
- : Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
-})
+api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
+ Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst)))
.Produces()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
-api.MapPost("/herregistraties", (HerregistratieRequest req) =>
-{
- var reject = SubmissionRules.RejectZeroUren(req.Uren);
- return reject is null
- ? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
- : Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
-})
+api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
+ Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren)))
.Produces()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
-api.MapPost("/intakes", (IntakeRequest req) =>
-{
- var reject = SubmissionRules.RejectZeroUren(req.Uren);
- return reject is null
- ? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
- : Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
-})
+api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
+ Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren)))
+.Produces()
+.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
+
+api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
+ Submit(ctx, "adreswijziging", SubmissionRules.RejectChangeRequest(req.Straat, req.Postcode)))
.Produces()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
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.
public partial class Program { }
diff --git a/backend/swagger.json b/backend/swagger.json
index 21ab64b..e30611d 100644
--- a/backend/swagger.json
+++ b/backend/swagger.json
@@ -5,7 +5,31 @@
"version": "v1"
},
"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": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -24,7 +48,7 @@
}
}
},
- "/api/notes": {
+ "/api/v1/notes": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -46,7 +70,7 @@
}
}
},
- "/api/brp/address": {
+ "/api/v1/brp/address": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -65,7 +89,7 @@
}
}
},
- "/api/duo/diplomas": {
+ "/api/v1/duo/diplomas": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -84,7 +108,7 @@
}
}
},
- "/api/intake/policy": {
+ "/api/v1/intake/policy": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -103,7 +127,7 @@
}
}
},
- "/api/registrations": {
+ "/api/v1/registrations": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -142,7 +166,7 @@
}
}
},
- "/api/herregistraties": {
+ "/api/v1/herregistraties": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -181,7 +205,7 @@
}
}
},
- "/api/intakes": {
+ "/api/v1/intakes": {
"post": {
"tags": [
"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": {
@@ -271,6 +334,24 @@
},
"additionalProperties": false
},
+ "ChangeRequestRequest": {
+ "type": "object",
+ "properties": {
+ "straat": {
+ "type": "string",
+ "nullable": true
+ },
+ "postcode": {
+ "type": "string",
+ "nullable": true
+ },
+ "woonplaats": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "additionalProperties": false
+ },
"DashboardViewDto": {
"type": "object",
"properties": {
diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs
index 2cb5e36..973ed87 100644
--- a/backend/tests/BigRegister.Tests/EndpointTests.cs
+++ b/backend/tests/BigRegister.Tests/EndpointTests.cs
@@ -12,7 +12,7 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task DashboardView_computes_eligibility_decision()
{
- var dto = await _client.GetFromJsonAsync("/api/dashboard-view");
+ var dto = await _client.GetFromJsonAsync("/api/v1/dashboard-view");
Assert.NotNull(dto);
Assert.Equal("19012345601", dto.Registration.BigNummer);
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
@@ -24,14 +24,14 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Notes_returns_seeded_aantekeningen()
{
- var notes = await _client.GetFromJsonAsync>("/api/notes");
+ var notes = await _client.GetFromJsonAsync>("/api/v1/notes");
Assert.Equal(3, notes!.Count);
}
[Fact]
public async Task Brp_returns_address()
{
- var dto = await _client.GetFromJsonAsync("/api/brp/address");
+ var dto = await _client.GetFromJsonAsync("/api/v1/brp/address");
Assert.True(dto!.Gevonden);
Assert.Equal("2514 EA", dto.Adres!.Postcode);
}
@@ -39,7 +39,7 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
{
- var dto = await _client.GetFromJsonAsync("/api/duo/diplomas");
+ var dto = await _client.GetFromJsonAsync("/api/v1/duo/diplomas");
Assert.NotNull(dto);
var english = dto.Diplomas.Single(d => d.Id == "d2");
@@ -59,14 +59,14 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task IntakePolicy_returns_scholing_threshold()
{
- var dto = await _client.GetFromJsonAsync("/api/intake/policy");
+ var dto = await _client.GetFromJsonAsync("/api/v1/intake/policy");
Assert.Equal(1000, dto!.ScholingThreshold);
}
[Fact]
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();
var body = await res.Content.ReadFromJsonAsync();
Assert.StartsWith("BIG-2026-", body!.Referentie);
@@ -75,14 +75,14 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu
[Fact]
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.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
}
[Theory]
- [InlineData("/api/intakes")]
- [InlineData("/api/herregistraties")]
+ [InlineData("/api/v1/intakes")]
+ [InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
@@ -90,11 +90,36 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu
}
[Theory]
- [InlineData("/api/intakes")]
- [InlineData("/api/herregistraties")]
+ [InlineData("/api/v1/intakes")]
+ [InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
res.EnsureSuccessStatusCode();
}
+
+ [Fact]
+ public async Task Change_request_with_valid_address_succeeds()
+ {
+ var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
+ new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
+ res.EnsureSuccessStatusCode();
+ var body = await res.Content.ReadFromJsonAsync();
+ 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();
+ }
}
diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs
index c77c7a1..c7408b0 100644
--- a/backend/tests/BigRegister.Tests/RuleTests.cs
+++ b/backend/tests/BigRegister.Tests/RuleTests.cs
@@ -114,4 +114,11 @@ public class SubmissionRuleTests
[Fact]
public void Worked_hours_are_accepted() =>
Assert.Null(SubmissionRules.RejectZeroUren(40));
+
+ [Theory]
+ [InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
+ [InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
+ [InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
+ public void Change_request_is_validated(string straat, string postcode, string? expected) =>
+ Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
}
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..a7c4fd1
--- /dev/null
+++ b/eslint.config.mjs
@@ -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.' }] },
+ ],
+ },
+ },
+];
diff --git a/package-lock.json b/package-lock.json
index 4e99026..5d80276 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,11 +32,13 @@
"@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6",
+ "eslint": "^10.6.0",
"jsdom": "^29.0.0",
"nswag": "^14.7.1",
"prettier": "^3.8.1",
"storybook": "^10.4.6",
"typescript": "~6.0.2",
+ "typescript-eslint": "^8.62.0",
"vitest": "^4.0.8"
}
},
@@ -3746,6 +3748,113 @@
"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": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
@@ -3795,6 +3904,72 @@
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
@@ -8224,6 +8399,13 @@
"@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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -8396,6 +8578,236 @@
"@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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@utrecht/accordion-css/-/accordion-css-3.0.1.tgz",
@@ -9723,6 +10135,16 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz",
@@ -11457,6 +11879,13 @@
"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": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -11949,6 +12378,78 @@
"dev": true,
"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": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -11963,6 +12464,90 @@
"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": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -11977,6 +12562,29 @@
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -12273,6 +12881,20 @@
"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": {
"version": "3.0.3",
"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": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -12420,6 +13055,27 @@
"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": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -13424,6 +14080,16 @@
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz",
@@ -13475,6 +14141,16 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -13940,6 +14616,13 @@
"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": {
"version": "5.0.0",
"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,
"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": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -14017,6 +14707,16 @@
"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": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -14102,6 +14802,20 @@
"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": {
"version": "4.0.2",
"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_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": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz",
@@ -15427,6 +16148,24 @@
"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": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz",
@@ -16253,6 +16992,16 @@
"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": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
@@ -18545,6 +19294,19 @@
"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": {
"version": "2.3.0",
"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_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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@@ -18738,6 +19513,30 @@
"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": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -18887,6 +19686,16 @@
"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": {
"version": "1.6.0",
"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"
}
},
+ "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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
diff --git a/package.json b/package.json
index f869f15..86e9168 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
+ "lint": "eslint .",
"start": "ng serve --proxy-config proxy.conf.json",
"gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json",
"build": "ng build",
@@ -39,11 +40,13 @@
"@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6",
+ "eslint": "^10.6.0",
"jsdom": "^29.0.0",
"nswag": "^14.7.1",
"prettier": "^3.8.1",
"storybook": "^10.4.6",
"typescript": "~6.0.2",
+ "typescript-eslint": "^8.62.0",
"vitest": "^4.0.8"
},
"comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.",
@@ -54,6 +57,7 @@
"ajv": "^8.20.0",
"webpack-dev-server": "^5.2.5",
"sockjs": "^0.3.24",
- "uuid": "^11.1.1"
+ "uuid": "^11.1.1",
+ "eslint": { "ajv": "^6.12.6" }
}
}
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 86e6b2c..b87f087 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -1,4 +1,4 @@
-import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
+import { ApplicationConfig, LOCALE_ID, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
@@ -14,7 +14,9 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withViewTransitions()),
- provideHttpClient(withInterceptors([scenarioInterceptor])),
+ // Dev-only: the ?scenario= toggle must never reach a production build, where
+ // a query param could otherwise force errors on the live app.
+ provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor] : [])),
provideApiClient(),
{ provide: LOCALE_ID, useValue: 'nl' },
]
diff --git a/src/app/herregistratie/infrastructure/intake-policy.adapter.ts b/src/app/herregistratie/infrastructure/intake-policy.adapter.ts
new file mode 100644
index 0000000..863c745
--- /dev/null
+++ b/src/app/herregistratie/infrastructure/intake-policy.adapter.ts
@@ -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() });
+ }
+}
diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
index 6b5d0dc..4ad8ffa 100644
--- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
+++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
@@ -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 { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
@@ -22,6 +22,7 @@ import {
} from '@herregistratie/domain/intake.machine';
import { submitIntake } from '@herregistratie/application/submit-intake';
import { ApiClient } from '@shared/infrastructure/api-client';
+import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter';
const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration.
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
@@ -111,12 +112,12 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private apiClient = inject(ApiClient);
+ private policy = inject(IntakePolicyAdapter);
private store = createStore(initial, reduce);
- // Server-owned policy: the scholing threshold is fetched from the backend
- // (`GET /api/intake/policy`), not hardcoded. The backend stays the authority
- // and re-validates on submit.
- private policyRes = resource({ loader: () => this.apiClient.policy() });
+ // Server-owned policy: the scholing threshold is fetched from the backend, not
+ // hardcoded. The backend stays the authority and re-validates on submit.
+ private policyRes = this.policy.policyResource();
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input(initial);
diff --git a/src/app/registratie/application/submit-change-request.ts b/src/app/registratie/application/submit-change-request.ts
new file mode 100644
index 0000000..dd20bdc
--- /dev/null
+++ b/src/app/registratie/application/submit-change-request.ts
@@ -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> {
+ 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.'));
+ }
+}
diff --git a/src/app/registratie/domain/change-request.machine.spec.ts b/src/app/registratie/domain/change-request.machine.spec.ts
new file mode 100644
index 0000000..5e6423d
--- /dev/null
+++ b/src/app/registratie/domain/change-request.machine.spec.ts
@@ -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).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).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).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);
+ });
+});
diff --git a/src/app/registratie/domain/change-request.machine.ts b/src/app/registratie/domain/change-request.machine.ts
new file mode 100644
index 0000000..dffd646
--- /dev/null
+++ b/src/app/registratie/domain/change-request.machine.ts
@@ -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>;
+
+/**
+ * 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 {
+ 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);
+ }
+}
diff --git a/src/app/registratie/ui/change-request-form/change-request-form.component.ts b/src/app/registratie/ui/change-request-form/change-request-form.component.ts
index f83807a..5b6fc1e 100644
--- a/src/app/registratie/ui/change-request-form/change-request-form.component.ts
+++ b/src/app/registratie/ui/change-request-form/change-request-form.component.ts
@@ -1,60 +1,93 @@
-import { Component, output, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
+import { Component, computed, inject, input } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
-import { AddressFieldsComponent, AdresValue } from '@registratie/ui/address-fields/address-fields.component';
-import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
+import { AlertComponent } from '@shared/ui/alert/alert.component';
+import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component';
+import { createStore } from '@shared/application/store';
+import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
+import { submitChangeRequest } from '@registratie/application/submit-change-request';
+import { ApiClient } from '@shared/infrastructure/api-client';
-/** A submitted change request carries a *parsed* postcode (branded Postcode),
- not a raw string — downstream code can't receive an unvalidated one. */
-export interface ChangeRequest {
- street: string;
- zip: Postcode;
- city: string;
-}
-
-/** Organism: change-request (adreswijziging) form. Reuses the same form-field
- molecule + text-input/button atoms as the login form. Field errors come
- straight from the parser's Result — no parallel "is it valid" flag to drift. */
+/**
+ * Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
+ * wizards — all state in one signal driven by the pure `reduce`
+ * (change-request.machine.ts), submitted via a `submit-*` command returning
+ * `Result`. Renders the shared ``; the server re-validates.
+ */
@Component({
selector: 'app-change-request-form',
- imports: [FormsModule, ButtonComponent, HeadingComponent, AddressFieldsComponent],
+ imports: [ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
template: `
- Adreswijziging doorgeven
-
+ } @else {
+ Adreswijziging doorgeven
+
+ }
`,
})
export class ChangeRequestFormComponent {
- street = '';
- zip = '';
- city = '';
- streetError = signal('');
- zipError = signal('');
- submitted = output();
+ private apiClient = inject(ApiClient);
+ private store = createStore(initial, reduce);
- onAdres(e: { key: keyof AdresValue; value: string }) {
- if (e.key === 'straat') this.street = e.value;
- else if (e.key === 'postcode') this.zip = e.value;
- else this.city = e.value;
+ /** Optional seed so Storybook / tests can mount any state directly. */
+ seed = input(initial);
+
+ readonly state = this.store.model;
+ protected dispatch = this.store.dispatch;
+
+ private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null));
+ protected errors = computed(() => this.editing()?.errors ?? {});
+ protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : ''));
+ protected referentie = computed(() => (this.state().tag === 'Submitted' ? (this.state() as Extract).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(() => {
+ const s = this.state();
+ if (s.tag === 'Editing') return s.draft;
+ if (s.tag === 'Submitting' || s.tag === 'Failed') {
+ return { straat: s.data.straat, postcode: s.data.postcode, woonplaats: s.data.woonplaats };
+ }
+ return { straat: '', postcode: '', woonplaats: '' }; // Submitted shows the success alert, not the fields
+ });
+
+ constructor() {
+ queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
}
onSubmit() {
- const street = this.street.trim();
- this.streetError.set(street ? '' : 'Vul straat en huisnummer in.');
+ this.dispatch({ tag: 'Submit' });
+ this.runIfSubmitting();
+ }
- const postcode = parsePostcode(this.zip);
- this.zipError.set(postcode.ok ? '' : postcode.error);
-
- if (!street || !postcode.ok) return;
- this.submitted.emit({ street, zip: postcode.value, city: this.city.trim() });
+ /** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
+ private async runIfSubmitting() {
+ const s = this.state();
+ if (s.tag !== 'Submitting') return;
+ const r = await submitChangeRequest(this.apiClient, s.data);
+ if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
+ else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}
diff --git a/src/app/registratie/ui/change-request-form/change-request-form.stories.ts b/src/app/registratie/ui/change-request-form/change-request-form.stories.ts
new file mode 100644
index 0000000..0b68e5a
--- /dev/null
+++ b/src/app/registratie/ui/change-request-form/change-request-form.stories.ts
@@ -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 = {
+ 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;
+
+// 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' } } };
diff --git a/src/app/registratie/ui/registration-detail.page.ts b/src/app/registratie/ui/registration-detail.page.ts
index 4db9db5..60aa606 100644
--- a/src/app/registratie/ui/registration-detail.page.ts
+++ b/src/app/registratie/ui/registration-detail.page.ts
@@ -1,6 +1,5 @@
-import { Component, inject, signal } from '@angular/core';
+import { Component, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
-import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
@@ -10,7 +9,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
@Component({
selector: 'app-registration-detail-page',
imports: [
- PageShellComponent, AlertComponent, SkeletonComponent, ...ASYNC,
+ PageShellComponent, SkeletonComponent, ...ASYNC,
RegistrationSummaryComponent, ChangeRequestFormComponent,
],
template: `
@@ -25,16 +24,11 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
- @if (submitted()) {
-
Uw adreswijziging is ontvangen. U ontvangt binnen 5 werkdagen bericht.
- } @else {
-
- }
+
`,
})
export class RegistrationDetailPage {
protected store = inject(BigProfileStore);
- submitted = signal(false);
}
diff --git a/src/app/shared/infrastructure/api-client.provider.ts b/src/app/shared/infrastructure/api-client.provider.ts
index de4a978..5cf8662 100644
--- a/src/app/shared/infrastructure/api-client.provider.ts
+++ b/src/app/shared/infrastructure/api-client.provider.ts
@@ -1,31 +1,48 @@
import { Provider } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
-import { firstValueFrom } from 'rxjs';
+import { firstValueFrom, timeout, TimeoutError } from 'rxjs';
import { ApiClient, ProblemDetails } from './api-client';
+import { environment } from '../../../environments/environment';
+
+/** Single place every API call passes through: the seam for cross-cutting concerns. */
+const REQUEST_TIMEOUT_MS = 10_000;
/**
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
- * client expects, so every API call flows through HttpClient interceptors — the
- * `?scenario=` toggle (scenario.interceptor.ts) and any future auth header. The
- * generated client is the only place HTTP shapes are known; this is the only
- * place it meets Angular's HTTP stack.
+ * client expects, so every API call flows through HttpClient interceptors (the
+ * `?scenario=` toggle) and the cross-cutting concerns below. The generated client
+ * is the only place HTTP shapes are known; this is the only place it meets
+ * Angular's HTTP stack — i.e. the one seam to add:
+ * - timeout (done — REQUEST_TIMEOUT_MS),
+ * - correlation id (done — X-Correlation-Id, echoed in backend logs),
+ * - idempotency key for writes (done — Idempotency-Key; a real retry would thread
+ * a STABLE key per logical submit so re-sends dedupe; here it's per-attempt),
+ * - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
+ * - retry/backoff: wrap the pipe with rxjs `retry({ count, delay })` here.
*/
function httpClientFetch(http: HttpClient) {
return {
async fetch(url: RequestInfo, init?: RequestInit): Promise {
const method = (init?.method ?? 'GET').toUpperCase();
- const headers = (init?.headers ?? {}) as Record;
+ const headers: Record = {
+ ...((init?.headers ?? {}) as Record),
+ 'X-Correlation-Id': crypto.randomUUID(),
+ };
+ if (method !== 'GET') headers['Idempotency-Key'] = crypto.randomUUID();
try {
const res = await firstValueFrom(
- http.request(method, url as string, {
- body: init?.body as string | undefined,
- headers,
- observe: 'response',
- responseType: 'text',
- }),
+ http
+ .request(method, url as string, {
+ body: init?.body as string | undefined,
+ headers,
+ observe: 'response',
+ responseType: 'text',
+ })
+ .pipe(timeout(REQUEST_TIMEOUT_MS)),
);
return new Response(res.body ?? '', { status: res.status || 200 });
} catch (e) {
+ if (e instanceof TimeoutError) return new Response('', { status: 504 });
const err = e as HttpErrorResponse;
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
@@ -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 {
return {
provide: ApiClient,
- useFactory: (http: HttpClient) => new ApiClient('', httpClientFetch(http)),
+ useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
deps: [HttpClient],
};
}
diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts
index 5d4f07b..4029faa 100644
--- a/src/app/shared/infrastructure/api-client.ts
+++ b/src/app/shared/infrastructure/api-client.ts
@@ -17,11 +17,77 @@ export class ApiClient {
this.baseUrl = baseUrl ?? "";
}
+ /**
+ * @return OK
+ */
+ health(): Promise {
+ 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 {
+ 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(null as any);
+ }
+
+ /**
+ * @return OK
+ */
+ ready(): Promise {
+ 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 {
+ 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(null as any);
+ }
+
/**
* @return OK
*/
dashboardView(): Promise {
- let url_ = this.baseUrl + "/api/dashboard-view";
+ let url_ = this.baseUrl + "/api/v1/dashboard-view";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
@@ -57,7 +123,7 @@ export class ApiClient {
* @return OK
*/
notes(): Promise {
- let url_ = this.baseUrl + "/api/notes";
+ let url_ = this.baseUrl + "/api/v1/notes";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
@@ -93,7 +159,7 @@ export class ApiClient {
* @return OK
*/
address(): Promise {
- let url_ = this.baseUrl + "/api/brp/address";
+ let url_ = this.baseUrl + "/api/v1/brp/address";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
@@ -129,7 +195,7 @@ export class ApiClient {
* @return OK
*/
diplomas(): Promise {
- let url_ = this.baseUrl + "/api/duo/diplomas";
+ let url_ = this.baseUrl + "/api/v1/duo/diplomas";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
@@ -165,7 +231,7 @@ export class ApiClient {
* @return OK
*/
policy(): Promise {
- let url_ = this.baseUrl + "/api/intake/policy";
+ let url_ = this.baseUrl + "/api/v1/intake/policy";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
@@ -201,7 +267,7 @@ export class ApiClient {
* @return OK
*/
registrations(body: RegistratieRequest): Promise {
- let url_ = this.baseUrl + "/api/registrations";
+ let url_ = this.baseUrl + "/api/v1/registrations";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
@@ -247,7 +313,7 @@ export class ApiClient {
* @return OK
*/
herregistraties(body: HerregistratieRequest): Promise {
- let url_ = this.baseUrl + "/api/herregistraties";
+ let url_ = this.baseUrl + "/api/v1/herregistraties";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
@@ -293,7 +359,7 @@ export class ApiClient {
* @return OK
*/
intakes(body: IntakeRequest): Promise {
- let url_ = this.baseUrl + "/api/intakes";
+ let url_ = this.baseUrl + "/api/v1/intakes";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
@@ -334,6 +400,52 @@ export class ApiClient {
}
return Promise.resolve(null as any);
}
+
+ /**
+ * @return OK
+ */
+ changeRequests(body: ChangeRequestRequest): Promise {
+ 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 {
+ 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(null as any);
+ }
}
export interface AantekeningDto {
@@ -353,6 +465,12 @@ export interface BrpAddressDto {
adres?: AdresDto;
}
+export interface ChangeRequestRequest {
+ straat?: string | undefined;
+ postcode?: string | undefined;
+ woonplaats?: string | undefined;
+}
+
export interface DashboardViewDto {
registration?: RegistrationDto;
person?: PersonDto;
diff --git a/src/app/shared/layout/shell/shell.component.ts b/src/app/shared/layout/shell/shell.component.ts
index d881d5c..883c6dc 100644
--- a/src/app/shared/layout/shell/shell.component.ts
+++ b/src/app/shared/layout/shell/shell.component.ts
@@ -1,4 +1,4 @@
-import { Component } from '@angular/core';
+import { Component, isDevMode } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.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
-
+ @if (isDev) {
+
+ }
`,
})
-export class ShellComponent {}
+export class ShellComponent {
+ protected readonly isDev = isDevMode();
+}
diff --git a/src/app/shared/layout/site-footer/site-footer.stories.ts b/src/app/shared/layout/site-footer/site-footer.stories.ts
new file mode 100644
index 0000000..e99a2d7
--- /dev/null
+++ b/src/app/shared/layout/site-footer/site-footer.stories.ts
@@ -0,0 +1,12 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { SiteFooterComponent } from './site-footer.component';
+
+const meta: Meta = {
+ title: 'Layout/Site Footer',
+ component: SiteFooterComponent,
+ render: () => ({ template: `` }),
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/app/shared/layout/site-header/site-header.stories.ts b/src/app/shared/layout/site-header/site-header.stories.ts
new file mode 100644
index 0000000..1e9f49b
--- /dev/null
+++ b/src/app/shared/layout/site-header/site-header.stories.ts
@@ -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 = {
+ title: 'Layout/Site Header',
+ component: SiteHeaderComponent,
+ decorators: [applicationConfig({ providers: [provideRouter([])] })],
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+ args: { subtitle: 'Mijn omgeving' },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/app/shared/ui/data-row/data-row.stories.ts b/src/app/shared/ui/data-row/data-row.stories.ts
new file mode 100644
index 0000000..327d3f4
--- /dev/null
+++ b/src/app/shared/ui/data-row/data-row.stories.ts
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { DataRowComponent } from './data-row.component';
+
+const meta: Meta = {
+ 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: `
`,
+ }),
+ args: { key: 'BIG-nummer', value: '19012345601' },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Empty: Story = { args: { key: 'Tweede naam', value: '' } };
diff --git a/src/app/shared/ui/debug-state/debug-state.component.ts b/src/app/shared/ui/debug-state/debug-state.component.ts
index 2747a72..07f9600 100644
--- a/src/app/shared/ui/debug-state/debug-state.component.ts
+++ b/src/app/shared/ui/debug-state/debug-state.component.ts
@@ -3,7 +3,8 @@ import { JsonPipe } from '@angular/common';
import { SessionStore } from '@auth/application/session.store';
import { Session } from '@auth/domain/session';
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).
@@ -48,9 +49,11 @@ export class DebugStateComponent {
// on construction, so we must not instantiate it until the dev asks to look.
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(() => ({
session: maskSession(this.session.session()),
- profile: this.profileStore?.profile(),
+ profile: this.profileStore ? map(this.profileStore.profile(), redactProfile) : undefined,
decisions: this.profileStore?.decisions(),
aantekeningen: this.profileStore?.aantekeningen(),
pendingHerregistratie: this.profileStore?.pendingHerregistratie(),
diff --git a/src/app/shared/ui/debug-state/mask.ts b/src/app/shared/ui/debug-state/mask.ts
index d66a518..111513c 100644
--- a/src/app/shared/ui/debug-state/mask.ts
+++ b/src/app/shared/ui/debug-state/mask.ts
@@ -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. */
export function maskBsn(bsn: string): string {
- if (bsn.length <= 3) return '*'.repeat(bsn.length);
- return '*'.repeat(bsn.length - 3) + bsn.slice(-3);
+ return maskTail(bsn, 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 },
+ };
}
diff --git a/src/app/shared/ui/link/link.stories.ts b/src/app/shared/ui/link/link.stories.ts
new file mode 100644
index 0000000..d92cf02
--- /dev/null
+++ b/src/app/shared/ui/link/link.stories.ts
@@ -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 = {
+ title: 'Atoms/Link',
+ component: LinkComponent,
+ decorators: [applicationConfig({ providers: [provideRouter([])] })],
+ render: (args) => ({
+ props: args,
+ template: `Naar het dashboard`,
+ }),
+ args: { to: '/dashboard' },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/app/shared/ui/text-input/text-input.stories.ts b/src/app/shared/ui/text-input/text-input.stories.ts
new file mode 100644
index 0000000..9db1837
--- /dev/null
+++ b/src/app/shared/ui/text-input/text-input.stories.ts
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { TextInputComponent } from './text-input.component';
+
+const meta: Meta = {
+ title: 'Atoms/Text Input',
+ component: TextInputComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+ args: { inputId: 'demo', placeholder: 'Bijv. 1234 AB' },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Invalid: Story = { args: { invalid: true } };
+export const Password: Story = { args: { type: 'password', placeholder: '' } };
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
new file mode 100644
index 0000000..ba89d95
--- /dev/null
+++ b/src/environments/environment.prod.ts
@@ -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: '',
+};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
new file mode 100644
index 0000000..d84e348
--- /dev/null
+++ b/src/environments/environment.ts
@@ -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: '',
+};