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 6223513..6f7cc3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,15 +8,21 @@ update this file. POC of a Dutch BIG-register self-service portal (healthcare professionals log in, view their registration, apply for re-registration). Angular 22, standalone, -signals. No real backend/auth — static mock JSON + fake timers. +signals. Auth is faked; **data and business rules are served by a minimal ASP.NET +Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed +through an NSwag-generated typed client. The FE renders the backend's decisions. ## Commands ```bash -npm start # ng serve → http://localhost:4200 +npm start # ng serve (proxies /api → backend) → http://localhost:4200 npm test # vitest +npm run lint # eslint — enforces `any`-free code + import/layer boundaries npm run build # ng build (must stay green) npm run storybook # component library by atomic layer +npm run gen:api # regenerate the typed client from the backend OpenAPI doc +docker compose up # run FE + backend together (Swagger at :5000/swagger) +cd backend && dotnet test # backend rule + endpoint tests ``` `.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22). @@ -109,16 +115,36 @@ not heavy component tests. - Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over constructor DI (constructor only for `effect()`/template-ref injection). -- Angular-native control flow `@if/@for`; native `httpResource` for fetching; - `withViewTransitions()` for page transitions (header/footer have stable - `view-transition-name`, excluded from the fade). +- Angular-native control flow `@if/@for`; fetch via `resource({ loader })` over the + generated `ApiClient` inside an `infrastructure/*.adapter.ts` (one place HTTP lives), + with a `parse*` boundary; `withViewTransitions()` for page transitions (header/footer + have stable `view-transition-name`, excluded from the fade). +- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`, + `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`, + `*.machine.ts`). Pick the language by which side of the seam the code is on. +- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's + first-party `$localize` (no third-party i18n lib), with a stable custom id + (`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a + translation file, not a code change (the seam). Shared/English components must **not** + hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller + supplies the text (see `shared/ui/async`). Format-validation messages in + `domain/value-objects/` stay co-located but are still `$localize`-wrapped. +- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts` + (Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the + same shape as the wizards, whether it's one step or many. Don't hand-roll mutable + fields + ad-hoc error signals. - Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate: [authGuard]` on protected routes (`app.routes.ts`). - Theming is one import — `src/styles.scss` pulls the RHC palette; no hand-written theme. -- Scenario toggle: `?scenario=slow|loading|empty|error` on data pages - (`scenario.interceptor.ts`) to see every async state. +- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error` + on data pages (`scenario.interceptor.ts`) to see every async state. - Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`, `noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`. +- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build + on `any` and on illegal imports — `domain/` importing Angular, or a context importing + "upward" (the `herregistratie → registratie → shared`, `auth → shared` direction). + CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend + `dotnet test`, and an API-client drift check. ## Adding a feature (recipe) @@ -129,5 +155,5 @@ dispatch messages). Worked example: the intake wizard (`herregistratie/`). ## Out of scope (POC, don't build unprompted) -Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo, -runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen. +Real auth/DigiD, NgRx, licensed Rijkshuisstijl font/logo, +runtime DTO validation on every endpoint, multi-tab session sync. 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..d1eecd6 100644 --- a/angular.json +++ b/angular.json @@ -30,9 +30,8 @@ "input": "public" } ], - "styles": [ - "src/styles.scss" - ] + "styles": ["src/styles.scss"], + "polyfills": ["@angular/localize/init"] }, "configurations": { "production": { @@ -48,7 +47,13 @@ "maximumError": "8kB" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] }, "development": { "optimization": false, @@ -79,12 +84,7 @@ "configDir": ".storybook", "browserTarget": "atomic-design-poc:build", "compodoc": true, - "compodocArgs": [ - "-e", - "json", - "-d", - "." - ], + "compodocArgs": ["-e", "json", "-d", "."], "port": 6006 } }, @@ -94,16 +94,11 @@ "configDir": ".storybook", "browserTarget": "atomic-design-poc:build", "compodoc": true, - "compodocArgs": [ - "-e", - "json", - "-d", - "." - ], + "compodocArgs": ["-e", "json", "-d", "."], "outputDir": "storybook-static" } } } } } -} \ No newline at end of file +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/backend/BigRegister.slnx b/backend/BigRegister.slnx new file mode 100644 index 0000000..7dc239f --- /dev/null +++ b/backend/BigRegister.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7392dff --- /dev/null +++ b/backend/README.md @@ -0,0 +1,112 @@ +# BIG-register BFF (ASP.NET Core) + +The backend that hosts the **business rules** for the BIG-register portal. The +frontend renders the decisions this service computes; it does not recompute them +(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`). + +No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`), +but the endpoints, DTOs, status codes and error envelope are production-shaped. + +## Run + +### Everything (docker-compose, from repo root) + +```bash +docker compose up +``` + +- App: +- Swagger UI: + +### Backend only (local) + +```bash +cd backend +dotnet run --project src/BigRegister.Api +# → http://localhost:5000/swagger +``` + +### Frontend against a local backend + +```bash +npm start # ng serve, proxies /api → http://localhost:5000 (proxy.conf.json) +``` + +### Tests + +```bash +cd backend && dotnet test # rule unit tests + endpoint integration tests +``` + +## API + +| Method | Route | Purpose | +|---|---|---| +| GET | `/api/dashboard-view` | registration + person + computed herregistratie decision | +| GET | `/api/notes` | specialisms / aantekeningen | +| GET | `/api/brp/address` | BRP address lookup (`gevonden:false` = no address) | +| GET | `/api/duo/diplomas` | diplomas with derived profession + applicable policy questions, + manual fallback | +| GET | `/api/intake/policy` | scholing threshold (config value) | +| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) | +| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) | +| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) | + +Rejections use **ProblemDetails (RFC 7807)** with status **422**. Every request +carries an `X-Correlation-Id` (set by the FE fetch adapter); the backend echoes it +into a no-PII submit-audit log line (`kind`, `outcome`, `reference`, correlation id) +— the seam for real structured logging / an audit store. + +### Versioning + +Endpoints live under **`/api/v1`**. Additive changes (a new optional field) stay on +v1: the NSwag-generated client and the FE `parse*` boundary ignore unknown fields, +so old clients keep working. A breaking change (renamed/removed field, changed +semantics) is introduced as **`/api/v2`** served alongside v1 until clients migrate. + +## Where the rules live (`src/BigRegister.Api/Domain/`) + +- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply. +- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant. +- `Intake/IntakePolicy.cs` — scholing threshold. +- `Submissions/SubmissionRules.cs` — submit rejections + reference generation. + +## Typed client (NSwag) + +The frontend calls this API through a generated TypeScript client. Regenerate it +from the contract after a **shape** change: + +```bash +npm run gen:api # builds backend → swagger.json → src/app/shared/infrastructure/api-client.ts +``` + +## Maintainability: changing a policy is one backend change + +**Goal:** require every *Verpleegkundige* diploma to confirm a Dutch skills +assessment. This is a new policy question on a diploma type. + +Edit **one file** — `Domain/Diplomas/DiplomaRules.cs`: + +```diff + public static IReadOnlyList QuestionsFor(Diploma d) + { + var questions = new List(); + if (d.Engelstalig) + questions.Add(NlTaalEngelstalig); ++ if (d.Opleiding == "verpleegkunde") ++ questions.Add(new PolicyQuestion( ++ "bekwaamheid", ++ "Heeft u in de afgelopen vijf jaar een bekwaamheidstoets afgelegd?", ++ QuestionType.JaNee)); + return questions; + } +``` + +Rebuild the backend (`docker compose up` or `dotnet run`). The new question now +appears in the registration wizard for HBO-Verpleegkunde. + +- **No frontend change.** The FE renders whatever questions the API returns. +- **No client regeneration.** The wire shape (`PolicyQuestionDto`) is unchanged — + only the data behind it. `npm run gen:api` is only needed when a DTO *shape* changes. + +Add a unit test for the new rule in `tests/BigRegister.Tests/RuleTests.cs` and +you're done. diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json new file mode 100644 index 0000000..c5b7a30 --- /dev/null +++ b/backend/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "swashbuckle.aspnetcore.cli": { + "version": "10.2.3", + "commands": [ + "swagger" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/backend/src/BigRegister.Api/BigRegister.Api.csproj b/backend/src/BigRegister.Api/BigRegister.Api.csproj new file mode 100644 index 0000000..213738b --- /dev/null +++ b/backend/src/BigRegister.Api/BigRegister.Api.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs new file mode 100644 index 0000000..99ba29d --- /dev/null +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -0,0 +1,73 @@ +namespace BigRegister.Api.Contracts; + +// Wire contracts (the FE⇄BE seam). Field names + shapes mirror the frontend's +// contracts/*.dto.ts 1:1; the NSwag-generated TS client is produced from these. + +public sealed record AdresDto(string Straat, string Postcode, string Woonplaats); + +public sealed record RegistrationStatusDto( + string Tag, + string? HerregistratieDatum = null, + string? GeschorstTot = null, + string? Reden = null, + string? DoorgehaaldOp = null); + +public sealed record RegistrationDto( + string BigNummer, + string Naam, + string Beroep, + string Registratiedatum, + string Geboortedatum, + RegistrationStatusDto Status); + +public sealed record PersonDto(string Naam, string Geboortedatum, AdresDto Adres); + +public sealed record HerregistratieDecisionsDto(bool EligibleForHerregistratie, string? HerregistratieReason); + +public sealed record DashboardViewDto(RegistrationDto Registration, PersonDto Person, HerregistratieDecisionsDto Decisions); + +public sealed record AantekeningDto(string Type, string Omschrijving, string Datum); + +public sealed record BrpAddressDto(bool Gevonden, AdresDto? Adres); + +public sealed record PolicyQuestionDto(string Id, string Vraag, string Type); + +public sealed record DuoDiplomaDto( + string Id, + string Naam, + string Instelling, + int Jaar, + string Beroep, + IReadOnlyList PolicyQuestions); + +public sealed record ManualDiplomaPolicyDto(IReadOnlyList Beroepen, IReadOnlyList PolicyQuestions); + +public sealed record DuoLookupDto(IReadOnlyList Diplomas, ManualDiplomaPolicyDto Handmatig); + +public sealed record IntakePolicyDto(int ScholingThreshold); + +// --- Document upload contracts --- + +public sealed record DocumentCategoryDto( + string CategoryId, string Label, string Description, bool Required, + IReadOnlyList AcceptedTypes, int MaxSizeMb, bool Multiple, bool AllowPostDelivery); + +public sealed record UploadCategoriesDto(IReadOnlyList Categories); + +public sealed record UploadResponse(string DocumentId, string LocalId); + +public sealed record UploadStatusItemDto(string LocalId, string Status, string? DocumentId); +public sealed record UploadStatusDto(IReadOnlyList Results); + +// Per-category delivery intent on submit: digital categories carry their DocumentId, +// post-delivery categories carry Channel="post". +public sealed record DocumentRefDto(string CategoryId, string Channel, string? DocumentId = null); + +// Submit requests carry only the fields the server re-validates (UX-only fields +// stay on the client). ponytail: a real submit would carry the full application. +public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList? Documents = null); +public sealed record IntakeRequest(int Uren); +public sealed record HerregistratieRequest(int Uren, IReadOnlyList? Documents = null); +public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats); + +public sealed record ReferentieResponse(string Referentie); diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs new file mode 100644 index 0000000..2fb139d --- /dev/null +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -0,0 +1,37 @@ +using BigRegister.Domain.Diplomas; +using BigRegister.Domain.Documents; +using BigRegister.Domain.People; +using BigRegister.Domain.Registrations; + +namespace BigRegister.Api.Contracts; + +/// Domain → wire DTO mapping (the anti-corruption boundary, server side). +public static class Mappers +{ + private static string D(DateOnly d) => d.ToString("yyyy-MM-dd"); + + public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new( + Tag: s.Tag.ToString(), + HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null, + GeschorstTot: s.GeschorstTot is { } g ? D(g) : null, + Reden: s.Reden, + DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null); + + public static RegistrationDto ToDto(this Registration r) => new( + r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto()); + + public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats); + + public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto()); + + public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new( + q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst"); + + public static DuoDiplomaDto ToDto(this Diploma d) => new( + d.Id, d.Naam, d.Instelling, d.Jaar, + DiplomaRules.ProfessionFor(d), + DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList()); + + public static DocumentCategoryDto ToDto(this DocumentCategory c) => new( + c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery); +} diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs new file mode 100644 index 0000000..876afe6 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -0,0 +1,101 @@ +namespace BigRegister.Api.Data; + +/// +/// Stored document metadata. The demo deliberately keeps NO file content — only +/// metadata — which is enough for status/delete/link and avoids holding PII bytes +/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId. +/// +public sealed record StoredDocument( + string DocumentId, string LocalId, string CategoryId, string WizardId, + string FileName, long SizeBytes, string Owner, DateTimeOffset UploadedAt) +{ + public bool Linked { get; set; } +} + +public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor); + +/// +/// In-memory document store + audit log (no DB). ponytail: one global lock — fine +/// for a single-process demo store; swap for per-key locks if it ever serves load. +/// The audit log holds metadata only (never file content or other PII). +/// +public static class DocumentStore +{ + /// The single seeded user (the demo has no real auth; ownership = this id). + public const string DemoOwner = "19012345601"; + + private static readonly Dictionary _docs = new(); + private static readonly List _audit = new(); + private static readonly object _gate = new(); + + public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, long sizeBytes, string owner) + { + var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, sizeBytes, owner, DateTimeOffset.UtcNow); + lock (_gate) _docs[doc.DocumentId] = doc; + Audit("upload", doc.DocumentId, categoryId, owner); + return doc; + } + + public static StoredDocument? Get(string documentId) + { + lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null; + } + + /// Status for the poll-on-return pattern: a known localId is "complete" (it + /// arrived), an unknown one is still in flight / never started. + public static IReadOnlyList ByLocalIds(IEnumerable localIds) + { + var set = localIds.ToHashSet(); + lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList(); + } + + /// Mark digital documents as linked to a finalised submission (blocks user delete). + public static void Link(IEnumerable documentIds) + { + lock (_gate) + foreach (var id in documentIds) + if (_docs.TryGetValue(id, out var d)) d.Linked = true; + } + + public enum DeleteResult { Ok, NotFound, Linked } + + /// User delete: owner-scoped; blocked once linked to a finalised submission. + public static DeleteResult DeleteOwned(string documentId, string owner) + { + string categoryId; + lock (_gate) + { + if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound; + if (d.Linked) return DeleteResult.Linked; + categoryId = d.CategoryId; + _docs.Remove(documentId); + } + Audit("delete-user", documentId, categoryId, owner); + return DeleteResult.Ok; + } + + /// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for + /// review so a caseworker is notified. Returns false if the document is unknown. + public static bool AdminDelete(string documentId, string actor) + { + string categoryId; + lock (_gate) + { + if (!_docs.TryGetValue(documentId, out var d)) return false; + categoryId = d.CategoryId; + _docs.Remove(documentId); + } + Audit("delete-admin", documentId, categoryId, actor); + return true; + } + + public static void Audit(string action, string documentId, string categoryId, string actor) + { + lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor)); + } + + public static IReadOnlyList AuditLog + { + get { lock (_gate) return _audit.ToList(); } + } +} diff --git a/backend/src/BigRegister.Api/Data/SeedData.cs b/backend/src/BigRegister.Api/Data/SeedData.cs new file mode 100644 index 0000000..f0c6628 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/SeedData.cs @@ -0,0 +1,42 @@ +using BigRegister.Domain.Diplomas; +using BigRegister.Domain.People; +using BigRegister.Domain.Registrations; + +namespace BigRegister.Api.Data; + +/// +/// In-memory seeded synthetic data — no DB, no real BRP/DUO. Stands in for the +/// upstream systems so the API behaves like production for a demo. +/// +public static class SeedData +{ + public static readonly Registration Registration = new( + BigNummer: "19012345601", + Naam: "Dr. A. (Anna) de Vries", + Beroep: "Arts", + Registratiedatum: new DateOnly(2012, 9, 1), + Geboortedatum: new DateOnly(1985, 3, 14), + Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))); + + public static readonly Person Person = new( + Naam: "Dr. A. (Anna) de Vries", + Geboortedatum: new DateOnly(1985, 3, 14), + Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag")); + + /// The address BRP returns for the seeded citizen. + public static readonly Adres BrpAddress = Person.Adres; + + public static readonly IReadOnlyList Diplomas = new[] + { + new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false), + new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true), + new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false), + }; + + public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[] + { + ("Specialisme", "Huisartsgeneeskunde", "2016-04-12"), + ("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"), + ("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"), + }; +} diff --git a/backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs b/backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs new file mode 100644 index 0000000..8451329 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs @@ -0,0 +1,22 @@ +namespace BigRegister.Domain.Diplomas; + +public enum QuestionType +{ + JaNee, + Tekst, +} + +public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type); + +/// +/// A diploma as DUO knows it. The profession and the applicable policy questions +/// are NOT stored on the diploma — they are DERIVED by the rules below from its +/// attributes (, ). +/// +public sealed record Diploma( + string Id, + string Naam, + string Instelling, + int Jaar, + string Opleiding, + bool Engelstalig); diff --git a/backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs b/backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs new file mode 100644 index 0000000..f7fe999 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs @@ -0,0 +1,68 @@ +namespace BigRegister.Domain.Diplomas; + +/// +/// SERVER-OWNED business rules for diplomas. This is the single place a policy +/// changes: which profession a study program maps to, and which policy questions +/// (geldigheidsvragen) apply to a diploma. The frontend renders these; it never +/// derives them. +/// +public static class DiplomaRules +{ + // RULE: study program → BIG profession. + private static readonly Dictionary ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase) + { + ["geneeskunde"] = "Arts", + ["verpleegkunde"] = "Verpleegkundige", + ["fysiotherapie"] = "Fysiotherapeut", + ["farmacie"] = "Apotheker", + ["tandheelkunde"] = "Tandarts", + }; + + public static string ProfessionFor(Diploma d) => + ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend"; + + /// Professions a user may declare for a manual (unlisted) diploma. + public static IReadOnlyList ManualProfessions() => + ProfessionByProgram.Values.Distinct().ToList(); + + // --- Policy questions (geldigheidsvragen) --- + + private static readonly PolicyQuestion NlTaalEngelstalig = new( + "nl-taalvaardigheid", + "Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", + QuestionType.JaNee); + + private static readonly PolicyQuestion NlTaalManual = new( + "nl-taalvaardigheid", + "Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", + QuestionType.JaNee); + + private static readonly PolicyQuestion DiplomaErkend = new( + "diploma-erkend", + "Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?", + QuestionType.JaNee); + + private static readonly PolicyQuestion Toelichting = new( + "toelichting", + "Geef een korte toelichting op uw diploma en opleiding.", + QuestionType.Tekst); + + /// + /// RULE: an English-language diploma requires proof of Dutch proficiency (B2). + /// Add a question here to apply it to a (set of) diploma(s) — a single backend + /// change, no frontend change. + /// + public static IReadOnlyList QuestionsFor(Diploma d) + { + var questions = new List(); + if (d.Engelstalig) + questions.Add(NlTaalEngelstalig); + return questions; + } + + /// + /// RULE: a manual diploma is unverified, so the strictest (maximal) set applies. + /// + public static IReadOnlyList ManualQuestions() => + new[] { NlTaalManual, DiplomaErkend, Toelichting }; +} diff --git a/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs b/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs new file mode 100644 index 0000000..1ba9aa4 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs @@ -0,0 +1,59 @@ +namespace BigRegister.Domain.Documents; + +/// +/// SERVER-OWNED document category config. The frontend renders these; it never +/// hardcodes file types, size limits, labels, required-ness or the post-delivery +/// flag. Adding a new category or changing a rule is a backend-only change. +/// +public sealed record DocumentCategory( + string CategoryId, + string Label, + string Description, + bool Required, + IReadOnlyList AcceptedTypes, + int MaxSizeMb, + bool Multiple, + bool AllowPostDelivery); + +public static class DocumentRules +{ + private static readonly string[] Pdf = { "application/pdf" }; + private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" }; + + /// The categories that apply to a given wizard/step. + public static IReadOnlyList CategoriesFor(string wizardId) => wizardId switch + { + "registratie" => new[] + { + new DocumentCategory("diploma", "Diplomabewijs", + "Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false), + new DocumentCategory("identiteit", "Identiteitsbewijs", + "Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true), + }, + "herregistratie" => new[] + { + new DocumentCategory("werkervaring", "Bewijs van werkervaring", + "Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true), + new DocumentCategory("nascholing", "Nascholingscertificaten", + "Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true), + }, + _ => Array.Empty(), + }; + + public static DocumentCategory? Find(string wizardId, string categoryId) => + CategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId); + + /// + /// Authoritative upload validation (the client check is UX-only). Returns a + /// rejection reason, or null when the file is acceptable. + /// + public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes) + { + if (category is null) return "Onbekende documentcategorie."; + if (!category.AcceptedTypes.Contains(contentType)) + return $"Bestandstype niet toegestaan voor {category.Label}."; + if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024) + return $"Bestand is groter dan {category.MaxSizeMb} MB."; + return null; + } +} diff --git a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs new file mode 100644 index 0000000..f6d5bae --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs @@ -0,0 +1,11 @@ +namespace BigRegister.Domain.Intake; + +/// +/// SERVER-OWNED config value. Below this many NL work-hours the scholing question +/// is required. The frontend receives this value and applies it for instant UX +/// feedback, but the backend re-validates on submit as the authority. +/// +public static class IntakePolicy +{ + public const int ScholingThreshold = 1000; +} diff --git a/backend/src/BigRegister.Api/Domain/People/Person.cs b/backend/src/BigRegister.Api/Domain/People/Person.cs new file mode 100644 index 0000000..31f01c4 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/People/Person.cs @@ -0,0 +1,5 @@ +namespace BigRegister.Domain.People; + +public sealed record Adres(string Straat, string Postcode, string Woonplaats); + +public sealed record Person(string Naam, DateOnly Geboortedatum, Adres Adres); diff --git a/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs b/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs new file mode 100644 index 0000000..4565bef --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs @@ -0,0 +1,32 @@ +namespace BigRegister.Domain.Registrations; + +/// +/// SERVER-OWNED business rule (ported from the frontend reference impl +/// registration.policy.ts:isHerregistratieEligible). A registration may apply for +/// herregistratie only while active and within the window before its deadline. +/// The endpoint ships the result as a decision flag + reason; the frontend renders it. +/// +public static class HerregistratieRule +{ + public const int WindowMonths = 12; + + public static DateOnly? Deadline(Registration reg) => + reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null; + + public static (bool Eligible, string? Reason) Evaluate( + Registration reg, DateOnly today, int windowMonths = WindowMonths) + { + var deadline = Deadline(reg); + if (deadline is null) + return (false, "Geen actieve registratie."); + + var windowStart = deadline.Value.AddMonths(-windowMonths); + return today >= windowStart + ? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).") + : (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}."); + } + + /// Invariant: a non-active status must not carry a herregistratie date. + public static bool IsStatusConsistent(RegistrationStatus s) => + s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null; +} diff --git a/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs b/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs new file mode 100644 index 0000000..2b1515b --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs @@ -0,0 +1,28 @@ +namespace BigRegister.Domain.Registrations; + +/// The three states a BIG registration can be in. +public enum StatusTag +{ + Geregistreerd, + Geschorst, + Doorgehaald, +} + +/// +/// Status as a flat record: only carries a +/// herregistratie deadline. The frontend mirrors this as a discriminated union. +/// +public sealed record RegistrationStatus( + StatusTag Tag, + DateOnly? HerregistratieDatum = null, + DateOnly? GeschorstTot = null, + string? Reden = null, + DateOnly? DoorgehaaldOp = null); + +public sealed record Registration( + string BigNummer, + string Naam, + string Beroep, + DateOnly Registratiedatum, + DateOnly Geboortedatum, + RegistrationStatus Status); diff --git a/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs new file mode 100644 index 0000000..de4940b --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; + +namespace BigRegister.Domain.Submissions; + +/// +/// SERVER-OWNED submit rules (ported from the frontend submit-*.ts commands, where +/// they were hardcoded). Each method returns a rejection reason, or null when the +/// submission is accepted. The reference is generated server-side on acceptance. +/// +public static class SubmissionRules +{ + // RULE: a manually entered diploma cannot be auto-verified. + public static string? RejectRegistratie(string diplomaHerkomst) => + diplomaHerkomst == "handmatig" + ? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling." + : null; + + // RULE: an application reporting zero worked hours is rejected. + public static string? RejectZeroUren(int uren) => + uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null; + + private static readonly Regex PostcodePattern = + new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // RULE: a change request needs a street and a well-formed Dutch postcode. The + // server re-validates format authoritatively (the FE check is UX-only). + public static string? RejectChangeRequest(string straat, string postcode) + { + if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in."; + if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB."; + return null; + } + + public static string NewReference() => + // ponytail: random reference is fine for a demo; a real system reserves it transactionally. + "BIG-2026-" + Random.Shared.Next(100_000, 1_000_000); +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs new file mode 100644 index 0000000..e70572b --- /dev/null +++ b/backend/src/BigRegister.Api/Program.cs @@ -0,0 +1,185 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using BigRegister.Domain.Diplomas; +using BigRegister.Domain.Documents; +using BigRegister.Domain.Intake; +using BigRegister.Domain.Registrations; +using BigRegister.Domain.Submissions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" })); +builder.Services.AddProblemDetails(); +builder.Services.ConfigureHttpJsonOptions(o => +{ + o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + +const string SpaCors = "spa"; +builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => + p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod())); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseCors(SpaCors); + +// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII. +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" })); + +// Versioned prefix: additive changes (new fields) stay on v1 — the generated client +// + FE parse* boundary absorb them; a breaking change introduces /api/v2 alongside. +var api = app.MapGroup("/api/v1"); + +// --- GET: screen-shaped reads. Decisions are computed here, never on the client. --- + +api.MapGet("/dashboard-view", () => +{ + var reg = SeedData.Registration; + var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today)); + return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(), + new HerregistratieDecisionsDto(eligible, reason)); +}); + +api.MapGet("/notes", () => + SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList()); + +// BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded +// citizen has one. +api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto())); + +api.MapGet("/duo/diplomas", () => new DuoLookupDto( + SeedData.Diplomas.Select(d => d.ToDto()).ToList(), + new ManualDiplomaPolicyDto( + DiplomaRules.ManualProfessions(), + DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList()))); + +api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold)); + +// --- POST: submits. The server is the authority; it re-validates and decides. --- + +api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) => + Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents)) +.Produces() +.ProducesProblem(StatusCodes.Status422UnprocessableEntity); + +api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) => + Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents)) +.Produces() +.ProducesProblem(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); + +// --- Document upload --- + +// Server-owned category config per wizard. The FE renders these; it never hardcodes. +api.MapGet("/uploads/categories", (string wizardId) => + new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId).Select(c => c.ToDto()).ToList())); + +// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded +// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type +// and size authoritatively; stores metadata only (no file bytes / PII held). +api.MapPost("/uploads", async (HttpRequest request) => +{ + if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); + var form = await request.ReadFormAsync(); + var file = form.Files.GetFile("file"); + string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString(); + if (file is null || categoryId == "" || localId == "" || wizardId == "") + return Results.Problem(detail: "Onvolledige upload.", statusCode: 400); + + var category = DocumentRules.Find(wizardId, categoryId); + var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); + if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); + + var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.Length, DocumentStore.DemoOwner); + return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); +}) +.ExcludeFromDescription(); + +// Poll-on-return: which of these client localIds have arrived at the BFF. +api.MapGet("/uploads/status", (string? localIds) => +{ + var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId); + var results = ids.Select(id => found.TryGetValue(id, out var d) + ? new UploadStatusItemDto(id, "complete", d.DocumentId) + : new UploadStatusItemDto(id, "unknown", null)).ToList(); + return new UploadStatusDto(results); +}); + +// User delete: owner-scoped; 409 once linked to a finalised submission. +api.MapDelete("/uploads/{documentId}", (string documentId) => + DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch + { + DocumentStore.DeleteResult.Ok => Results.NoContent(), + DocumentStore.DeleteResult.Linked => Results.Problem( + detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.", + statusCode: StatusCodes.Status409Conflict), + _ => Results.NotFound(), + }) +.Produces(StatusCodes.Status204NoContent) +.ProducesProblem(StatusCodes.Status409Conflict) +.Produces(StatusCodes.Status404NotFound); + +// Admin delete (seam): a real system requires an admin role; here an X-Admin header +// stands in. Bypasses ownership, unlinks, and flags the submission for review. +api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => + !IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden) + : DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()) +.Produces(StatusCodes.Status204NoContent) +.Produces(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + +app.Run(); + +static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; + +// Audit + outcome for a submit, with NO personal data: only kind, outcome, +// generated reference and the caller's correlation id (the observability seam — a +// real system ships this to structured logging / an audit store). +IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList? documents = null) +{ + var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v) + ? v.ToString() + : "none"; + + if (reject is not null) + { + app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid); + return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); + } + + if (documents is not null) + { + // Link digital documents (blocks later user delete) and record post-delivery + // intent so a caseworker knows to expect the physical document. + DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); + foreach (var d in documents.Where(d => d.Channel == "post")) + DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid); + } + + var reference = SubmissionRules.NewReference(); + app.Logger.LogInformation( + "submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}", + kind, reference, cid, DateTimeOffset.UtcNow); + return Results.Ok(new ReferentieResponse(reference)); +} + +// Exposed so the integration tests can spin up the app with WebApplicationFactory. +public partial class Program { } diff --git a/backend/src/BigRegister.Api/Properties/launchSettings.json b/backend/src/BigRegister.Api/Properties/launchSettings.json new file mode 100644 index 0000000..c780fa5 --- /dev/null +++ b/backend/src/BigRegister.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/BigRegister.Api/appsettings.Development.json b/backend/src/BigRegister.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/backend/src/BigRegister.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/src/BigRegister.Api/appsettings.json b/backend/src/BigRegister.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/backend/src/BigRegister.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/backend/swagger.json b/backend/swagger.json new file mode 100644 index 0000000..ed155cf --- /dev/null +++ b/backend/swagger.json @@ -0,0 +1,860 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "BIG-register BFF", + "version": "v1" + }, + "paths": { + "/health": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/health/ready": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/dashboard-view": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardViewDto" + } + } + } + } + } + } + }, + "/api/v1/notes": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AantekeningDto" + } + } + } + } + } + } + } + }, + "/api/v1/brp/address": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrpAddressDto" + } + } + } + } + } + } + }, + "/api/v1/duo/diplomas": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DuoLookupDto" + } + } + } + } + } + } + }, + "/api/v1/intake/policy": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntakePolicyDto" + } + } + } + } + } + } + }, + "/api/v1/registrations": { + "post": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistratieRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferentieResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/herregistraties": { + "post": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HerregistratieRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferentieResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/intakes": { + "post": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntakeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferentieResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/change-requests": { + "post": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeRequestRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferentieResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/uploads/categories": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "wizardId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadCategoriesDto" + } + } + } + } + } + } + }, + "/api/v1/uploads/status": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "localIds", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadStatusDto" + } + } + } + } + } + } + }, + "/api/v1/uploads/{documentId}": { + "delete": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/api/v1/admin/uploads/{documentId}": { + "delete": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "documentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } + } + }, + "components": { + "schemas": { + "AantekeningDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "omschrijving": { + "type": "string", + "nullable": true + }, + "datum": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "AdresDto": { + "type": "object", + "properties": { + "straat": { + "type": "string", + "nullable": true + }, + "postcode": { + "type": "string", + "nullable": true + }, + "woonplaats": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "BrpAddressDto": { + "type": "object", + "properties": { + "gevonden": { + "type": "boolean" + }, + "adres": { + "$ref": "#/components/schemas/AdresDto" + } + }, + "additionalProperties": false + }, + "ChangeRequestRequest": { + "type": "object", + "properties": { + "straat": { + "type": "string", + "nullable": true + }, + "postcode": { + "type": "string", + "nullable": true + }, + "woonplaats": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "DashboardViewDto": { + "type": "object", + "properties": { + "registration": { + "$ref": "#/components/schemas/RegistrationDto" + }, + "person": { + "$ref": "#/components/schemas/PersonDto" + }, + "decisions": { + "$ref": "#/components/schemas/HerregistratieDecisionsDto" + } + }, + "additionalProperties": false + }, + "DocumentCategoryDto": { + "type": "object", + "properties": { + "categoryId": { + "type": "string", + "nullable": true + }, + "label": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "required": { + "type": "boolean" + }, + "acceptedTypes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "maxSizeMb": { + "type": "integer", + "format": "int32" + }, + "multiple": { + "type": "boolean" + }, + "allowPostDelivery": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DocumentRefDto": { + "type": "object", + "properties": { + "categoryId": { + "type": "string", + "nullable": true + }, + "channel": { + "type": "string", + "nullable": true + }, + "documentId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "DuoDiplomaDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "naam": { + "type": "string", + "nullable": true + }, + "instelling": { + "type": "string", + "nullable": true + }, + "jaar": { + "type": "integer", + "format": "int32" + }, + "beroep": { + "type": "string", + "nullable": true + }, + "policyQuestions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicyQuestionDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "DuoLookupDto": { + "type": "object", + "properties": { + "diplomas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DuoDiplomaDto" + }, + "nullable": true + }, + "handmatig": { + "$ref": "#/components/schemas/ManualDiplomaPolicyDto" + } + }, + "additionalProperties": false + }, + "HerregistratieDecisionsDto": { + "type": "object", + "properties": { + "eligibleForHerregistratie": { + "type": "boolean" + }, + "herregistratieReason": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "HerregistratieRequest": { + "type": "object", + "properties": { + "uren": { + "type": "integer", + "format": "int32" + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentRefDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "IntakePolicyDto": { + "type": "object", + "properties": { + "scholingThreshold": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "IntakeRequest": { + "type": "object", + "properties": { + "uren": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "ManualDiplomaPolicyDto": { + "type": "object", + "properties": { + "beroepen": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "policyQuestions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicyQuestionDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "PersonDto": { + "type": "object", + "properties": { + "naam": { + "type": "string", + "nullable": true + }, + "geboortedatum": { + "type": "string", + "nullable": true + }, + "adres": { + "$ref": "#/components/schemas/AdresDto" + } + }, + "additionalProperties": false + }, + "PolicyQuestionDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "vraag": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": { } + }, + "ReferentieResponse": { + "type": "object", + "properties": { + "referentie": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "RegistratieRequest": { + "type": "object", + "properties": { + "diplomaHerkomst": { + "type": "string", + "nullable": true + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentRefDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "RegistrationDto": { + "type": "object", + "properties": { + "bigNummer": { + "type": "string", + "nullable": true + }, + "naam": { + "type": "string", + "nullable": true + }, + "beroep": { + "type": "string", + "nullable": true + }, + "registratiedatum": { + "type": "string", + "nullable": true + }, + "geboortedatum": { + "type": "string", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/RegistrationStatusDto" + } + }, + "additionalProperties": false + }, + "RegistrationStatusDto": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "nullable": true + }, + "herregistratieDatum": { + "type": "string", + "nullable": true + }, + "geschorstTot": { + "type": "string", + "nullable": true + }, + "reden": { + "type": "string", + "nullable": true + }, + "doorgehaaldOp": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UploadCategoriesDto": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentCategoryDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "UploadStatusDto": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UploadStatusItemDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "UploadStatusItemDto": { + "type": "object", + "properties": { + "localId": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "documentId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + } + } + }, + "tags": [ + { + "name": "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + } + ] +} \ No newline at end of file diff --git a/backend/tests/BigRegister.Tests/BigRegister.Tests.csproj b/backend/tests/BigRegister.Tests/BigRegister.Tests.csproj new file mode 100644 index 0000000..d355225 --- /dev/null +++ b/backend/tests/BigRegister.Tests/BigRegister.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs new file mode 100644 index 0000000..ed37877 --- /dev/null +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -0,0 +1,212 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +public class EndpointTests(WebApplicationFactory factory) : IClassFixture> +{ + private readonly HttpClient _client = factory.CreateClient(); + + [Fact] + public async Task DashboardView_computes_eligibility_decision() + { + 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); + // seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible + Assert.True(dto.Decisions.EligibleForHerregistratie); + Assert.NotNull(dto.Decisions.HerregistratieReason); + } + + [Fact] + public async Task Notes_returns_seeded_aantekeningen() + { + var notes = await _client.GetFromJsonAsync>("/api/v1/notes"); + Assert.Equal(3, notes!.Count); + } + + [Fact] + public async Task Brp_returns_address() + { + var dto = await _client.GetFromJsonAsync("/api/v1/brp/address"); + Assert.True(dto!.Gevonden); + Assert.Equal("2514 EA", dto.Adres!.Postcode); + } + + [Fact] + public async Task Duo_lookup_carries_server_decided_questions_and_professions() + { + var dto = await _client.GetFromJsonAsync("/api/v1/duo/diplomas"); + Assert.NotNull(dto); + + var english = dto.Diplomas.Single(d => d.Id == "d2"); + Assert.Equal("Arts", english.Beroep); + Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid"); + + var dutch = dto.Diplomas.Single(d => d.Id == "d1"); + Assert.Empty(dutch.PolicyQuestions); + + // DUO "not found" fallback: an unlisted diploma → user uses the manual path, + // which the same lookup provides (maximal question set + declarable professions). + Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id"); + Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count); + Assert.Equal(5, dto.Handmatig.Beroepen.Count); + } + + [Fact] + public async Task IntakePolicy_returns_scholing_threshold() + { + var dto = await _client.GetFromJsonAsync("/api/v1/intake/policy"); + Assert.Equal(1000, dto!.ScholingThreshold); + } + + [Fact] + public async Task Registration_with_duo_diploma_succeeds() + { + var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo")); + res.EnsureSuccessStatusCode(); + var body = await res.Content.ReadFromJsonAsync(); + Assert.StartsWith("BIG-2026-", body!.Referentie); + } + + [Fact] + public async Task Registration_with_manual_diploma_is_rejected_with_problem_details() + { + var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig")); + Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); + Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString()); + } + + [Theory] + [InlineData("/api/v1/intakes")] + [InlineData("/api/v1/herregistraties")] + public async Task Zero_hours_submission_is_rejected(string route) + { + var res = await _client.PostAsJsonAsync(route, new { uren = 0 }); + Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); + } + + [Theory] + [InlineData("/api/v1/intakes")] + [InlineData("/api/v1/herregistraties")] + public async Task Worked_hours_submission_succeeds(string route) + { + var res = await _client.PostAsJsonAsync(route, new { uren = 40 }); + res.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Change_request_with_valid_address_succeeds() + { + var res = await _client.PostAsJsonAsync("/api/v1/change-requests", + new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" }); + res.EnsureSuccessStatusCode(); + var body = await res.Content.ReadFromJsonAsync(); + Assert.StartsWith("BIG-2026-", body!.Referentie); + } + + [Fact] + public async Task Change_request_with_bad_postcode_is_rejected() + { + var res = await _client.PostAsJsonAsync("/api/v1/change-requests", + new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }); + Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); + } + + [Fact] + public async Task Health_endpoint_is_ok() + { + var res = await _client.GetAsync("/health"); + res.EnsureSuccessStatusCode(); + } + + // --- Document upload --- + + private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType) + { + var content = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue(contentType); + content.Add(file, "file", fileName); + content.Add(new StringContent(categoryId), "categoryId"); + content.Add(new StringContent(localId), "localId"); + content.Add(new StringContent(wizardId), "wizardId"); + return content; + } + + private async Task Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf") + { + var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type)); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + return (await res.Content.ReadFromJsonAsync())!; + } + + [Fact] + public async Task Categories_are_server_owned_config() + { + var dto = await _client.GetFromJsonAsync("/api/v1/uploads/categories?wizardId=registratie"); + Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery); + Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery); + } + + [Fact] + public async Task Upload_then_status_reports_complete_for_known_localId() + { + var localId = Guid.NewGuid().ToString(); + var doc = await Upload(localId); + var status = await _client.GetFromJsonAsync($"/api/v1/uploads/status?localIds={localId},onbekend"); + Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId); + Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown"); + } + + [Fact] + public async Task Upload_rejects_wrong_type() + { + var res = await _client.PostAsync("/api/v1/uploads", + UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain")); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + + [Fact] + public async Task User_delete_succeeds_then_404() + { + var doc = await Upload(Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); + } + + [Fact] + public async Task User_delete_blocked_with_409_once_linked_to_submission() + { + var doc = await Upload(Guid.NewGuid().ToString()); + var submit = await _client.PostAsJsonAsync("/api/v1/registrations", + new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) })); + submit.EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); + } + + [Fact] + public async Task Admin_delete_requires_admin_role() + { + var doc = await Upload(Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode); + + var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}"); + req.Headers.Add("X-Admin", "true"); + Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode); + } + + [Fact] + public async Task Audit_log_records_upload_and_delete_metadata_only() + { + var doc = await Upload(Guid.NewGuid().ToString()); + await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}"); + Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload"); + Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user"); + } +} diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs new file mode 100644 index 0000000..006c59e --- /dev/null +++ b/backend/tests/BigRegister.Tests/RuleTests.cs @@ -0,0 +1,153 @@ +using BigRegister.Domain.Diplomas; +using BigRegister.Domain.Documents; +using BigRegister.Domain.Registrations; +using BigRegister.Domain.Submissions; + +namespace BigRegister.Tests; + +public class DocumentRuleTests +{ + [Fact] + public void Rejects_unknown_category() => + Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1)); + + [Fact] + public void Rejects_disallowed_type() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1)); + } + + [Fact] + public void Rejects_oversized_file() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024)); + } + + [Fact] + public void Accepts_valid_file() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024)); + } +} + +public class HerregistratieRuleTests +{ + private static Registration Active(DateOnly deadline) => new( + "19012345601", "Test", "Arts", + new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14), + new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline)); + + [Fact] + public void Eligible_within_window() + { + var (eligible, reason) = HerregistratieRule.Evaluate( + Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26)); + Assert.True(eligible); + Assert.Contains("12 maanden", reason); + } + + [Fact] + public void Not_eligible_before_window() + { + var (eligible, _) = HerregistratieRule.Evaluate( + Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1)); + Assert.False(eligible); + } + + [Fact] + public void Eligible_on_window_boundary() + { + // window opens exactly 12 months before the deadline + var (eligible, _) = HerregistratieRule.Evaluate( + Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1)); + Assert.True(eligible); + } + + [Fact] + public void Suspended_is_not_eligible() + { + var reg = Active(new DateOnly(2027, 3, 1)) with + { + Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"), + }; + var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26)); + Assert.False(eligible); + } + + [Fact] + public void Status_consistency_invariant() + { + Assert.True(HerregistratieRule.IsStatusConsistent( + new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)))); + Assert.False(HerregistratieRule.IsStatusConsistent( + new RegistrationStatus(StatusTag.Geregistreerd))); + } +} + +public class DiplomaRuleTests +{ + private static Diploma Diploma(string opleiding, bool engelstalig) => + new("x", "naam", "instelling", 2011, opleiding, engelstalig); + + [Theory] + [InlineData("geneeskunde", "Arts")] + [InlineData("verpleegkunde", "Verpleegkundige")] + [InlineData("onbekend-programma", "Onbekend")] + public void Profession_is_derived_from_program(string opleiding, string expected) => + Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false))); + + [Fact] + public void English_diploma_requires_dutch_proficiency() + { + var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true)); + Assert.Single(questions); + Assert.Equal("nl-taalvaardigheid", questions[0].Id); + } + + [Fact] + public void Dutch_diploma_has_no_policy_questions() => + Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false))); + + [Fact] + public void Manual_diploma_gets_maximal_set() + { + var questions = DiplomaRules.ManualQuestions(); + Assert.Equal(3, questions.Count); + Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" }, + questions.Select(q => q.Id)); + } + + [Fact] + public void Manual_professions_match_known_programs() => + Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" }, + DiplomaRules.ManualProfessions()); +} + +public class SubmissionRuleTests +{ + [Fact] + public void Manual_diploma_is_rejected() => + Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig")); + + [Fact] + public void Duo_diploma_is_accepted() => + Assert.Null(SubmissionRules.RejectRegistratie("duo")); + + [Fact] + public void Zero_hours_is_rejected() => + Assert.NotNull(SubmissionRules.RejectZeroUren(0)); + + [Fact] + public void Worked_hours_are_accepted() => + Assert.Null(SubmissionRules.RejectZeroUren(40)); + + [Theory] + [InlineData("Lange Voorhout 9", "2514 EA", null)] // valid + [InlineData("", "2514 EA", "Vul straat en huisnummer in.")] + [InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")] + public void Change_request_is_validated(string straat, string postcode, string? expected) => + Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode)); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3f09a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# ponytail: dev-server images (not multi-stage prod builds) — this is a demo. +# `docker compose up` → app at http://localhost:4200, Swagger at http://localhost:5000/swagger +services: + api: + image: mcr.microsoft.com/dotnet/sdk:10.0 + working_dir: /src + command: dotnet run --project src/BigRegister.Api --urls http://+:5000 + environment: + - ASPNETCORE_ENVIRONMENT=Development + volumes: + # ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts. + - ./backend:/src:z + - api-bin:/src/src/BigRegister.Api/bin + - api-obj:/src/src/BigRegister.Api/obj + ports: + - "5000:5000" + + web: + image: node:24 + working_dir: /app + # Uses the committed generated client (no codegen at startup); proxies /api → api container. + command: sh -c "npm ci && npx ng serve --host 0.0.0.0 --proxy-config proxy.conf.docker.json" + volumes: + - ./:/app:z + - web-modules:/app/node_modules + ports: + - "4200:4200" + depends_on: + - api + +volumes: + api-bin: + api-obj: + web-modules: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5629459..4338e5c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -415,9 +415,14 @@ update as you type. ## 6. Connecting to a .NET backend -Today the adapters read static JSON (`mock/*.json`). Because `infrastructure/` is the only +> **Implemented.** No longer hypothetical: a minimal ASP.NET Core backend now hosts +> the business rules and serves the endpoints; the FE consumes it through an +> NSwag-generated typed client. See `backend/README.md`. The text below remains as +> the rationale for _why_ only `infrastructure/` + `contracts/` had to change. + +The adapters used to read static JSON (`mock/*.json`). Because `infrastructure/` is the only layer that touches the network — the **anti-corruption boundary** — pointing the app at a -real ASP.NET API touches _only these files_. Domain, application and UI don't change. +real ASP.NET API touched _only these files_. Domain, application and UI don't change. The one concrete change per adapter: a **DTO** type matching the .NET response, a `toDomain` mapper, and a real URL. 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/nswag.json b/nswag.json new file mode 100644 index 0000000..08fc74b --- /dev/null +++ b/nswag.json @@ -0,0 +1,29 @@ +{ + "runtime": "Net80", + "documentGenerator": { + "fromDocument": { + "url": "backend/swagger.json" + } + }, + "codeGenerators": { + "openApiToTypeScriptClient": { + "className": "ApiClient", + "moduleName": "", + "namespace": "", + "template": "Fetch", + "promiseType": "Promise", + "httpClass": "HttpClient", + "useGetBaseUrlMethod": false, + "baseUrlTokenName": "API_BASE_URL", + "generateClientClasses": true, + "generateOptionalParameters": true, + "typeScriptVersion": 5.0, + "dateTimeType": "String", + "nullValue": "Undefined", + "generateDtoTypes": true, + "typeStyle": "Interface", + "markOptionalProperties": true, + "output": "src/app/shared/infrastructure/api-client.ts" + } + } +} diff --git a/package-lock.json b/package-lock.json index a0f33ad..7f8e350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,16 +26,20 @@ "@angular/build": "^22.0.4", "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.0", + "@angular/localize": "^22.0.4", "@angular/platform-browser-dynamic": "^22.0.0", "@compodoc/compodoc": "^1.2.1", "@storybook/addon-a11y": "^10.4.6", "@storybook/addon-docs": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6", "@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" } }, @@ -778,6 +782,79 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/localize": { + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-22.0.4.tgz", + "integrity": "sha512-BJELtGDcTqPjNn10pUa5Ly0Zv+yOyhNOhMl7OA7izRwgK0bWCqgTRnCFh9OWuvn+KyTLfsd9HKVxSDTmtJYEJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "7.29.7", + "@types/babel__core": "7.20.5", + "tinyglobby": "^0.2.12", + "yargs": "^18.0.0" + }, + "bin": { + "localize-extract": "tools/bundles/src/extract/cli.js", + "localize-migrate": "tools/bundles/src/migrate/cli.js", + "localize-translate": "tools/bundles/src/translate/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.0.4", + "@angular/compiler-cli": "22.0.4" + } + }, + "node_modules/@angular/localize/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/localize/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/localize/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@angular/platform-browser": { "version": "22.0.2", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.0.2.tgz", @@ -3745,6 +3822,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", @@ -3794,6 +3978,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", @@ -8141,6 +8391,51 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -8223,6 +8518,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", @@ -8395,6 +8697,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", @@ -9722,6 +10254,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", @@ -11456,6 +11998,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", @@ -11948,6 +12497,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", @@ -11962,6 +12583,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", @@ -11976,6 +12681,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", @@ -12272,6 +13000,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", @@ -12357,6 +13099,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", @@ -12419,6 +13174,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", @@ -13423,6 +14199,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", @@ -13474,6 +14260,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", @@ -13939,6 +14735,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", @@ -13963,6 +14766,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", @@ -14016,6 +14826,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", @@ -14101,6 +14921,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", @@ -14963,6 +15797,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", @@ -15276,6 +16117,19 @@ "node": ">=8" } }, + "node_modules/nswag": { + "version": "14.7.1", + "resolved": "https://registry.npmjs.org/nswag/-/nswag-14.7.1.tgz", + "integrity": "sha512-V6LiNhRLY4EfaEWAvExAPSPHGUaVIuneA+a67zuhu00fcwR+7xxiBBTpyw82vuI4xMUh1Eq8Nwnc6iLqp/74+g==", + "dev": true, + "license": "MIT", + "bin": { + "nswag": "bin/nswag.js" + }, + "engines": { + "npm": ">=3.10.8" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -15413,6 +16267,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", @@ -16239,6 +17111,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", @@ -18531,6 +19413,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", @@ -18670,6 +19565,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", @@ -18724,6 +19632,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", @@ -18873,6 +19805,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", @@ -20053,6 +20995,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 bedcd90..7c04455 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,15 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "lint": "eslint .", + "start": "ng serve --proxy-config proxy.conf.json", + "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", "storybook": "ng run atomic-design-poc:storybook", "build-storybook": "ng run atomic-design-poc:build-storybook", - "check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi" + "check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts' --exclude='debug-state.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi" }, "private": true, "packageManager": "npm@11.12.1", @@ -26,23 +28,27 @@ "tslib": "^2.3.0" }, "devDependencies": { + "@angular-devkit/architect": "^0.2200.0", + "@angular-devkit/build-angular": "^22.0.0", + "@angular-devkit/core": "^22.0.0", "@angular/build": "^22.0.4", "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.0", - "jsdom": "^29.0.0", - "prettier": "^3.8.1", - "typescript": "~6.0.2", - "vitest": "^4.0.8", - "storybook": "^10.4.6", - "@storybook/angular": "^10.4.6", + "@angular/localize": "^22.0.4", + "@angular/platform-browser-dynamic": "^22.0.0", + "@compodoc/compodoc": "^1.2.1", "@storybook/addon-a11y": "^10.4.6", "@storybook/addon-docs": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6", - "@angular-devkit/build-angular": "^22.0.0", - "@angular-devkit/architect": "^0.2200.0", - "@angular-devkit/core": "^22.0.0", - "@angular/platform-browser-dynamic": "^22.0.0", - "@compodoc/compodoc": "^1.2.1" + "@storybook/angular": "^10.4.6", + "eslint": "^10.6.0", + "jsdom": "^29.0.0", + "nswag": "^14.7.1", + "prettier": "^3.8.1", + "storybook": "^10.4.6", + "typescript": "~6.0.2", + "typescript-eslint": "^8.62.0", + "vitest": "^4.0.8" }, "comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.", "overrides": { @@ -52,6 +58,9 @@ "ajv": "^8.20.0", "webpack-dev-server": "^5.2.5", "sockjs": "^0.3.24", - "uuid": "^11.1.1" + "uuid": "^11.1.1", + "eslint": { + "ajv": "^6.12.6" + } } } diff --git a/proxy.conf.docker.json b/proxy.conf.docker.json new file mode 100644 index 0000000..fc9056a --- /dev/null +++ b/proxy.conf.docker.json @@ -0,0 +1,7 @@ +{ + "/api": { + "target": "http://api:5000", + "secure": false, + "changeOrigin": true + } +} diff --git a/proxy.conf.json b/proxy.conf.json new file mode 100644 index 0000000..e4a6ace --- /dev/null +++ b/proxy.conf.json @@ -0,0 +1,7 @@ +{ + "/api": { + "target": "http://localhost:5000", + "secure": false, + "changeOrigin": true + } +} diff --git a/public/mock/brp-address.json b/public/mock/brp-address.json deleted file mode 100644 index 3b04788..0000000 --- a/public/mock/brp-address.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "gevonden": true, - "adres": { - "straat": "Lange Voorhout 9", - "postcode": "2514 EA", - "woonplaats": "Den Haag" - } -} diff --git a/public/mock/dashboard-view.json b/public/mock/dashboard-view.json deleted file mode 100644 index 7e8c3bc..0000000 --- a/public/mock/dashboard-view.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "registration": { - "bigNummer": "19012345601", - "naam": "Dr. A. (Anna) de Vries", - "beroep": "Arts", - "registratiedatum": "2012-09-01", - "geboortedatum": "1985-03-14", - "status": { "tag": "Geregistreerd", "herregistratieDatum": "2027-03-01" } - }, - "person": { - "naam": "Dr. A. (Anna) de Vries", - "geboortedatum": "1985-03-14", - "adres": { - "straat": "Lange Voorhout 9", - "postcode": "2514 EA", - "woonplaats": "Den Haag" - } - }, - "decisions": { - "eligibleForHerregistratie": true, - "herregistratieReason": "Registratie verloopt binnen 12 maanden (2027-03-01)." - } -} diff --git a/public/mock/duo-diplomas.json b/public/mock/duo-diplomas.json deleted file mode 100644 index 3a650ce..0000000 --- a/public/mock/duo-diplomas.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "diplomas": [ - { - "id": "d1", - "naam": "Geneeskunde", - "instelling": "Universiteit Leiden", - "jaar": 2011, - "beroep": "Arts", - "policyQuestions": [] - }, - { - "id": "d2", - "naam": "Medicine (MBChB)", - "instelling": "University of Edinburgh", - "jaar": 2013, - "beroep": "Arts", - "policyQuestions": [ - { "id": "nl-taalvaardigheid", "vraag": "Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" } - ] - }, - { - "id": "d3", - "naam": "HBO-Verpleegkunde", - "instelling": "Hogeschool Utrecht", - "jaar": 2016, - "beroep": "Verpleegkundige", - "policyQuestions": [] - } - ], - "handmatig": { - "beroepen": ["Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts"], - "policyQuestions": [ - { "id": "nl-taalvaardigheid", "vraag": "Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" }, - { "id": "diploma-erkend", "vraag": "Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?", "type": "ja-nee" }, - { "id": "toelichting", "vraag": "Geef een korte toelichting op uw diploma en opleiding.", "type": "tekst" } - ] - } -} diff --git a/public/mock/intake-policy.json b/public/mock/intake-policy.json deleted file mode 100644 index 5e78eb1..0000000 --- a/public/mock/intake-policy.json +++ /dev/null @@ -1 +0,0 @@ -{ "scholingThreshold": 1000 } diff --git a/public/mock/notes.json b/public/mock/notes.json deleted file mode 100644 index eea820b..0000000 --- a/public/mock/notes.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - { "type": "Specialisme", "omschrijving": "Huisartsgeneeskunde", "datum": "2016-04-12" }, - { "type": "Aantekening", "omschrijving": "Erkend opleider huisartsgeneeskunde", "datum": "2019-01-08" }, - { "type": "Specialisme", "omschrijving": "Spoedeisende hulp (kaderopleiding)", "datum": "2021-06-30" } -] diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 5f4451e..c8f6071 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'; @@ -6,6 +6,9 @@ import localeNl from '@angular/common/locales/nl'; import { routes } from './app.routes'; import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor'; +import { provideApiClient } from '@shared/infrastructure/api-client.provider'; +import { SESSION_PORT } from '@shared/application/session.port'; +import { SessionStore } from '@auth/application/session.store'; registerLocaleData(localeNl); @@ -13,7 +16,11 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes, withViewTransitions()), - provideHttpClient(withInterceptors([scenarioInterceptor])), + // Dev-only: the ?scenario= toggle must never reach a production build, where + // a query param could otherwise force errors on the live app. + provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor] : [])), + provideApiClient(), + { provide: SESSION_PORT, useExisting: SessionStore }, { provide: LOCALE_ID, useValue: 'nl' }, ] }; diff --git a/src/app/auth/application/session.store.ts b/src/app/auth/application/session.store.ts index f5a540f..26080d0 100644 --- a/src/app/auth/application/session.store.ts +++ b/src/app/auth/application/session.store.ts @@ -5,11 +5,16 @@ import { DigidAdapter } from '../infrastructure/digid.adapter'; const STORAGE_KEY = 'session-v1'; -/** Restore a persisted session (best-effort; corrupt entry → logged out). */ +/** Restore a persisted session (best-effort; corrupt entry → logged out). + G2: validate the shape before trusting it. G1: the BSN is never persisted + (see the effect below), so a restored session carries an empty one — it is + unused after login; only `naam` is shown in the chrome. */ function restore(): Session | null { try { const raw = sessionStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as Session) : null; + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; } catch { return null; } @@ -35,7 +40,8 @@ export class SessionStore { constructor() { effect(() => { const s = this._session(); - if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + // G1: persist only `naam` — never write the BSN (national ID) to storage. + if (s) sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam })); else sessionStorage.removeItem(STORAGE_KEY); }); } diff --git a/src/app/auth/infrastructure/digid.adapter.ts b/src/app/auth/infrastructure/digid.adapter.ts index fb30287..6be75a6 100644 --- a/src/app/auth/infrastructure/digid.adapter.ts +++ b/src/app/auth/infrastructure/digid.adapter.ts @@ -9,7 +9,7 @@ export class DigidAdapter { // Swap for a real OIDC redirect flow when there's a backend. async authenticate(bsn: string): Promise> { const t = bsn.trim(); - if (!/^\d{9}$/.test(t)) return err('Voer een geldig BSN van 9 cijfers in.'); + if (!/^\d{9}$/.test(t)) return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`); return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' }); } } diff --git a/src/app/auth/ui/login-form/login-form.component.ts b/src/app/auth/ui/login-form/login-form.component.ts index 50364ce..1b39667 100644 --- a/src/app/auth/ui/login-form/login-form.component.ts +++ b/src/app/auth/ui/login-form/login-form.component.ts @@ -9,18 +9,16 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; selector: 'app-login-form', imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], template: ` -
- + + - + -
- Inloggen met DigiD -
+ Inloggen met DigiD `, }) diff --git a/src/app/auth/ui/login.page.ts b/src/app/auth/ui/login.page.ts index 7751b1d..1bb7b4b 100644 --- a/src/app/auth/ui/login.page.ts +++ b/src/app/auth/ui/login.page.ts @@ -9,8 +9,8 @@ import { SessionStore } from '@auth/application/session.store'; selector: 'app-login-page', imports: [PageShellComponent, AlertComponent, LoginFormComponent], template: ` - + @if (error()) { {{ error() }} } diff --git a/src/app/herregistratie/application/submit-herregistratie.ts b/src/app/herregistratie/application/submit-herregistratie.ts index d566653..ff41329 100644 --- a/src/app/herregistratie/application/submit-herregistratie.ts +++ b/src/app/herregistratie/application/submit-herregistratie.ts @@ -1,14 +1,15 @@ -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result } from '@shared/kernel/fp'; import { Valid } from '../domain/herregistratie.machine'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; /** - * The mutation/command: send a herregistratie application to the backend. - * Returns a Result so the caller can branch on success/failure without - * try/catch. ponytail: faked with a timer; swap for a real POST when there's - * an API. The "uren must be > 0" rule lets the demo show a failure path. + * Command: POST a herregistratie application to the backend (`/api/herregistraties`). + * The "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced + * as the error by `runSubmit`. */ -export async function submitHerregistratie(data: Valid): Promise> { - await new Promise((r) => setTimeout(r, 800)); - if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.'); - return ok(undefined); +export function submitHerregistratie(client: ApiClient, data: Valid): Promise> { + return runSubmit(async () => { + await client.herregistraties({ uren: data.uren, documents: data.documents }); + }, SUBMIT_FAILED); } diff --git a/src/app/herregistratie/application/submit-intake.spec.ts b/src/app/herregistratie/application/submit-intake.spec.ts new file mode 100644 index 0000000..0305e02 --- /dev/null +++ b/src/app/herregistratie/application/submit-intake.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { submitIntake } from './submit-intake'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { ValidIntake } from '../domain/intake.machine'; + +// submit-herregistratie shares this exact shape (POST uren → Result); covered here. +const data = { uren: 40 } as unknown as ValidIntake; + +describe('submitIntake', () => { + it('returns ok on success', async () => { + const client = { intakes: async () => ({ referentie: 'BIG-2026-1' }) } as unknown as ApiClient; + const r = await submitIntake(client, data); + expect(r.ok).toBe(true); + }); + + it('returns err with the ProblemDetails detail when the server rejects', async () => { + const client = { + intakes: async () => { + throw { detail: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.', status: 422 }; + }, + } as unknown as ApiClient; + const r = await submitIntake(client, data); + expect(r.ok).toBe(false); + expect(r).toMatchObject({ error: expect.stringContaining('geen gewerkte uren') }); + }); +}); diff --git a/src/app/herregistratie/application/submit-intake.ts b/src/app/herregistratie/application/submit-intake.ts index cc6e20e..857b292 100644 --- a/src/app/herregistratie/application/submit-intake.ts +++ b/src/app/herregistratie/application/submit-intake.ts @@ -1,14 +1,15 @@ -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result } from '@shared/kernel/fp'; import { ValidIntake } from '../domain/intake.machine'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; /** - * Command: send the intake questionnaire to the backend. Returns a Result so the - * caller branches on success/failure without try/catch. ponytail: faked with a - * timer; swap for a real POST when there's an API. The "uren must be > 0" rule - * lets the demo show the failure path. + * Command: POST the intake questionnaire to the backend (`/api/intakes`). The + * "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced as + * the error by `runSubmit`. */ -export async function submitIntake(data: ValidIntake): Promise> { - await new Promise((r) => setTimeout(r, 800)); - if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.'); - return ok(undefined); +export function submitIntake(client: ApiClient, data: ValidIntake): Promise> { + return runSubmit(async () => { + await client.intakes({ uren: data.uren }); + }, SUBMIT_FAILED); } diff --git a/src/app/herregistratie/contracts/intake-policy.dto.ts b/src/app/herregistratie/contracts/intake-policy.dto.ts deleted file mode 100644 index 9ca5ff6..0000000 --- a/src/app/herregistratie/contracts/intake-policy.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * WIRE CONTRACT for intake policy values owned by the backend. - * - * This is the "config value" shape of moving policy off the client: instead of - * hardcoding the scholing threshold in the frontend, the backend ships the value - * and the UI applies it for instant feedback. The backend remains the AUTHORITY - * — it re-validates on submit. See docs/architecture/0001-bff-lite-decision-dtos.md. - */ -export interface IntakePolicyDto { - /** Below this many NL-hours the scholing question is required. */ - scholingThreshold: number; -} diff --git a/src/app/herregistratie/domain/herregistratie.machine.spec.ts b/src/app/herregistratie/domain/herregistratie.machine.spec.ts index 4ad8632..ee8dc49 100644 --- a/src/app/herregistratie/domain/herregistratie.machine.spec.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; +import { initialUpload } from '@shared/upload/upload.machine'; import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine'; -const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {} }); -const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {} }); +const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); +const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); +const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 3, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload }); describe('wizard.machine', () => { it('next advances only when step 1 parses', () => { @@ -12,11 +14,18 @@ describe('wizard.machine', () => { expect((next(editing1('4160')) as any).step).toBe(2); }); - it('submit reaches Submitting ONLY with fully valid data', () => { - expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting - const good = submit(editing2('4160', '200')); + it('next advances step 2 → 3 only when punten parses', () => { + expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays + expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy(); + expect((next(editing2('4160', '200')) as any).step).toBe(3); + }); + + it('submit reaches Submitting ONLY from step 3 with fully valid data', () => { + expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting + expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten + const good = submit(editing3('4160', '200')); expect(good.tag).toBe('Submitting'); - expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 }); + expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] }); }); it('next requires BOTH step-1 fields (uren and jaren)', () => { @@ -25,13 +34,15 @@ describe('wizard.machine', () => { expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance }); - it('back / resolve are no-ops from illegal states', () => { + it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => { expect(back(initial)).toBe(initial); // step 1, nothing to go back to + expect((back(editing3('1', '2')) as any).step).toBe(2); + expect((back(editing2('1', '2')) as any).step).toBe(1); expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting }); it('resolve maps Submitting to Submitted / Failed', () => { - const submitting = submit(editing2('4160', '200')); + const submitting = submit(editing3('4160', '200')); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); @@ -45,18 +56,32 @@ describe('reduce (message-driven)', () => { s = reduce(s, { tag: 'Next' }); expect(s.tag === 'Editing' && s.step).toBe(2); s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' }); + s = reduce(s, { tag: 'Next' }); + expect(s.tag === 'Editing' && s.step).toBe(3); s = reduce(s, { tag: 'Submit' }); expect(s.tag).toBe('Submitting'); s = reduce(s, { tag: 'SubmitConfirmed' }); expect(s.tag).toBe('Submitted'); }); + it('blocks submit until required documents are satisfied', () => { + const cat = { categoryId: 'bewijs', label: 'Bewijs', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true }; + let s = reduce(editing3('4160', '200'), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + s = reduce(s, { tag: 'Submit' }); + expect(s.tag).toBe('Editing'); + expect((s as any).errors.documenten).toBeTruthy(); + s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' } }); + s = reduce(s, { tag: 'Submit' }); + expect(s.tag).toBe('Submitting'); + expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]); + }); + it('SubmitFailed then Retry returns to Submitting with the same data', () => { - let s = reduce(reduce(editing2('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); + let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); expect(s.tag).toBe('Failed'); s = reduce(s, { tag: 'Retry' }); expect(s.tag).toBe('Submitting'); - expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 }); + expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] }); }); it('Seed mounts an arbitrary state', () => { diff --git a/src/app/herregistratie/domain/herregistratie.machine.ts b/src/app/herregistratie/domain/herregistratie.machine.ts index 157efb3..5bb1a95 100644 --- a/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.ts @@ -1,5 +1,13 @@ import { Result, assertNever } from '@shared/kernel/fp'; import { Uren, parseUren } from '@registratie/domain/value-objects/uren'; +import { + UploadState, + UploadMsg, + initialUpload, + reduceUpload, + requiredCategoriesSatisfied, + deliveryRefs, +} from '@shared/upload/upload.machine'; /** What the user is typing (raw, possibly invalid). */ export interface Draft { @@ -8,11 +16,14 @@ export interface Draft { punten: string; } +export type StepErrors = Partial>; + /** What we have AFTER parsing — branded/typed, guaranteed valid. */ export interface Valid { uren: Uren; jaren: number; punten: number; + documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>; } /** @@ -22,51 +33,68 @@ export interface Valid { * errors set" are unrepresentable — the bug class is gone by construction. */ export type WizardState = - | { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial> } + | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState } | { tag: 'Submitting'; data: Valid } | { tag: 'Submitted'; data: Valid } | { tag: 'Failed'; data: Valid; error: string }; -export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} }; +export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload }; /** Parse every field; on success hand back a Valid, else the per-field errors. */ -function validate(draft: Draft): Result>, Valid> { +function validate(draft: Draft, upload: UploadState): Result { const uren = parseUren(draft.uren); const jaren = parseUren(draft.jaren); const punten = parseUren(draft.punten); - const errors: Partial> = {}; + const errors: StepErrors = {}; if (!uren.ok) errors.uren = uren.error; if (!jaren.ok) errors.jaren = jaren.error; if (!punten.ok) errors.punten = punten.error; - if (uren.ok && jaren.ok && punten.ok) { - return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value } }; + if (!requiredCategoriesSatisfied(upload)) { + errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`; + } + if (uren.ok && jaren.ok && punten.ok && !errors.documenten) { + return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value, documents: deliveryRefs(upload) } }; } return { ok: false, error: errors }; } -/** Step 1 → 2: advance only when BOTH step-1 fields parse. Illegal elsewhere = no-op. */ +/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */ export function next(s: WizardState): WizardState { - if (s.tag !== 'Editing' || s.step !== 1) return s; - const uren = parseUren(s.draft.uren); - const jaren = parseUren(s.draft.jaren); - const errors: Partial> = {}; - if (!uren.ok) errors.uren = uren.error; - if (!jaren.ok) errors.jaren = jaren.error; - return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors }; + if (s.tag !== 'Editing') return s; + const errors: StepErrors = {}; + if (s.step === 1) { + const uren = parseUren(s.draft.uren); + const jaren = parseUren(s.draft.jaren); + if (!uren.ok) errors.uren = uren.error; + if (!jaren.ok) errors.jaren = jaren.error; + return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors }; + } + if (s.step === 2) { + const punten = parseUren(s.draft.punten); + if (!punten.ok) errors.punten = punten.error; + return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors }; + } + return s; } export function back(s: WizardState): WizardState { - if (s.tag !== 'Editing' || s.step !== 2) return s; - return { ...s, step: 1, errors: {} }; + if (s.tag !== 'Editing' || s.step === 1) return s; + return { ...s, step: (s.step - 1) as 1 | 2, errors: {} }; } -/** Step 2 submit: parse everything; move to Submitting only with Valid data. */ +/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */ export function submit(s: WizardState): WizardState { - if (s.tag !== 'Editing' || s.step !== 2) return s; - const result = validate(s.draft); + if (s.tag !== 'Editing' || s.step !== 3) return s; + const result = validate(s.draft, s.upload); return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; } +/** Route an upload sub-message through the pure upload reducer (Editing only). */ +export function upload(s: WizardState, msg: UploadMsg): WizardState { + if (s.tag !== 'Editing') return s; + return { ...s, upload: reduceUpload(s.upload, msg) }; +} + /** Resolve the async submit. Only meaningful while Submitting. */ export function resolve(s: WizardState, r: Result): WizardState { if (s.tag !== 'Submitting') return s; @@ -92,6 +120,7 @@ export type WizardMsg = | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } + | { tag: 'Upload'; msg: UploadMsg } | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase) export function reduce(s: WizardState, m: WizardMsg): WizardState { @@ -110,6 +139,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState { return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s; case 'SubmitFailed': return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; + case 'Upload': + return upload(s, m.msg); case 'Seed': return m.state; default: diff --git a/src/app/herregistratie/domain/intake.machine.ts b/src/app/herregistratie/domain/intake.machine.ts index 016d2e6..3131949 100644 --- a/src/app/herregistratie/domain/intake.machine.ts +++ b/src/app/herregistratie/domain/intake.machine.ts @@ -73,9 +73,9 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu const errors: Errors = {}; switch (step) { case 'buitenland': - if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = 'Maak een keuze.'; + if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`; else if (a.buitenlandGewerkt === 'ja') { - if (!a.land || a.land.trim() === '') errors.land = 'Vul een land in.'; + if (!a.land || a.land.trim() === '') errors.land = $localize`:@@validation.land:Vul een land in.`; const u = parseUren(a.buitenlandseUren ?? ''); if (!u.ok) errors.buitenlandseUren = u.error; } @@ -83,7 +83,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu case 'werk': { const u = parseUren(a.uren ?? ''); if (!u.ok) errors.uren = u.error; - if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = 'Maak een keuze.'; + if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`; // Nascholingspunten are only asked (and required) when scholing was followed. if (a.scholingGevolgd === 'ja') { const p = parseUren(a.punten ?? ''); 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/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 2719752..7b356f3 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -2,13 +2,17 @@ import { Component, computed, inject, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; -import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; -import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; +import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { createStore } from '@shared/application/store'; +import { whenTag } from '@shared/kernel/fp'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine'; import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; +import { createUploadController } from '@shared/upload/upload-controller'; +import { UploadState, initialUpload } from '@shared/upload/upload.machine'; /** Organism: multi-step herregistratie wizard. ALL state lives in one signal driven by the pure `reduce` function (see herregistratie.machine.ts) via an @@ -18,55 +22,62 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi the dashboard shows "in behandeling" immediately. */ @Component({ selector: 'app-herregistratie-wizard', - imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent], + imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent, DocumentUploadComponent], template: ` - @switch (state().tag) { - @case ('Editing') { -

Stap {{ step() }} van 2

-
- @if (step() === 1) { - - - - - - -
- Volgende - Annuleren -
- } @else { - - - -
- Vorige - Herregistratie aanvragen - Annuleren -
+ + + @switch (step()) { + @case (1) { + + + + + + + } + @case (2) { + + + + } + @case (3) { + + @if (errDocumenten()) { + {{ errDocumenten() }} } - + } } - @case ('Submitting') { - Aanvraag wordt verwerkt… - } - @case ('Submitted') { - Uw aanvraag tot herregistratie is ontvangen. - } - @case ('Failed') { - Indienen mislukt: {{ failedError() }} -
- Opnieuw proberen -
- } - } + +
+ Uw aanvraag tot herregistratie is ontvangen. +
+
`, }) export class HerregistratieWizardComponent { private profile = inject(BigProfileStore); + private apiClient = inject(ApiClient); private store = createStore(initial, reduce); /** Optional seed so Storybook / the showcase can mount any state directly. */ @@ -75,13 +86,44 @@ export class HerregistratieWizardComponent { readonly state = this.store.model; // public so the showcase can highlight the live state protected dispatch = this.store.dispatch; - private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null)); + // Stepper labels + per-step heading titles (presentational only). + readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.documenten:Documenten`]; + private stepTitles = [$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`, $localize`:@@herregWizard.title.nascholing:Nascholing`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`]; + + private editing = computed(() => whenTag(this.state(), 'Editing')); protected step = computed(() => this.editing()?.step ?? 1); protected draft = computed(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }); + protected upload = computed(() => this.editing()?.upload ?? initialUpload); protected errUren = computed(() => this.editing()?.errors.uren ?? ''); protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); - protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); + protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? ''); + protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); + protected uploadCtl = createUploadController({ + wizardId: 'herregistratie', + getUpload: () => this.upload(), + dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), + }); + + // --- Presentational wiring for the shared wizard shell --------------------- + protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); + protected primaryLabel = computed(() => (this.step() < 3 ? $localize`:@@wizard.volgende:Volgende` : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`)); + protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`); + protected shellStatus = computed(() => { + switch (this.state().tag) { + case 'Editing': return 'editing'; + case 'Submitting': return 'submitting'; + case 'Submitted': return 'submitted'; + case 'Failed': return 'failed'; + } + }); + /** Current step's field errors, flattened for the shell's error summary. */ + protected errorList = computed(() => { + const e = this.editing()?.errors ?? {}; + return (Object.keys(e) as (keyof typeof e)[]) + .filter((k) => e[k]) + .map((k) => ({ id: k, message: e[k]! })); + }); constructor() { queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() })); @@ -90,7 +132,7 @@ export class HerregistratieWizardComponent { onPrimary() { const s = this.state(); if (s.tag !== 'Editing') return; - this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' }); + this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' }); this.runIfSubmitting(); } @@ -110,7 +152,7 @@ export class HerregistratieWizardComponent { const s = this.state(); if (s.tag !== 'Submitting') return; this.profile.beginHerregistratie(); - const r = await submitHerregistratie(s.data); + const r = await submitHerregistratie(this.apiClient, s.data); if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts index fda71ca..d5134cb 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -3,9 +3,10 @@ import { applicationConfig } from '@storybook/angular'; import { provideHttpClient } from '@angular/common/http'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { WizardState } from '@herregistratie/domain/herregistratie.machine'; +import { initialUpload } from '@shared/upload/upload.machine'; import { Uren } from '@registratie/domain/value-objects/uren'; -const validData = { uren: 4160 as Uren, jaren: 5, punten: 200 }; +const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] }; const meta: Meta = { title: 'Herregistratie/Wizard', @@ -18,11 +19,12 @@ export default meta; type Story = StoryObj; // Each story seeds one state of the machine — one render per union variant. -export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} } } }; +export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload } } }; export const Step1Error: Story = { - args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' } } satisfies WizardState }, + args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' }, upload: initialUpload } satisfies WizardState }, }; -export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {} } } }; +export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {}, upload: initialUpload } } }; +export const Step3: Story = { args: { seed: { tag: 'Editing', step: 3, draft: { uren: '4160', jaren: '5', punten: '200' }, errors: {}, upload: initialUpload } } }; export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; diff --git a/src/app/herregistratie/ui/herregistratie.page.ts b/src/app/herregistratie/ui/herregistratie.page.ts index 81480a3..f943784 100644 --- a/src/app/herregistratie/ui/herregistratie.page.ts +++ b/src/app/herregistratie/ui/herregistratie.page.ts @@ -13,18 +13,18 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie selector: 'app-herregistratie-page', imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent], template: ` - + @if (eligible) { - + Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan. -
+
} @else { - + Voor uw huidige registratiestatus is herregistratie niet mogelijk. } 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 c835d3f..551be97 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -1,13 +1,14 @@ import { Component, computed, effect, inject, input, untracked } from '@angular/core'; -import { httpResource } from '@angular/common/http'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; -import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component'; +import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; -import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; +import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; +import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { createStore } from '@shared/application/store'; +import { whenTag } from '@shared/kernel/fp'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { IntakeState, @@ -20,110 +21,103 @@ import { lageUren, SCHOLING_THRESHOLD_DEFAULT, } from '@herregistratie/domain/intake.machine'; -import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto'; import { submitIntake } from '@herregistratie/application/submit-intake'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter'; const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration. -const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; /** Organism: a BRANCHING intake questionnaire. All state lives in one signal driven by the pure `reduce` (intake.machine.ts). Which step renders is derived from the answers via `visibleSteps`, never stored — so editing an earlier answer immediately changes the remaining steps. Answers are persisted to - localStorage so a page reload keeps the user's progress. */ + sessionStorage so a page reload keeps the user's progress (cleared on tab close). */ @Component({ selector: 'app-intake-wizard', - imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent], + imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, WizardShellComponent], template: ` - @switch (state().tag) { - @case ('Answering') { -

