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 -
- + @if (state().tag === 'Submitted') { + + Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht. +
- Wijziging indienen + Nieuwe wijziging doorgeven
- + } @else { + Adreswijziging doorgeven +
+ + + @if (failedError()) { +
Het indienen is niet gelukt: {{ failedError() }}
+ } + +
+ + {{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }} + +
+ + } `, }) 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: '', +};