Stap {{ cursor() + 1 }} van {{ steps.length }}

-
- @switch (step()) { - @case ('buitenland') { - - - - @if (answers().buitenlandGewerkt === 'ja') { - - - - - - - } - } - @case ('werk') { - - - - @if (scholingZichtbaar()) { - - - - } - @if (answers().scholingGevolgd === 'ja') { - - - - } - } - @case ('review') { - Controleer uw antwoorden en dien de aanvraag in. -
-
Buiten NL gewerkt
{{ answers().buitenlandGewerkt ?? '—' }}
- @if (answers().buitenlandGewerkt === 'ja') { -
Land
{{ answers().land }}
-
Buitenlandse uren
{{ answers().buitenlandseUren }}
- } -
Uren NL
{{ answers().uren }}
- @if (scholingZichtbaar()) { -
Aanvullende scholing
{{ answers().scholingGevolgd }}
- } - @if (answers().scholingGevolgd === 'ja') { -
Nascholingspunten
{{ answers().punten }}
- } -
- } + + + @switch (step()) { + @case ('buitenland') { + + + + @if (answers().buitenlandGewerkt === 'ja') { + + + + + + } -
- @if (cursor() > 0) { - Vorige + } + @case ('werk') { + + + + @if (scholingZichtbaar()) { + + + + } + @if (answers().scholingGevolgd === 'ja') { + + + + } + } + @case ('review') { + Controleer uw antwoorden en dien de aanvraag in. +
+ + @if (answers().buitenlandGewerkt === 'ja') { + + } - {{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }} - Annuleren -
- + + @if (scholingZichtbaar()) { + + } + @if (answers().scholingGevolgd === 'ja') { + + } + + } } - @case ('Submitting') { - Aanvraag wordt verwerkt… - } - @case ('Submitted') { - Uw aanvraag tot herregistratie is ontvangen. -
- Opnieuw beginnen + +
+ Uw aanvraag tot herregistratie is ontvangen. +
+ Opnieuw beginnen
- } - @case ('Failed') { - Indienen mislukt: {{ failedError() }} -
- Opnieuw proberen -
- } - } +
+ `, }) 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, not hardcoded. The - // backend stays the authority and re-validates on submit. - private policyRes = httpResource(() => 'mock/intake-policy.json', { - defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT }, - }); + // Server-owned policy: the scholing threshold is fetched from the backend, not + // hardcoded. The backend stays the authority and re-validates on submit. + private policyRes = this.policy.policyResource(); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); @@ -132,7 +126,7 @@ export class IntakeWizardComponent { readonly state = this.store.model; readonly dispatch = this.store.dispatch; - private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract) : null)); + private answering = computed(() => whenTag(this.state(), 'Answering')); /** Public so the showcase can render the (fixed) step list next to the wizard. */ readonly steps = STEPS; protected cursor = computed(() => this.answering()?.cursor ?? 0); @@ -142,35 +136,64 @@ export class IntakeWizardComponent { protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT); /** Whether the inline scholing question is shown (and required) in the 'werk' step. */ protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); - protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); + protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); + + // --- Presentational wiring for the shared wizard shell --------------------- + readonly stepLabels = [$localize`:@@intake.step.buitenland:Buitenland`, $localize`:@@intake.step.werk:Werk`, $localize`:@@intake.step.controle:Controle`]; + private stepTitles: Record = { + buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`, + werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`, + review: $localize`:@@intake.title.review:Controleren en indienen`, + }; + protected stepTitle = computed(() => this.stepTitles[this.step()]); + protected primaryLabel = computed(() => (this.step() === 'review' ? $localize`:@@intake.indienen:Aanvraag indienen` : $localize`:@@wizard.volgende:Volgende`)); + protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`); + protected shellStatus = computed(() => { + switch (this.state().tag) { + case 'Answering': return 'editing'; + case 'Submitting': return 'submitting'; + case 'Submitted': return 'submitted'; + case 'Failed': return 'failed'; + } + }); + /** Current step's field errors, flattened for the shell's error summary. The + field ids match the answer keys, so the summary anchors jump to the field. */ + protected errorList = computed(() => { + const e = this.answering()?.errors ?? {}; + return (Object.keys(e) as (keyof Answers)[]) + .filter((k) => e[k]) + .map((k) => ({ id: k, message: e[k]! })); + }); protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? ''; protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value }); constructor() { - // An explicit seed (stories) wins; otherwise resume from localStorage. + // An explicit seed (stories) wins; otherwise resume from sessionStorage. const seeded = this.seed(); queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) })); - // Persist only while answering; clear once the flow is done. + // Persist only while answering; clear once the flow is done. G1: sessionStorage + // (not localStorage) — the draft holds personal data and must not outlive the tab. effect(() => { const s = this.state(); - if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); - else localStorage.removeItem(STORAGE_KEY); + if (s.tag === 'Answering') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + else sessionStorage.removeItem(STORAGE_KEY); }); // Apply the server-owned threshold into machine state as it arrives. Track // only the policy value; untrack the dispatch (it reads the state signal // internally, which would otherwise make this effect loop on its own write). effect(() => { - const scholingThreshold = this.policyRes.value().scholingThreshold; + const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT; untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold })); }); } private restore(): IntakeState | null { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) return null; try { - return JSON.parse(raw) as IntakeState; + const parsed = JSON.parse(raw) as IntakeState; + return parsed?.tag === 'Answering' ? parsed : null; // G2: only resume a known shape. } catch { return null; // ponytail: corrupt entry -> start fresh, no migration. } @@ -198,7 +221,7 @@ export class IntakeWizardComponent { const s = this.state(); if (s.tag !== 'Submitting') return; this.profile.beginHerregistratie(); - const r = await submitIntake(s.data); + const r = await submitIntake(this.apiClient, s.data); if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); diff --git a/src/app/herregistratie/ui/intake.page.ts b/src/app/herregistratie/ui/intake.page.ts index 058820d..6349c8b 100644 --- a/src/app/herregistratie/ui/intake.page.ts +++ b/src/app/herregistratie/ui/intake.page.ts @@ -9,13 +9,13 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w selector: 'app-intake-page', imports: [PageShellComponent, AlertComponent, IntakeWizardComponent], template: ` - - + + Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u de pagina herlaadt. -
+
diff --git a/src/app/registratie/application/big-profile.store.ts b/src/app/registratie/application/big-profile.store.ts index ccffdf8..74e4e5a 100644 --- a/src/app/registratie/application/big-profile.store.ts +++ b/src/app/registratie/application/big-profile.store.ts @@ -42,9 +42,10 @@ export class BigProfileStore { readonly decisions = computed>(() => map(this.view(), (v) => v.decisions)); /** Specialisms/notes stay a separate stream (they have their own empty state). */ - readonly aantekeningen = computed>(() => - fromResource(this.aantekeningenRes, (v) => v.length === 0), - ); + readonly aantekeningen = computed>(() => { + const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0); + return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd; + }); // --- Optimistic herregistratie state, shared with the dashboard ----------- private pending = signal(false); 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..02ae11c --- /dev/null +++ b/src/app/registratie/application/submit-change-request.ts @@ -0,0 +1,20 @@ +import { Result } from '@shared/kernel/fp'; +import { Valid } from '@registratie/domain/change-request.machine'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; + +/** + * Command: POST an address change to the backend (`/api/v1/change-requests`), + * which re-validates and returns a reference. Same shape as the other submit + * commands — a `Result`, never a thrown error — so the form's reduce can branch. + */ +export function submitChangeRequest(client: ApiClient, data: Valid): Promise> { + return runSubmit(async () => { + const res = await client.changeRequests({ + straat: data.straat, + postcode: data.postcode, + woonplaats: data.woonplaats, + }); + return res.referentie ?? ''; + }, SUBMIT_FAILED); +} diff --git a/src/app/registratie/application/submit-registratie.spec.ts b/src/app/registratie/application/submit-registratie.spec.ts new file mode 100644 index 0000000..d07573b --- /dev/null +++ b/src/app/registratie/application/submit-registratie.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { submitRegistratie } from './submit-registratie'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine'; + +// Mocked at the client boundary: the rule itself lives server-side now, so these +// tests only assert that the command maps the client's response onto a Result. +const data = { diplomaHerkomst: 'duo' } as unknown as ValidRegistratie; + +describe('submitRegistratie', () => { + it('returns ok with the server reference on success', async () => { + const client = { registrations: async () => ({ referentie: 'BIG-2026-123456' }) } as unknown as ApiClient; + const r = await submitRegistratie(client, data); + expect(r).toEqual({ ok: true, value: 'BIG-2026-123456' }); + }); + + it('returns err with the ProblemDetails detail when the server rejects (422)', async () => { + const client = { + registrations: async () => { + throw { detail: 'Handmatig diploma kan niet automatisch worden geverifieerd.', status: 422 }; + }, + } as unknown as ApiClient; + const r = await submitRegistratie(client, data); + expect(r.ok).toBe(false); + expect(r).toMatchObject({ error: expect.stringContaining('Handmatig diploma') }); + }); +}); diff --git a/src/app/registratie/application/submit-registratie.ts b/src/app/registratie/application/submit-registratie.ts index db1253c..b3cdcff 100644 --- a/src/app/registratie/application/submit-registratie.ts +++ b/src/app/registratie/application/submit-registratie.ts @@ -1,19 +1,17 @@ -import { Result, ok, err } from '@shared/kernel/fp'; +import { Result } from '@shared/kernel/fp'; import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; /** - * Command: send the completed registration to the backend, which invokes the - * domain command to create/finalize the registration aggregate. Returns a Result - * so the caller branches on success/failure without try/catch; on success it - * yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap - * for a real POST when there's an API. The "manually entered diploma" rule lets - * the demo show the failure path. + * Command: POST the completed registration to the backend (`/api/registrations`), + * which re-validates and decides. The rule that a manually entered diploma cannot + * be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On + * success it yields the server-generated confirmation reference (PRD §9). */ -export async function submitRegistratie(data: ValidRegistratie): Promise> { - await new Promise((r) => setTimeout(r, 800)); - if (data.diplomaHerkomst === 'handmatig') { - return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.'); - } - const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000); - return ok(referentie); +export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise> { + return runSubmit(async () => { + const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst, documents: data.documents }); + return res.referentie ?? ''; + }, SUBMIT_FAILED); } 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..64fb85c --- /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 = $localize`:@@validation.straat:Vul straat en huisnummer in.`; + if (!postcode.ok) errors.postcode = postcode.error; + if (straat && postcode.ok) { + return { ok: true, value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() } }; + } + return { ok: false, error: errors }; +} + +export type Msg = + | { tag: 'SetField'; key: keyof Draft; value: string } + | { tag: 'Submit' } + | { tag: 'Retry' } + | { tag: 'SubmitConfirmed'; referentie: string } + | { tag: 'SubmitFailed'; error: string } + | { tag: 'Reset' } + | { tag: 'Seed'; state: State }; // mount a specific state (stories/tests) + +export function reduce(s: State, m: Msg): State { + switch (m.tag) { + case 'SetField': + return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s; + case 'Submit': { + if (s.tag !== 'Editing') return s; + const r = validate(s.draft); + return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error }; + } + case 'Retry': + return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; + case 'SubmitConfirmed': + return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s; + case 'SubmitFailed': + return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; + case 'Reset': + return initial; + case 'Seed': + return m.state; + default: + return assertNever(m); + } +} diff --git a/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/src/app/registratie/domain/registratie-wizard.machine.spec.ts index 788f879..a2ccc74 100644 --- a/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; +import { initialUpload } from '@shared/upload/upload.machine'; import { Draft, RegistratieState, @@ -25,6 +26,7 @@ const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ draft: { antwoorden: {}, ...draft }, cursor, errors: {}, + upload: initialUpload, }); const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const }; @@ -204,3 +206,31 @@ describe('reduce (message-driven happy path)', () => { expect((s as any).data.beroep).toBe('Arts'); }); }); + +describe('inline document upload (beroep step)', () => { + const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true }; + + it('routes Upload messages through the upload reducer', () => { + const s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + expect((s as any).upload.categories).toHaveLength(1); + }); + + it('blocks the beroep step until a required category is satisfied', () => { + let s = reduce(invullen(validDraft, 1), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + s = reduce(s, { tag: 'Next' }); // beroep → controle blocked + expect(currentStep(s as any)).toBe('beroep'); + expect((s as any).errors.documenten).toBeTruthy(); + // choosing post delivery satisfies the requirement + s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } }); + s = reduce(s, { tag: 'Next' }); + expect(currentStep(s as any)).toBe('controle'); + }); + + it('includes delivery refs in the submitted data', () => { + let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); + s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } }); + const done = submit(s as any); + expect(done.tag).toBe('Indienen'); + expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]); + }); +}); diff --git a/src/app/registratie/domain/registratie-wizard.machine.ts b/src/app/registratie/domain/registratie-wizard.machine.ts index 85acd7a..d625e5e 100644 --- a/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/src/app/registratie/domain/registratie-wizard.machine.ts @@ -1,6 +1,15 @@ import { Result, ok, err, assertNever } from '@shared/kernel/fp'; import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode'; import { Email, parseEmail } from '@registratie/domain/value-objects/email'; +import { + UploadState, + UploadMsg, + DeliveryChannel, + initialUpload, + reduceUpload, + requiredCategoriesSatisfied, + deliveryRefs, +} from '@shared/upload/upload.machine'; /** * A FIXED 3-step registration wizard. The steps never change in number (always @@ -49,6 +58,7 @@ export interface ValidRegistratie { diplomaHerkomst: DiplomaHerkomst; beroep: string; antwoorden: Record; + documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>; } /** Text fields settable via SetField. */ @@ -63,17 +73,18 @@ export interface Errors { email?: string; correspondentie?: string; diploma?: string; + documenten?: string; antwoorden?: Record; } export type RegistratieState = - | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors } + | { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState } | { tag: 'Indienen'; data: ValidRegistratie } | { tag: 'Ingediend'; data: ValidRegistratie; referentie: string } | { tag: 'Mislukt'; data: ValidRegistratie; error: string }; const emptyDraft: Draft = { antwoorden: {} }; -export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} }; +export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {}, upload: initialUpload }; /** Which step the cursor currently points at (clamped to the fixed list). */ export function currentStep(s: Extract): StepId { @@ -81,15 +92,15 @@ export function currentStep(s: Extract): } /** Validate every question currently visible in ONE step. Errors keyed per field. */ -function validateStep(step: StepId, d: Draft): Result { +function validateStep(step: StepId, d: Draft, upload: UploadState): Result { const errors: Errors = {}; switch (step) { case 'adres': { - if (!d.straat || d.straat.trim() === '') errors.straat = 'Vul een straat en huisnummer in.'; + if (!d.straat || d.straat.trim() === '') errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`; const pc = parsePostcode(d.postcode ?? ''); if (!pc.ok) errors.postcode = pc.error; - if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = 'Vul een woonplaats in.'; - if (!d.correspondentie) errors.correspondentie = 'Maak een keuze.'; + if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`; + if (!d.correspondentie) errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`; // E-mail is only required when 'email' is the chosen channel. if (d.correspondentie === 'email') { const e = parseEmail(d.email ?? ''); @@ -100,7 +111,7 @@ function validateStep(step: StepId, d: Draft): Result { case 'beroep': { // A diploma must be chosen (or declared manually); its beroep is then known. if (!d.diplomaId || !d.beroep) { - errors.diploma = 'Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.'; + errors.diploma = $localize`:@@validation.diploma:Kies het diploma waarmee u zich wilt registreren, of voer het handmatig in.`; break; } // Every policy question the chosen diploma raised must be answered. Which @@ -108,9 +119,13 @@ function validateStep(step: StepId, d: Draft): Result { // they're answered. const open: Record = {}; for (const id of d.vraagIds ?? []) { - if (!(d.antwoorden[id] ?? '').trim()) open[id] = 'Beantwoord deze vraag.'; + if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`; } if (Object.keys(open).length > 0) errors.antwoorden = open; + // Required documents for this wizard attach to the beroep step (inline upload). + if (!requiredCategoriesSatisfied(upload)) { + errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`; + } break; } case 'controle': @@ -122,10 +137,10 @@ function validateStep(step: StepId, d: Draft): Result { } /** Parse the whole wizard into a ValidRegistratie (called on submit). */ -function validateAll(d: Draft): Result { +function validateAll(d: Draft, upload: UploadState): Result { const errors: Errors = {}; for (const step of STEPS) { - const r = validateStep(step, d); + const r = validateStep(step, d, upload); if (!r.ok) Object.assign(errors, r.error); } if (Object.keys(errors).length > 0) return err(errors); @@ -147,6 +162,7 @@ function validateAll(d: Draft): Result { diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig', beroep: d.beroep, antwoorden, + documents: deliveryRefs(upload), }); } @@ -197,7 +213,7 @@ export function setAntwoord(s: RegistratieState, vraagId: string, value: string) export function next(s: RegistratieState): RegistratieState { if (s.tag !== 'Invullen') return s; - const r = validateStep(currentStep(s), s.draft); + const r = validateStep(currentStep(s), s.draft, s.upload); if (!r.ok) return { ...s, errors: r.error }; return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} }; } @@ -216,10 +232,16 @@ export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieStat export function submit(s: RegistratieState): RegistratieState { if (s.tag !== 'Invullen') return s; - const r = validateAll(s.draft); + const r = validateAll(s.draft, s.upload); return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error }; } +/** Route an upload sub-message through the pure upload reducer (Invullen only). */ +export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState { + if (s.tag !== 'Invullen') return s; + return { ...s, upload: reduceUpload(s.upload, msg) }; +} + export function resolve(s: RegistratieState, r: Result): RegistratieState { if (s.tag !== 'Indienen') return s; return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error }; @@ -240,6 +262,7 @@ export type RegistratieMsg = | { tag: 'Retry' } | { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitFailed'; error: string } + | { tag: 'Upload'; msg: UploadMsg } | { tag: 'Seed'; state: RegistratieState }; export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState { @@ -272,6 +295,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s; case 'SubmitFailed': return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s; + case 'Upload': + return upload(s, m.msg); case 'Seed': return m.state; default: diff --git a/src/app/registratie/domain/tasks.spec.ts b/src/app/registratie/domain/tasks.spec.ts new file mode 100644 index 0000000..316d13a --- /dev/null +++ b/src/app/registratie/domain/tasks.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { tasksFromProfile } from './tasks'; +import { Registration } from './registration'; + +const base: Registration = { + bigNummer: '12345678901', + naam: 'A. Tester', + beroep: 'arts', + registratiedatum: '2018-01-01', + geboortedatum: '1980-01-01', + status: { tag: 'Geregistreerd', herregistratieDatum: '2026-12-31' }, +}; + +describe('tasksFromProfile', () => { + it('offers herregistratie when the server says eligible, with the formatted deadline', () => { + const tasks = tasksFromProfile(base, true); + expect(tasks).toHaveLength(1); + expect(tasks[0].to).toBe('/herregistratie'); + expect(tasks[0].description).toContain('31 december 2026'); + }); + + it('offers nothing when the server says not eligible', () => { + expect(tasksFromProfile(base, false)).toHaveLength(0); + }); + + it('surfaces a notice for a suspended registration (independent of eligibility)', () => { + const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } }; + const tasks = tasksFromProfile(reg, false); + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toContain('geschorst'); + expect(tasks[0].description).toBe('Onderzoek'); + }); + + it('surfaces a notice for a struck-off registration', () => { + const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } }; + const tasks = tasksFromProfile(reg, false); + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toContain('doorgehaald'); + }); +}); diff --git a/src/app/registratie/domain/tasks.ts b/src/app/registratie/domain/tasks.ts new file mode 100644 index 0000000..3a320fd --- /dev/null +++ b/src/app/registratie/domain/tasks.ts @@ -0,0 +1,59 @@ +import { Registration } from './registration'; +import { herregistratieDeadline } from './registration.policy'; + +/** + * What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data + * derived from the registration — no Angular. Mirrors the shared TaskItem shape. + */ +export interface PortalTask { + title: string; + description: string; + to: string; + actionLabel: string; +} + +function formatNL(d: Date): string { + return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +/** + * Derive the open tasks for a professional (pure). Eligibility is the server's + * decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it, + * it does not recompute the rule (ADR-0001). The deadline is still formatted + * client-side for the task copy (presentation, not a rule). + */ +export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] { + const tasks: PortalTask[] = []; + + if (eligibleForHerregistratie) { + const deadline = herregistratieDeadline(reg); + tasks.push({ + title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`, + description: deadline + ? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatNL(deadline)}:deadline:.` + : $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`, + to: '/herregistratie', + actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`, + }); + } + + if (reg.status.tag === 'Geschorst') { + tasks.push({ + title: $localize`:@@task.geschorst.title:Uw registratie is geschorst`, + description: reg.status.reden, + to: '/registratie', + actionLabel: $localize`:@@task.bekijkGegevens.action:Bekijk uw gegevens`, + }); + } + + if (reg.status.tag === 'Doorgehaald') { + tasks.push({ + title: $localize`:@@task.doorgehaald.title:Uw registratie is doorgehaald`, + description: reg.status.reden, + to: '/registratie', + actionLabel: $localize`:@@task.bekijkGegevens.action:Bekijk uw gegevens`, + }); + } + + return tasks; +} diff --git a/src/app/registratie/domain/value-objects/big-nummer.ts b/src/app/registratie/domain/value-objects/big-nummer.ts index d60fd93..fab3a0e 100644 --- a/src/app/registratie/domain/value-objects/big-nummer.ts +++ b/src/app/registratie/domain/value-objects/big-nummer.ts @@ -5,5 +5,5 @@ export type BigNummer = Brand; export function parseBigNummer(raw: string): Result { const t = raw.trim(); - return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err('Een BIG-nummer bestaat uit 11 cijfers.'); + return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`); } diff --git a/src/app/registratie/domain/value-objects/email.ts b/src/app/registratie/domain/value-objects/email.ts index fb9369e..4fb9c56 100644 --- a/src/app/registratie/domain/value-objects/email.ts +++ b/src/app/registratie/domain/value-objects/email.ts @@ -13,7 +13,7 @@ export function parseEmail(raw: string): Result { // Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough // for instant feedback; the server re-validates. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) { - return err('Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.'); + return err($localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`); } return ok(t as Email); } diff --git a/src/app/registratie/domain/value-objects/postcode.ts b/src/app/registratie/domain/value-objects/postcode.ts index 2339a45..2264fce 100644 --- a/src/app/registratie/domain/value-objects/postcode.ts +++ b/src/app/registratie/domain/value-objects/postcode.ts @@ -10,7 +10,7 @@ export type Postcode = Brand; export function parsePostcode(raw: string): Result { const t = raw.trim().toUpperCase(); if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) { - return err('Voer een geldige postcode in, bijv. 1234 AB.'); + return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`); } // Normalise to "1234 AB" — the parser also cleans up. return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode); diff --git a/src/app/registratie/domain/value-objects/uren.ts b/src/app/registratie/domain/value-objects/uren.ts index 8e8563c..de6f377 100644 --- a/src/app/registratie/domain/value-objects/uren.ts +++ b/src/app/registratie/domain/value-objects/uren.ts @@ -8,7 +8,7 @@ export function parseUren(raw: string): Result { const n = Number(t); // Number('') is 0 — guard the empty string explicitly. if (t === '' || !Number.isInteger(n) || n < 0) { - return err('Vul een geheel aantal in (0 of meer).'); + return err($localize`:@@validation.uren:Vul een geheel aantal in (0 of meer).`); } return ok(n as Uren); } diff --git a/src/app/registratie/infrastructure/big-register.adapter.ts b/src/app/registratie/infrastructure/big-register.adapter.ts index 01e2e52..c4aa828 100644 --- a/src/app/registratie/infrastructure/big-register.adapter.ts +++ b/src/app/registratie/infrastructure/big-register.adapter.ts @@ -1,19 +1,26 @@ -import { Injectable } from '@angular/core'; -import { httpResource } from '@angular/common/http'; -import { Aantekening } from '../domain/registration'; +import { Injectable, inject, resource } from '@angular/core'; +import { Aantekening, AantekeningType } from '../domain/registration'; +import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client'; /** * Infrastructure adapter for the BIG-register source. Exposes signal-based - * resources (Angular's httpResource); each returns a Resource with - * status()/value()/error()/reload(). Call from an injection context - * (a field initializer in the store). + * resources (Angular's `resource` over the generated typed client); each returns + * a Resource with status()/value()/error()/reload(). Call from an injection + * context (a field initializer in the store). * * Note: registration + person are now served via the aggregated dashboard-view * endpoint (see DashboardViewAdapter). Only the notes stream remains separate. */ @Injectable({ providedIn: 'root' }) export class BigRegisterAdapter { + private client = inject(ApiClient); + aantekeningenResource() { - return httpResource(() => 'mock/notes.json', { defaultValue: [] }); + return resource({ loader: () => this.client.notes().then((ns) => ns.map(toAantekening)) }); } } + +/** Map the wire DTO (all fields optional) onto our domain type. */ +function toAantekening(n: AantekeningDto): Aantekening { + return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' }; +} diff --git a/src/app/registratie/infrastructure/brp.adapter.ts b/src/app/registratie/infrastructure/brp.adapter.ts index ae7a198..5211a71 100644 --- a/src/app/registratie/infrastructure/brp.adapter.ts +++ b/src/app/registratie/infrastructure/brp.adapter.ts @@ -1,19 +1,20 @@ -import { Injectable } from '@angular/core'; -import { httpResource } from '@angular/common/http'; +import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { BrpAddressDto } from '@registratie/contracts/brp-address.dto'; +import { ApiClient } from '@shared/infrastructure/api-client'; /** * Infrastructure adapter for the BRP address lookup, reached only through our own - * ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint - * is a static mock; pointing at a real backend touches only this file + the DTO. + * ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET + * backend (`GET /api/brp/address`) via the generated typed client. */ @Injectable({ providedIn: 'root' }) export class BrpAdapter { - // Typed as the DTO for ergonomics, but the value is untrusted JSON until - // parseBrpAddress validates it. + private client = inject(ApiClient); + + // The value is untrusted JSON until parseBrpAddress validates it. adresResource() { - return httpResource(() => 'mock/brp-address.json'); + return resource({ loader: () => this.client.address() }); } } diff --git a/src/app/registratie/infrastructure/dashboard-view.adapter.ts b/src/app/registratie/infrastructure/dashboard-view.adapter.ts index 995cfde..e85eb17 100644 --- a/src/app/registratie/infrastructure/dashboard-view.adapter.ts +++ b/src/app/registratie/infrastructure/dashboard-view.adapter.ts @@ -1,20 +1,28 @@ -import { Injectable } from '@angular/core'; -import { httpResource } from '@angular/common/http'; +import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto'; +import { ApiClient } from '@shared/infrastructure/api-client'; /** * Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint. - * ONE call returns registration + person + server-computed decisions. - * (In this POC the endpoint is a static mock; the decisions are precomputed to - * stand in for what the backend would compute.) + * ONE call returns registration + person + server-computed decisions. The data + * comes from the .NET backend (`GET /api/dashboard-view`) via the generated typed + * client; the decisions (e.g. herregistratie eligibility) are computed there. */ @Injectable({ providedIn: 'root' }) export class DashboardViewAdapter { - // Typed as the DTO for ergonomics, but the value is still untrusted JSON — - // parseDashboardView validates it at the boundary before the app uses it. + private client = inject(ApiClient); + + // The value is still untrusted JSON — parseDashboardView validates it at the + // boundary and maps DTO → domain before the app uses it. + // + // SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here — + // e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the + // adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls + // (the submit-* commands) must NEVER auto-retry — and don't. Manual retry + // (resource.reload via ) covers the UX today, so backoff stays unbuilt. dashboardViewResource() { - return httpResource(() => 'mock/dashboard-view.json'); + return resource({ loader: () => this.client.dashboardView() }); } } diff --git a/src/app/registratie/infrastructure/duo.adapter.ts b/src/app/registratie/infrastructure/duo.adapter.ts index 7e49a02..570473c 100644 --- a/src/app/registratie/infrastructure/duo.adapter.ts +++ b/src/app/registratie/infrastructure/duo.adapter.ts @@ -1,21 +1,21 @@ -import { Injectable } from '@angular/core'; -import { httpResource } from '@angular/common/http'; +import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto'; - -const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } }; +import { ApiClient } from '@shared/infrastructure/api-client'; /** * Infrastructure adapter for the DUO diploma lookup, reached only through our own * ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the * user's diplomas (each with its server-computed beroep + policy questions) and - * the manual-entry fallback policy. The frontend renders; it does not derive. In - * this POC the endpoint is a static mock. + * the manual-entry fallback policy. The frontend renders; it does not derive. + * Data comes from the .NET backend (`GET /api/duo/diplomas`) via the typed client. */ @Injectable({ providedIn: 'root' }) export class DuoAdapter { + private client = inject(ApiClient); + diplomasResource() { - return httpResource(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY }); + return resource({ loader: () => this.client.diplomas() }); } } diff --git a/src/app/registratie/ui/address-fields/address-fields.component.ts b/src/app/registratie/ui/address-fields/address-fields.component.ts index f01f9f7..1db77f3 100644 --- a/src/app/registratie/ui/address-fields/address-fields.component.ts +++ b/src/app/registratie/ui/address-fields/address-fields.component.ts @@ -19,20 +19,25 @@ export type AdresErrors = Partial>; @Component({ selector: 'app-address-fields', imports: [FormsModule, FormFieldComponent, TextInputComponent], + styles: [` + fieldset{border:0;margin:0;padding:0;min-inline-size:0} + legend{padding:0;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-md)} + app-form-field + app-form-field{display:block;margin-block-start:var(--rhc-space-max-lg)} + `], template: `
{{ legend() }} - + - + + name="postcode" i18n-placeholder="@@address.postcodePlaceholder" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" /> - + @@ -45,6 +50,6 @@ export class AddressFieldsComponent { errors = input({}); /** Prefix for field ids/labels — keeps them unique if two blocks ever co-exist. */ idPrefix = input('adres'); - legend = input('Adres'); + legend = input($localize`:@@address.legend:Adres`); fieldChange = output<{ key: keyof AdresValue; value: string }>(); } 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..7789154 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,95 @@ -import { Component, output, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { Component, computed, inject, input } from '@angular/core'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component'; -import { AddressFieldsComponent, AdresValue } from '@registratie/ui/address-fields/address-fields.component'; -import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component'; +import { createStore } from '@shared/application/store'; +import { whenTag } from '@shared/kernel/fp'; +import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine'; +import { submitChangeRequest } from '@registratie/application/submit-change-request'; +import { ApiClient } from '@shared/infrastructure/api-client'; -/** A submitted change request carries a *parsed* postcode (branded Postcode), - not a raw string — downstream code can't receive an unvalidated one. */ -export interface ChangeRequest { - street: string; - zip: Postcode; - city: string; -} - -/** Organism: change-request (adreswijziging) form. Reuses the same form-field - molecule + text-input/button atoms as the login form. Field errors come - straight from the parser's Result — no parallel "is it valid" flag to drift. */ +/** + * Organism: change-request (adreswijziging) form. Uses the SAME idiom as the + * wizards — all state in one signal driven by the pure `reduce` + * (change-request.machine.ts), submitted via a `submit-*` command returning + * `Result`. Renders the shared ``; the server re-validates. + */ @Component({ selector: 'app-change-request-form', - imports: [FormsModule, ButtonComponent, HeadingComponent, AddressFieldsComponent], + imports: [ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent], template: ` - Adreswijziging doorgeven -
- -
- Wijziging indienen + @if (state().tag === 'Submitted') { + + Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht. + +
+ Nieuwe wijziging doorgeven
- + } @else { + Adreswijziging doorgeven +
+ + + @if (failedError()) { + Het indienen is niet gelukt: {{ failedError() }} + } + + + {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} + + + } `, }) 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; + + protected readonly submitLabel = $localize`:@@changeRequest.submit:Wijziging indienen`; + protected readonly submitBezigLabel = $localize`:@@changeRequest.submitBezig:Bezig met indienen…`; + + private editing = computed(() => whenTag(this.state(), 'Editing')); + protected errors = computed(() => this.editing()?.errors ?? {}); + protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); + protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? ''); + + /** The address shown in the fields — the live draft while editing, the parsed + data while submitting/failed (so the user sees what they sent). */ + protected adres = computed(() => { + 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/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index 55b6b3b..4b6f1fb 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -1,78 +1,133 @@ -import { Component, inject } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { LinkComponent } from '@shared/ui/link/link.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; +import { CardComponent } from '@shared/ui/card/card.component'; +import { TaskListComponent } from '@shared/ui/task-list/task-list.component'; +import { SideNavComponent, NavItem } from '@shared/layout/side-nav/side-nav.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { Registration } from '@registratie/domain/registration'; +import { tasksFromProfile } from '@registratie/domain/tasks'; +/** Page: "Mijn overzicht" — the portal home, following the NL Design System + "Mijn omgeving" pattern (side nav + "Wat moet ik regelen" + "Mijn zaken"). */ @Component({ selector: 'app-dashboard-page', imports: [ PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent, - DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent, + DataRowComponent, CardComponent, TaskListComponent, SideNavComponent, ...ASYNC, + RegistrationSummaryComponent, RegistrationTableComponent, ], template: ` - - @if (store.pendingHerregistratie()) { - Uw herregistratie-aanvraag is in behandeling. - } + +
+ - - - - -
- Persoonsgegevens (BRP) -
- - - -
-
-
- - - -
+
+ @if (store.pendingHerregistratie()) { + Uw herregistratie-aanvraag is in behandeling. + } -
- Specialismen en aantekeningen - - - - - - - - -

U heeft nog geen specialismen of aantekeningen.

-
-
+ + + @let tasks = tasksFor($any(p).registration); + +
+ Wat moet ik regelen + @if (tasks.length) { + + } @else { +

U heeft op dit moment niets openstaan.

+ } +
+ +
+ Mijn registratie +
+ +
+ +
+ + + +
+
+
+
+ + + +
+ +
+ Specialismen en aantekeningen +
+ + + + + + + + +

U heeft nog geen specialismen of aantekeningen.

+
+
+
+
+ +
+ Wat wilt u doen? +
    + @for (a of acties; track a.to) { +
  • + +

    {{ a.tekst }}

    + {{ a.actie }} → +
    +
  • + } +
+
+
- -

- Inschrijven in het BIG-register (registratiewizard) → -

-

- Gegevens bekijken of een wijziging doorgeven → -

-

- Herregistratie aanvragen → -

-

- Herregistratie-intake (vragenlijst met vertakkingen) → -

-

- Functionele patronen (impossible states) → -

`, }) export class DashboardPage { protected store = inject(BigProfileStore); + + /** Server-computed eligibility (rendered, not recomputed). */ + private readonly eligible = computed(() => { + const d = this.store.decisions(); + return d.tag === 'Success' && d.value.eligibleForHerregistratie; + }); + + protected tasksFor(reg: Registration) { + return tasksFromProfile(reg, this.eligible()); + } + + /** Portal sections (navigation, not actions). */ + protected readonly nav: NavItem[] = [ + { label: $localize`:@@dashboard.nav.overzicht:Overzicht`, to: '/dashboard' }, + { label: $localize`:@@dashboard.nav.gegevens:Mijn gegevens`, to: '/registratie' }, + { label: $localize`:@@dashboard.nav.herregistratie:Herregistratie`, to: '/herregistratie' }, + { label: $localize`:@@dashboard.nav.inschrijven:Inschrijven`, to: '/registreren' }, + { label: $localize`:@@dashboard.nav.concepten:Functionele patronen`, to: '/concepts' }, + ]; + + /** Primary transactional actions, as a card grid. */ + protected readonly acties = [ + { to: '/registreren', titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`, tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`, actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving` }, + { to: '/herregistratie', titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`, tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`, actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan` }, + { to: '/intake', titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`, tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`, actie: $localize`:@@dashboard.actie.intake.actie:Start intake` }, + { to: '/registratie', titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`, tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`, actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens` }, + ]; } diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 67c97e1..081a469 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -1,17 +1,17 @@ -import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core'; +import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; -import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component'; +import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; -import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; -import { StepperComponent } from '@shared/ui/stepper/stepper.component'; +import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component'; import { createStore } from '@shared/application/store'; +import { whenTag } from '@shared/kernel/fp'; import { RemoteData, fromResource } from '@shared/application/remote-data'; import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter'; import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter'; @@ -28,58 +28,74 @@ import { STEPS, } from '@registratie/domain/registratie-wizard.machine'; import { submitRegistratie } from '@registratie/application/submit-registratie'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; +import { createUploadController } from '@shared/upload/upload-controller'; +import { UploadState, initialUpload } from '@shared/upload/upload.machine'; -const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration. -const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }]; -const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; +const STORAGE_KEY = 'registratie-v2'; // ponytail: bump the suffix if the persisted shape changes; no migration. +const KANALEN = [ + { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` }, + { value: 'post', label: $localize`:@@registratie.kanaalPost:Post` }, +]; const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" /** Organism: the BIG-registration wizard. All state lives in one signal driven by the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the draft via an effect; the DUO diploma list renders through ; choosing a diploma reveals its server-derived beroep. The draft is persisted to - localStorage so a reload keeps the user's progress. Built entirely from existing + sessionStorage so a reload keeps the user's progress (cleared on tab close). Built from existing atoms/molecules. */ @Component({ selector: 'app-registratie-wizard', imports: [ FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, - AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, - AddressFieldsComponent, ...ASYNC, + AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent, + AddressFieldsComponent, DocumentUploadComponent, ...ASYNC, ], template: ` - @switch (state().tag) { - @case ('Invullen') { - -

{{ stepTitle() }}

-
- @switch (step()) { - @case ('adres') { + + + @switch (step()) { + @case ('adres') { @if (adresStatus() === 'laden') { } @else { @switch (adresStatus()) { @case ('gevonden') { - Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan. + Vooraf ingevuld op basis van de BRP. Controleer en pas zo nodig aan. } @case ('geen') { - We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in. + We vonden geen adres in de BRP. Vul uw adres hieronder handmatig in. } @case ('fout') { - We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in. + We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in. } } - + @if (draft().correspondentie === 'email') { - - + + } } @@ -87,20 +103,20 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" @case ('beroep') { - + @if (handmatigActief()) { - Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld. - + Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig beoordeeld. + } @else if (draft().beroep) {
- +
} @@ -120,58 +136,54 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
+ + + @if (err('documenten')) { + {{ err('documenten') }} + } } @case ('controle') { - Controleer uw gegevens en dien de registratie in. + Controleer uw gegevens en dien de registratie in.
- - - + + + @if (draft().correspondentie === 'email') { - + } - - + + @for (item of samenvattingVragen(); track item.vraag) { }
- Adres wijzigen - Diploma wijzigen + Adres wijzigen + Diploma wijzigen
- } - } -
- @if (cursor() > 0) { - Vorige - } - {{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }} - Annuleren -
- + } } - @case ('Indienen') { - Uw registratie wordt verwerkt… - } - @case ('Ingediend') { - Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie. + +
+ Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.
- Nieuwe registratie starten + Nieuwe registratie starten
- } - @case ('Mislukt') { - Het indienen is niet gelukt: {{ failedError() }} -
- Opnieuw proberen -
- } - } +
+
`, }) export class RegistratieWizardComponent { private brp = inject(BrpAdapter); private duo = inject(DuoAdapter); + private apiClient = inject(ApiClient); private store = createStore(initial, reduce); protected adresRes = this.brp.adresResource(); @@ -181,29 +193,56 @@ export class RegistratieWizardComponent { seed = input(initial); readonly kanalen = KANALEN; - readonly stepLabels = ['Adres', 'Beroep', 'Controle']; // short labels for the stepper - private stepTitles = ['Adres en correspondentievoorkeur', 'Beroep op basis van uw diploma', 'Controleren en indienen']; + readonly stepLabels = [$localize`:@@regWizard.step.adres:Adres`, $localize`:@@regWizard.step.beroep:Beroep`, $localize`:@@regWizard.step.controle:Controle`]; // short labels for the stepper + private stepTitles = [$localize`:@@regWizard.title.adres:Adres en correspondentievoorkeur`, $localize`:@@regWizard.title.beroep:Beroep op basis van uw diploma`, $localize`:@@regWizard.title.controle:Controleren en indienen`]; readonly state = this.store.model; readonly dispatch = this.store.dispatch; - /** Focus target: the step heading receives focus on step change (a11y). */ - private stepHeading = viewChild>('stepHeading'); - - private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract) : null)); + private invullen = computed(() => whenTag(this.state(), 'Invullen')); protected cursor = computed(() => this.invullen()?.cursor ?? 0); protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} }); + protected upload = computed(() => this.invullen()?.upload ?? initialUpload); + protected uploadCtl = createUploadController({ + wizardId: 'registratie', + getUpload: () => this.upload(), + dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), + }); protected step = computed(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]); - protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract).referentie : '')); - protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract).error : '')); + protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); + protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? ''); + + // --- Presentational wiring for the shared wizard shell --------------------- + protected primaryLabel = computed(() => (this.step() === 'controle' ? $localize`:@@regWizard.indienen:Registratie indienen` : $localize`:@@wizard.volgende:Volgende`)); + protected errorMessage = computed(() => $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${this.failedError()}`); + protected shellStatus = computed(() => { + switch (this.state().tag) { + case 'Invullen': return 'editing'; + case 'Indienen': return 'submitting'; + case 'Ingediend': return 'submitted'; + case 'Mislukt': return 'failed'; + } + }); + /** Current step's errors (incl. per-question), flattened for the error summary. */ + protected errorList = computed(() => { + const e = this.invullen()?.errors ?? {}; + const out: WizardError[] = []; + for (const [k, v] of Object.entries(e)) { + if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v }); + } + for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) { + if (msg) out.push({ id: 'vraag-' + qid, message: msg }); + } + return out; + }); protected adresSamenvatting = computed(() => { const d = this.draft(); return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', '); }); // Readable labels for the controle summary (instead of raw enum values). - protected adresHerkomstLabel = computed(() => ({ brp: 'Automatisch uit de BRP', handmatig: 'Handmatig ingevoerd' }[this.draft().adresHerkomst ?? 'handmatig'])); - protected correspondentieLabel = computed(() => ({ email: 'Per e-mail', post: 'Per post' }[this.draft().correspondentie ?? 'post'])); - protected diplomaHerkomstLabel = computed(() => ({ duo: 'Geverifieerd via DUO', handmatig: 'Handmatig ingevoerd (wordt beoordeeld)' }[this.draft().diplomaHerkomst ?? 'handmatig'])); + protected adresHerkomstLabel = computed(() => ({ brp: $localize`:@@regWizard.herkomst.adresBrp:Automatisch uit de BRP`, handmatig: $localize`:@@regWizard.herkomst.adresHandmatig:Handmatig ingevoerd` }[this.draft().adresHerkomst ?? 'handmatig'])); + protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post'])); + protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig'])); /** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a @@ -234,7 +273,7 @@ export class RegistratieWizardComponent { readonly jaNee = JA_NEE; - protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? ''; + protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') => this.invullen()?.errors[k] ?? ''; protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? ''; protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value }); protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie }); @@ -246,7 +285,7 @@ export class RegistratieWizardComponent { protected diplomaOptions = (data: DuoLookupDto) => [ ...data.diplomas.map((d) => ({ value: d.id, label: `${d.naam} — ${d.instelling} (${d.jaar})` })), - { value: HANDMATIG, label: 'Mijn diploma staat er niet bij' }, + { value: HANDMATIG, label: $localize`:@@regWizard.diplomaNietBij:Mijn diploma staat er niet bij` }, ]; protected beroepOptions = (data: DuoLookupDto) => data.handmatig.beroepen.map((b) => ({ value: b, label: b })); @@ -276,14 +315,15 @@ export class RegistratieWizardComponent { } constructor() { - // An explicit seed (stories) wins; otherwise resume from localStorage. + // An explicit seed (stories) wins; otherwise resume from sessionStorage. const seeded = this.seed(); queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) })); - // Persist only while filling in; clear once the flow is done. + // Persist only while filling in; clear once the flow is done. G1: sessionStorage + // (not localStorage) — the draft holds address/email and must not outlive the tab. effect(() => { const s = this.state(); - if (s.tag === 'Invullen') localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); - else localStorage.removeItem(STORAGE_KEY); + if (s.tag === 'Invullen') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + else sessionStorage.removeItem(STORAGE_KEY); }); // Prefill the address from the BRP lookup as it arrives. Track only the resource // value; untrack the dispatch (it reads the state signal, which would otherwise @@ -302,25 +342,16 @@ export class RegistratieWizardComponent { } }); }); - // A11y: move focus to the step heading when the STEP changes (depends only on - // cursor(), which is value-stable across keystrokes — so typing never steals - // focus). Skip the first run so we don't grab focus on initial load. - let firstStep = true; - effect(() => { - this.cursor(); - if (firstStep) { firstStep = false; return; } - untracked(() => { - if (this.state().tag !== 'Invullen') return; - queueMicrotask(() => this.stepHeading()?.nativeElement.focus()); - }); - }); + // A11y: focus management (step heading on step change, error summary on a + // failed submit) now lives in the shared WizardShellComponent. } private restore(): RegistratieState | null { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) return null; try { - return JSON.parse(raw) as RegistratieState; + const parsed = JSON.parse(raw) as RegistratieState; + return parsed?.tag === 'Invullen' ? parsed : null; // G2: only resume a known shape. } catch { return null; // ponytail: corrupt entry -> start fresh, no migration. } @@ -350,7 +381,7 @@ export class RegistratieWizardComponent { private async runIfIndienen() { const s = this.state(); if (s.tag !== 'Indienen') return; - const r = await submitRegistratie(s.data); + const r = await submitRegistratie(this.apiClient, s.data); if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value }); else this.dispatch({ tag: 'SubmitFailed', error: r.error }); } diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts index c715436..5adb95b 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts @@ -3,12 +3,13 @@ import { applicationConfig } from '@storybook/angular'; import { provideHttpClient } from '@angular/common/http'; import { RegistratieWizardComponent } from './registratie-wizard.component'; import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine'; +import { initialUpload } from '@shared/upload/upload.machine'; import { Postcode } from '@registratie/domain/value-objects/postcode'; const adres: Partial = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' }; const filled: Partial = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] }; -const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} }); +const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {}, upload: initialUpload }); const validData: ValidRegistratie = { adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' }, @@ -18,6 +19,7 @@ const validData: ValidRegistratie = { diplomaHerkomst: 'duo', beroep: 'Arts', antwoorden: {}, + documents: [], }; const meta: Meta = { diff --git a/src/app/registratie/ui/registratie.page.ts b/src/app/registratie/ui/registratie.page.ts index 3d3510a..206b4c7 100644 --- a/src/app/registratie/ui/registratie.page.ts +++ b/src/app/registratie/ui/registratie.page.ts @@ -10,10 +10,10 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r imports: [PageShellComponent, AlertComponent, RegistratieWizardComponent], template: ` - + backLink="/dashboard"> + In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard als u de pagina herlaadt. diff --git a/src/app/registratie/ui/registration-detail.page.ts b/src/app/registratie/ui/registration-detail.page.ts index 4db9db5..eb84513 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,11 +9,11 @@ import { BigProfileStore } from '@registratie/application/big-profile.store'; @Component({ selector: 'app-registration-detail-page', imports: [ - PageShellComponent, AlertComponent, SkeletonComponent, ...ASYNC, + PageShellComponent, SkeletonComponent, ...ASYNC, RegistrationSummaryComponent, ChangeRequestFormComponent, ], template: ` - + @@ -24,17 +23,12 @@ 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/registratie/ui/registration-summary/registration-summary.component.ts b/src/app/registratie/ui/registration-summary/registration-summary.component.ts index 4c7ee64..b8b42e3 100644 --- a/src/app/registratie/ui/registration-summary/registration-summary.component.ts +++ b/src/app/registratie/ui/registration-summary/registration-summary.component.ts @@ -4,37 +4,38 @@ import { Registration } from '@registratie/domain/registration'; import { statusColor, statusLabel } from '@registratie/domain/registration.policy'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; +import { CardComponent } from '@shared/ui/card/card.component'; /** Organism: registration summary card. Composes data-row molecules + status-badge atom. */ @Component({ selector: 'app-registration-summary', - imports: [DatePipe, DataRowComponent, StatusBadgeComponent], + imports: [DatePipe, DataRowComponent, StatusBadgeComponent, CardComponent], template: ` -
+
- - - - + + + + - + @switch (reg().status.tag) { @case ('Geregistreerd') { - + } @case ('Geschorst') { - - + + } @case ('Doorgehaald') { - - + + } }
-
+ `, }) export class RegistrationSummaryComponent { diff --git a/src/app/registratie/ui/registration-table/registration-table.component.ts b/src/app/registratie/ui/registration-table/registration-table.component.ts index f6309ef..716e95a 100644 --- a/src/app/registratie/ui/registration-table/registration-table.component.ts +++ b/src/app/registratie/ui/registration-table/registration-table.component.ts @@ -11,9 +11,9 @@ import { Aantekening } from '@registratie/domain/registration'; - - - + + + diff --git a/src/app/shared/application/session.port.ts b/src/app/shared/application/session.port.ts new file mode 100644 index 0000000..da78cad --- /dev/null +++ b/src/app/shared/application/session.port.ts @@ -0,0 +1,14 @@ +import { InjectionToken, Signal } from '@angular/core'; + +/** + * A shared seam for the chrome to show "who is logged in" + log out, WITHOUT + * shared/ depending on the auth context (the import-direction rule forbids that). + * Auth provides this token at the app root (see app.config.ts); the shared header + * injects it. SessionStore satisfies this shape structurally. + */ +export interface SessionPort { + readonly session: Signal<{ naam: string } | null>; + logout(): void; +} + +export const SESSION_PORT = new InjectionToken('SESSION_PORT'); diff --git a/src/app/shared/application/store.spec.ts b/src/app/shared/application/store.spec.ts new file mode 100644 index 0000000..e93958b --- /dev/null +++ b/src/app/shared/application/store.spec.ts @@ -0,0 +1,32 @@ +import { ApplicationRef, effect } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { createStore } from './store'; + +describe('createStore', () => { + it('applies the pure update on dispatch', () => { + const store = createStore(0, (n: number, m: number) => n + m); + store.dispatch(5); + store.dispatch(3); + expect(store.model()).toBe(8); + }); + + // Regression: an effect that dispatches must NOT re-run because of its own write. + // dispatch used to read `model()` reactively (`set(update(model(), msg))`), so an + // effect calling dispatch subscribed to `model` and looped forever, livelocking the + // main thread (crashed the upload wizards). With `.update` the read is untracked. + it('dispatch from inside an effect does not self-loop', () => { + const store = createStore(0, (n: number, _m: 'inc') => n + 1); + let runs = 0; + TestBed.runInInjectionContext(() => { + effect(() => { + runs++; + if (runs < 100) store.dispatch('inc'); // bounded so the buggy version can't hang the test + }); + }); + TestBed.inject(ApplicationRef).tick(); // flush effects + + expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it + expect(store.model()).toBe(1); + }); +}); diff --git a/src/app/shared/application/store.ts b/src/app/shared/application/store.ts index 86782bf..0e55de7 100644 --- a/src/app/shared/application/store.ts +++ b/src/app/shared/application/store.ts @@ -24,6 +24,10 @@ export function createStore( const model = signal(init); return { model: model.asReadonly(), - dispatch: (msg) => model.set(update(model(), msg)), + // Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`: + // dispatch is a command and must never subscribe its caller to `model`. Reading + // `model()` here inside an effect that also dispatches makes the effect depend on + // its own write and livelock the main thread (crashed the upload wizards). + dispatch: (msg) => model.update((m) => update(m, msg)), }; } diff --git a/src/app/shared/application/submit.spec.ts b/src/app/shared/application/submit.spec.ts new file mode 100644 index 0000000..32abe86 --- /dev/null +++ b/src/app/shared/application/submit.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { runSubmit } from './submit'; + +describe('runSubmit', () => { + it('folds a resolved call into ok(value)', async () => { + const r = await runSubmit(async () => 'BIG-123', 'fallback'); + expect(r).toEqual({ ok: true, value: 'BIG-123' }); + }); + + it('maps a ProblemDetails rejection to err(detail)', async () => { + const r = await runSubmit(async () => { + throw { detail: 'Aanvraag afgewezen.' }; + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' }); + }); + + it('falls back when the rejection has no detail', async () => { + const r = await runSubmit(async () => { + throw new Error('network'); + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'fallback' }); + }); +}); diff --git a/src/app/shared/application/submit.ts b/src/app/shared/application/submit.ts new file mode 100644 index 0000000..bd4b28f --- /dev/null +++ b/src/app/shared/application/submit.ts @@ -0,0 +1,20 @@ +import { Result, ok, err } from '@shared/kernel/fp'; +import { problemDetail } from '@shared/infrastructure/api-error'; + +/** + * Run a mutating API call and fold it into a `Result` — the one place the + * try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just + * its own payload mapping. The backend re-validates and returns a 422 + * ProblemDetails on rejection, surfaced here as the error string. + */ +export async function runSubmit(fn: () => Promise, fallback: string): Promise> { + try { + return ok(await fn()); + } catch (e) { + return err(problemDetail(e, fallback)); + } +} + +// Single shared default for a failed submit; the @@id dedupes it at the +// translation layer. +export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`; diff --git a/src/app/shared/infrastructure/api-client.provider.ts b/src/app/shared/infrastructure/api-client.provider.ts new file mode 100644 index 0000000..5cf8662 --- /dev/null +++ b/src/app/shared/infrastructure/api-client.provider.ts @@ -0,0 +1,67 @@ +import { Provider } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom, timeout, TimeoutError } from 'rxjs'; +import { ApiClient, ProblemDetails } from './api-client'; +import { environment } from '../../../environments/environment'; + +/** Single place every API call passes through: the seam for cross-cutting concerns. */ +const REQUEST_TIMEOUT_MS = 10_000; + +/** + * Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated + * client expects, so every API call flows through HttpClient interceptors (the + * `?scenario=` toggle) and the cross-cutting concerns below. The generated client + * is the only place HTTP shapes are known; this is the only place it meets + * Angular's HTTP stack — i.e. the one seam to add: + * - timeout (done — REQUEST_TIMEOUT_MS), + * - correlation id (done — X-Correlation-Id, echoed in backend logs), + * - idempotency key for writes (done — Idempotency-Key; a real retry would thread + * a STABLE key per logical submit so re-sends dedupe; here it's per-attempt), + * - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands, + * - retry/backoff: wrap the pipe with rxjs `retry({ count, delay })` here. + */ +function httpClientFetch(http: HttpClient) { + return { + async fetch(url: RequestInfo, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + 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', + }) + .pipe(timeout(REQUEST_TIMEOUT_MS)), + ); + return new Response(res.body ?? '', { status: res.status || 200 }); + } catch (e) { + if (e instanceof TimeoutError) return new Response('', { status: 504 }); + const err = e as HttpErrorResponse; + const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {}); + // ponytail: clamp to a Response-constructible status (an aborted/interceptor + // request reports status 0, which `new Response` rejects). + const status = err.status >= 200 && err.status <= 599 ? err.status : 500; + return new Response(body, { status }); + } + }, + }; +} + +/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the + * environment (relative '' in dev → proxy; configurable per deployment). */ +export function provideApiClient(): Provider { + return { + provide: ApiClient, + useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)), + deps: [HttpClient], + }; +} + +export type { ProblemDetails }; diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts new file mode 100644 index 0000000..96af1ef --- /dev/null +++ b/src/app/shared/infrastructure/api-client.ts @@ -0,0 +1,796 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +/* eslint-disable */ +// ReSharper disable InconsistentNaming + +export class ApiClient { + private http: { fetch(url: RequestInfo, init?: RequestInit): Promise }; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) { + this.http = http ? http : window as any; + 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/v1/dashboard-view"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processDashboardView(_response); + }); + } + + protected processDashboardView(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DashboardViewDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return OK + */ + notes(): Promise { + let url_ = this.baseUrl + "/api/v1/notes"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processNotes(_response); + }); + } + + protected processNotes(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AantekeningDto[]; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return OK + */ + address(): Promise { + let url_ = this.baseUrl + "/api/v1/brp/address"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processAddress(_response); + }); + } + + protected processAddress(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BrpAddressDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return OK + */ + diplomas(): Promise { + let url_ = this.baseUrl + "/api/v1/duo/diplomas"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processDiplomas(_response); + }); + } + + protected processDiplomas(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DuoLookupDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return OK + */ + policy(): Promise { + let url_ = this.baseUrl + "/api/v1/intake/policy"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processPolicy(_response); + }); + } + + protected processPolicy(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as IntakePolicyDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return OK + */ + registrations(body: RegistratieRequest): Promise { + let url_ = this.baseUrl + "/api/v1/registrations"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processRegistrations(_response); + }); + } + + protected processRegistrations(response: Response): Promise { + 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); + } + + /** + * @return OK + */ + herregistraties(body: HerregistratieRequest): Promise { + let url_ = this.baseUrl + "/api/v1/herregistraties"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processHerregistraties(_response); + }); + } + + protected processHerregistraties(response: Response): Promise { + 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); + } + + /** + * @return OK + */ + intakes(body: IntakeRequest): Promise { + let url_ = this.baseUrl + "/api/v1/intakes"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processIntakes(_response); + }); + } + + protected processIntakes(response: Response): Promise { + 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); + } + + /** + * @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); + } + + /** + * @return OK + */ + categories(wizardId: string): Promise { + let url_ = this.baseUrl + "/api/v1/uploads/categories?"; + if (wizardId === undefined || wizardId === null) + throw new globalThis.Error("The parameter 'wizardId' must be defined and cannot be null."); + else + url_ += "wizardId=" + encodeURIComponent("" + wizardId) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processCategories(_response); + }); + } + + protected processCategories(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UploadCategoriesDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @param localIds (optional) + * @return OK + */ + status(localIds?: string | undefined): Promise { + let url_ = this.baseUrl + "/api/v1/uploads/status?"; + if (localIds === null) + throw new globalThis.Error("The parameter 'localIds' cannot be null."); + else if (localIds !== undefined) + url_ += "localIds=" + encodeURIComponent("" + localIds) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processStatus(_response); + }); + } + + protected processStatus(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UploadStatusDto; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + uploads(documentId: string): Promise { + let url_ = this.baseUrl + "/api/v1/uploads/{documentId}"; + if (documentId === undefined || documentId === null) + throw new globalThis.Error("The parameter 'documentId' must be defined."); + url_ = url_.replace("{documentId}", encodeURIComponent("" + documentId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processUploads(_response); + }); + } + + protected processUploads(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status === 409) { + return response.text().then((_responseText) => { + let result409: any = null; + result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Conflict", status, _responseText, _headers, result409); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + uploads2(documentId: string): Promise { + let url_ = this.baseUrl + "/api/v1/admin/uploads/{documentId}"; + if (documentId === undefined || documentId === null) + throw new globalThis.Error("The parameter 'documentId' must be defined."); + url_ = url_.replace("{documentId}", encodeURIComponent("" + documentId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processUploads2(_response); + }); + } + + protected processUploads2(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + return throwException("Forbidden", status, _responseText, _headers); + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } +} + +export interface AantekeningDto { + type?: string | undefined; + omschrijving?: string | undefined; + datum?: string | undefined; +} + +export interface AdresDto { + straat?: string | undefined; + postcode?: string | undefined; + woonplaats?: string | undefined; +} + +export interface BrpAddressDto { + gevonden?: boolean; + adres?: AdresDto; +} + +export interface ChangeRequestRequest { + straat?: string | undefined; + postcode?: string | undefined; + woonplaats?: string | undefined; +} + +export interface DashboardViewDto { + registration?: RegistrationDto; + person?: PersonDto; + decisions?: HerregistratieDecisionsDto; +} + +export interface DocumentCategoryDto { + categoryId?: string | undefined; + label?: string | undefined; + description?: string | undefined; + required?: boolean; + acceptedTypes?: string[] | undefined; + maxSizeMb?: number; + multiple?: boolean; + allowPostDelivery?: boolean; +} + +export interface DocumentRefDto { + categoryId?: string | undefined; + channel?: string | undefined; + documentId?: string | undefined; +} + +export interface DuoDiplomaDto { + id?: string | undefined; + naam?: string | undefined; + instelling?: string | undefined; + jaar?: number; + beroep?: string | undefined; + policyQuestions?: PolicyQuestionDto[] | undefined; +} + +export interface DuoLookupDto { + diplomas?: DuoDiplomaDto[] | undefined; + handmatig?: ManualDiplomaPolicyDto; +} + +export interface HerregistratieDecisionsDto { + eligibleForHerregistratie?: boolean; + herregistratieReason?: string | undefined; +} + +export interface HerregistratieRequest { + uren?: number; + documents?: DocumentRefDto[] | undefined; +} + +export interface IntakePolicyDto { + scholingThreshold?: number; +} + +export interface IntakeRequest { + uren?: number; +} + +export interface ManualDiplomaPolicyDto { + beroepen?: string[] | undefined; + policyQuestions?: PolicyQuestionDto[] | undefined; +} + +export interface PersonDto { + naam?: string | undefined; + geboortedatum?: string | undefined; + adres?: AdresDto; +} + +export interface PolicyQuestionDto { + id?: string | undefined; + vraag?: string | undefined; + type?: string | undefined; +} + +export interface ProblemDetails { + type?: string | undefined; + title?: string | undefined; + status?: number | undefined; + detail?: string | undefined; + instance?: string | undefined; + + [key: string]: any; +} + +export interface ReferentieResponse { + referentie?: string | undefined; +} + +export interface RegistratieRequest { + diplomaHerkomst?: string | undefined; + documents?: DocumentRefDto[] | undefined; +} + +export interface RegistrationDto { + bigNummer?: string | undefined; + naam?: string | undefined; + beroep?: string | undefined; + registratiedatum?: string | undefined; + geboortedatum?: string | undefined; + status?: RegistrationStatusDto; +} + +export interface RegistrationStatusDto { + tag?: string | undefined; + herregistratieDatum?: string | undefined; + geschorstTot?: string | undefined; + reden?: string | undefined; + doorgehaaldOp?: string | undefined; +} + +export interface UploadCategoriesDto { + categories?: DocumentCategoryDto[] | undefined; +} + +export interface UploadStatusDto { + results?: UploadStatusItemDto[] | undefined; +} + +export interface UploadStatusItemDto { + localId?: string | undefined; + status?: string | undefined; + documentId?: string | undefined; +} + +export class SwaggerException extends Error { + override message: string; + status: number; + response: string; + headers: { [key: string]: any; }; + result: any; + + constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) { + super(); + + this.message = message; + this.status = status; + this.response = response; + this.headers = headers; + this.result = result; + } + + protected isSwaggerException = true; + + static isSwaggerException(obj: any): obj is SwaggerException { + return obj.isSwaggerException === true; + } +} + +function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any { + if (result !== null && result !== undefined) + throw result; + else + throw new SwaggerException(message, status, response, headers, null); +} \ No newline at end of file diff --git a/src/app/shared/infrastructure/api-error.spec.ts b/src/app/shared/infrastructure/api-error.spec.ts new file mode 100644 index 0000000..4193360 --- /dev/null +++ b/src/app/shared/infrastructure/api-error.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { problemDetail, problemFieldErrors } from './api-error'; + +describe('problemDetail', () => { + it('extracts the detail from an RFC-7807 ProblemDetails', () => { + expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe('Afgewezen: 0 uren.'); + }); + + it('falls back when there is no detail', () => { + expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback'); + expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback'); + expect(problemDetail(undefined, 'fallback')).toBe('fallback'); + }); +}); + +describe('problemFieldErrors (G4 seam)', () => { + it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => { + expect(problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } })) + .toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' }); + }); + + it('returns {} when there is no errors envelope (the current backend shape)', () => { + expect(problemFieldErrors({ detail: 'one banner' })).toEqual({}); + expect(problemFieldErrors(new Error('boom'))).toEqual({}); + expect(problemFieldErrors(undefined)).toEqual({}); + }); +}); diff --git a/src/app/shared/infrastructure/api-error.ts b/src/app/shared/infrastructure/api-error.ts new file mode 100644 index 0000000..e8cf95b --- /dev/null +++ b/src/app/shared/infrastructure/api-error.ts @@ -0,0 +1,36 @@ +import { ProblemDetails } from './api-client'; + +/** + * Extract a human-readable message from a rejected API call. A 4xx/5xx with a + * ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed + * object; anything else falls back to the given message. + */ +export function problemDetail(e: unknown, fallback: string): string { + if (e && typeof e === 'object' && 'detail' in e) { + const detail = (e as ProblemDetails).detail; + if (typeof detail === 'string' && detail) return detail; + } + return fallback; +} + +/** + * SEAM (G4): map a server validation envelope to field-level errors. + * + * ASP.NET's ValidationProblemDetails carries `errors: { field: string[] }`. The + * backend today returns only `detail` (one banner message), so this returns `{}`. + * When the backend starts sending `errors`, a machine's `SubmitFailed` handler can + * merge this into its own `errors` map — the field-keyed shape the wizards already + * render — so a rejection shows inline per field, not just as a banner. The + * consumer hook is the only thing left to wire; the contract boundary lives here. + */ +export function problemFieldErrors(e: unknown): Record { + if (!e || typeof e !== 'object' || !('errors' in e)) return {}; + const errors = (e as { errors?: unknown }).errors; + if (!errors || typeof errors !== 'object') return {}; + const out: Record = {}; + for (const [field, msgs] of Object.entries(errors as Record)) { + const first = Array.isArray(msgs) ? msgs[0] : msgs; + if (typeof first === 'string') out[field] = first; + } + return out; +} diff --git a/src/app/shared/infrastructure/scenario.interceptor.ts b/src/app/shared/infrastructure/scenario.interceptor.ts index cc9cbe7..86fc607 100644 --- a/src/app/shared/infrastructure/scenario.interceptor.ts +++ b/src/app/shared/infrastructure/scenario.interceptor.ts @@ -4,12 +4,12 @@ import { delay } from 'rxjs/operators'; import { currentScenario } from './scenario'; /** - * Demo-only: rewrites the timing/outcome of mock data requests based on + * Demo-only: rewrites the timing/outcome of API data requests based on * ?scenario= so loading / empty / error states can be shown on demand. - * Real requests are untouched. + * Non-API requests are untouched. */ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => { - if (!req.url.includes('mock/')) return next(req); + if (!req.url.includes('/api/')) return next(req); switch (currentScenario()) { case 'slow': @@ -17,7 +17,8 @@ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => { case 'loading': return next(req).pipe(delay(600_000)); // effectively never resolves case 'empty': - return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400)); + // '[]' so the typed client parses it to an empty array (notes → Empty state). + return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400)); case 'error': return timer(400).pipe( switchMap(() => throwError(() => diff --git a/src/app/shared/infrastructure/scenario.ts b/src/app/shared/infrastructure/scenario.ts index 9bcd86b..33e78dc 100644 --- a/src/app/shared/infrastructure/scenario.ts +++ b/src/app/shared/infrastructure/scenario.ts @@ -1,6 +1,15 @@ -export type Scenario = 'default' | 'slow' | 'loading' | 'empty' | 'error'; +export type Scenario = + | 'default' + | 'slow' + | 'loading' + | 'empty' + | 'error' + // upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP + // interceptor — these are simulated in upload.adapter.ts instead): + | 'upload-slow' + | 'upload-fail'; -const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error']; +const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error', 'upload-slow', 'upload-fail']; /** Reads ?scenario= from the URL so a demo can force each async state. */ export function currentScenario(): Scenario { diff --git a/src/app/shared/kernel/fp.ts b/src/app/shared/kernel/fp.ts index 872f495..6dbe5dc 100644 --- a/src/app/shared/kernel/fp.ts +++ b/src/app/shared/kernel/fp.ts @@ -21,3 +21,13 @@ export const err = (error: E): Result => ({ ok: false, error }); /** Nominal typing: Brand is assignable from a plain string only through an explicit cast — so a smart constructor is the only minter. */ export type Brand = T & { readonly __brand: B }; + +/** Narrow a tagged union to one variant by its `tag`, or null. The single place + the cast lives — TS can't narrow through a runtime tag argument, so callers get + `whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */ +export function whenTag( + u: U, + tag: K, +): Extract | null { + return u.tag === tag ? (u as Extract) : null; +} diff --git a/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts b/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts new file mode 100644 index 0000000..87776d3 --- /dev/null +++ b/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts @@ -0,0 +1,32 @@ +import { BreadcrumbItem } from './breadcrumb.component'; + +/** Route → breadcrumb label + parent. The app has a small fixed route set + (see app.routes.ts), so a static map is enough — no per-page wiring. + ponytail: static map, not a breadcrumb service; revisit if routes go dynamic. */ +interface Crumb { label: string; parent?: string } + +const ROUTES: Record = { + '/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` }, + '/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' }, + '/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' }, + '/herregistratie': { label: $localize`:@@crumb.herregistratie:Herregistratie`, parent: '/dashboard' }, + '/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' }, + '/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' }, +}; + +/** Build the breadcrumb trail for a router url (query/fragment stripped). + Returns [] for unknown routes (e.g. /login) so the bar can hide itself. */ +export function trailFor(url: string): BreadcrumbItem[] { + const path = url.split(/[?#]/)[0]; + const trail: BreadcrumbItem[] = []; + let cursor: string | undefined = path; + while (cursor) { + const node: Crumb | undefined = ROUTES[cursor]; + if (!node) break; + trail.unshift({ label: node.label, link: cursor }); + cursor = node.parent; + } + // The current (last) page is not a link. + if (trail.length) delete trail[trail.length - 1].link; + return trail; +} diff --git a/src/app/shared/layout/breadcrumb/breadcrumb.component.ts b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts index 7c17e3d..a67c9b5 100644 --- a/src/app/shared/layout/breadcrumb/breadcrumb.component.ts +++ b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts @@ -14,19 +14,27 @@ export interface BreadcrumbItem { imports: [RouterLink], styles: [` :host{display:block} - .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0} + .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0;font-size:var(--rhc-text-font-size-sm)} + .crumb{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)} + a{color:var(--rhc-color-foreground-link);text-decoration:underline} + a:hover{color:var(--rhc-color-foreground-link-hover)} + .current{color:var(--rhc-color-foreground-subtle)} .sep{color:var(--rhc-color-foreground-subtle)} + /* Inverse: on the blue header bar, everything is white. */ + :host(.inverse) a, + :host(.inverse) .current, + :host(.inverse) .sep { color: var(--rhc-color-foreground-on-primary); } `], template: ` -
TypeOmschrijvingDatumTypeOmschrijvingDatum