diff --git a/CLAUDE.md b/CLAUDE.md index 6223513..16e7123 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,15 +8,20 @@ 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 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). 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..80f91d6 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,102 @@ +# 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**. + +## 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..5b2df82 --- /dev/null +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -0,0 +1,55 @@ +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); + +// 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); +public sealed record IntakeRequest(int Uren); +public sealed record HerregistratieRequest(int Uren); + +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..44e4883 --- /dev/null +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -0,0 +1,33 @@ +using BigRegister.Domain.Diplomas; +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()); +} 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/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..42557b8 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs @@ -0,0 +1,23 @@ +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; + + 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..5730da8 --- /dev/null +++ b/backend/src/BigRegister.Api/Program.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using BigRegister.Domain.Diplomas; +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); + +var api = app.MapGroup("/api"); + +// --- 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) => +{ + var reject = SubmissionRules.RejectRegistratie(req.DiplomaHerkomst); + return reject is null + ? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference())) + : Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); +}) +.Produces() +.ProducesProblem(StatusCodes.Status422UnprocessableEntity); + +api.MapPost("/herregistraties", (HerregistratieRequest req) => +{ + var reject = SubmissionRules.RejectZeroUren(req.Uren); + return reject is null + ? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference())) + : Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); +}) +.Produces() +.ProducesProblem(StatusCodes.Status422UnprocessableEntity); + +api.MapPost("/intakes", (IntakeRequest req) => +{ + var reject = SubmissionRules.RejectZeroUren(req.Uren); + return reject is null + ? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference())) + : Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); +}) +.Produces() +.ProducesProblem(StatusCodes.Status422UnprocessableEntity); + +app.Run(); + +// 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..21ab64b --- /dev/null +++ b/backend/swagger.json @@ -0,0 +1,545 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "BIG-register BFF", + "version": "v1" + }, + "paths": { + "/api/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/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/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/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/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/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/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/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" + } + } + } + } + } + } + } + }, + "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 + }, + "DashboardViewDto": { + "type": "object", + "properties": { + "registration": { + "$ref": "#/components/schemas/RegistrationDto" + }, + "person": { + "$ref": "#/components/schemas/PersonDto" + }, + "decisions": { + "$ref": "#/components/schemas/HerregistratieDecisionsDto" + } + }, + "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" + } + }, + "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 + } + }, + "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 + } + } + }, + "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..2cb5e36 --- /dev/null +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +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/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/notes"); + Assert.Equal(3, notes!.Count); + } + + [Fact] + public async Task Brp_returns_address() + { + var dto = await _client.GetFromJsonAsync("/api/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/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/intake/policy"); + Assert.Equal(1000, dto!.ScholingThreshold); + } + + [Fact] + public async Task Registration_with_duo_diploma_succeeds() + { + var res = await _client.PostAsJsonAsync("/api/registrations", new RegistratieRequest("duo")); + 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/registrations", new RegistratieRequest("handmatig")); + Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); + Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString()); + } + + [Theory] + [InlineData("/api/intakes")] + [InlineData("/api/herregistraties")] + 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/intakes")] + [InlineData("/api/herregistraties")] + public async Task Worked_hours_submission_succeeds(string route) + { + var res = await _client.PostAsJsonAsync(route, new { uren = 40 }); + res.EnsureSuccessStatusCode(); + } +} diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs new file mode 100644 index 0000000..c77c7a1 --- /dev/null +++ b/backend/tests/BigRegister.Tests/RuleTests.cs @@ -0,0 +1,117 @@ +using BigRegister.Domain.Diplomas; +using BigRegister.Domain.Registrations; +using BigRegister.Domain.Submissions; + +namespace BigRegister.Tests; + +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)); +} 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/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..4e99026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@storybook/addon-onboarding": "^10.4.6", "@storybook/angular": "^10.4.6", "jsdom": "^29.0.0", + "nswag": "^14.7.1", "prettier": "^3.8.1", "storybook": "^10.4.6", "typescript": "~6.0.2", @@ -15276,6 +15277,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", diff --git a/package.json b/package.json index bedcd90..f869f15 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,14 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "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 +27,24 @@ "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/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", + "jsdom": "^29.0.0", + "nswag": "^14.7.1", + "prettier": "^3.8.1", + "storybook": "^10.4.6", + "typescript": "~6.0.2", + "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": { 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..86e6b2c 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -6,6 +6,7 @@ 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'; registerLocaleData(localeNl); @@ -14,6 +15,7 @@ export const appConfig: ApplicationConfig = { provideBrowserGlobalErrorListeners(), provideRouter(routes, withViewTransitions()), provideHttpClient(withInterceptors([scenarioInterceptor])), + provideApiClient(), { provide: LOCALE_ID, useValue: 'nl' }, ] }; diff --git a/src/app/herregistratie/application/submit-herregistratie.ts b/src/app/herregistratie/application/submit-herregistratie.ts index d566653..bc8aef5 100644 --- a/src/app/herregistratie/application/submit-herregistratie.ts +++ b/src/app/herregistratie/application/submit-herregistratie.ts @@ -1,14 +1,18 @@ import { Result, ok, err } from '@shared/kernel/fp'; import { Valid } from '../domain/herregistratie.machine'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { problemDetail } from '@shared/infrastructure/api-error'; /** - * 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 now lives server-side; the backend returns a 422 + * ProblemDetails on rejection, which we surface as the error. */ -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 async function submitHerregistratie(client: ApiClient, data: Valid): Promise> { + try { + await client.herregistraties({ uren: data.uren }); + return ok(undefined); + } catch (e) { + return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.')); + } } 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..fb28403 100644 --- a/src/app/herregistratie/application/submit-intake.ts +++ b/src/app/herregistratie/application/submit-intake.ts @@ -1,14 +1,18 @@ import { Result, ok, err } from '@shared/kernel/fp'; import { ValidIntake } from '../domain/intake.machine'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { problemDetail } from '@shared/infrastructure/api-error'; /** - * 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 now lives server-side; the backend returns a 422 + * ProblemDetails on rejection, which we surface as the error. */ -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 async function submitIntake(client: ApiClient, data: ValidIntake): Promise> { + try { + await client.intakes({ uren: data.uren }); + return ok(undefined); + } catch (e) { + return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.')); + } } 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/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 2719752..b38ee73 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,13 @@ 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 { 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'; /** 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 +18,47 @@ 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], template: ` - @switch (state().tag) { - @case ('Editing') { -

Stap {{ step() }} van 2

-
- @if (step() === 1) { - - - - - - -
- Volgende - Annuleren -
- } @else { - - - -
- Vorige - Herregistratie aanvragen - Annuleren -
- } -
+ + + @if (step() === 1) { + + + + + + + } @else { + + + } - @case ('Submitting') { - Aanvraag wordt verwerkt… - } - @case ('Submitted') { + +
Uw aanvraag tot herregistratie is ontvangen. - } - @case ('Failed') { - Indienen mislukt: {{ failedError() }} -
- Opnieuw proberen -
- } - } +
+
`, }) 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,6 +67,10 @@ export class HerregistratieWizardComponent { readonly state = this.store.model; // public so the showcase can highlight the live state protected dispatch = this.store.dispatch; + // Stepper labels + per-step heading titles (presentational only). + readonly stepLabels = ['Werkervaring', 'Nascholing']; + private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing']; + private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null)); protected step = computed(() => this.editing()?.step ?? 1); protected draft = computed(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' }); @@ -83,6 +79,26 @@ export class HerregistratieWizardComponent { protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); + // --- Presentational wiring for the shared wizard shell --------------------- + protected stepTitle = computed(() => this.stepTitles[this.step() - 1]); + protected primaryLabel = computed(() => (this.step() === 1 ? 'Volgende' : 'Herregistratie aanvragen')); + protected errorMessage = computed(() => `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() })); } @@ -110,7 +126,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.page.ts b/src/app/herregistratie/ui/herregistratie.page.ts index 81480a3..65985a3 100644 --- a/src/app/herregistratie/ui/herregistratie.page.ts +++ b/src/app/herregistratie/ui/herregistratie.page.ts @@ -20,7 +20,7 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan. -
+
} @else { 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..6b5d0dc 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -1,12 +1,12 @@ -import { Component, computed, effect, inject, input, untracked } from '@angular/core'; -import { httpResource } from '@angular/common/http'; +import { Component, computed, effect, inject, input, resource, 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 { 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 { BigProfileStore } from '@registratie/application/big-profile.store'; import { @@ -20,8 +20,8 @@ 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'; 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' }]; @@ -33,97 +33,90 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; localStorage so a page reload keeps the user's progress. */ @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
- } - @case ('Failed') { - Indienen mislukt: {{ failedError() }} -
- Opnieuw proberen -
- } - } +
+ `, }) export class IntakeWizardComponent { private profile = inject(BigProfileStore); + private apiClient = inject(ApiClient); 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 + // (`GET /api/intake/policy`), not hardcoded. The backend stays the authority + // and re-validates on submit. + private policyRes = resource({ loader: () => this.apiClient.policy() }); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); @@ -144,6 +137,33 @@ export class IntakeWizardComponent { protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); + // --- Presentational wiring for the shared wizard shell --------------------- + readonly stepLabels = ['Buitenland', 'Werk', 'Controle']; + private stepTitles: Record = { + buitenland: 'Werken in het buitenland', + werk: 'Werkervaring in Nederland', + review: 'Controleren en indienen', + }; + protected stepTitle = computed(() => this.stepTitles[this.step()]); + protected primaryLabel = computed(() => (this.step() === 'review' ? 'Aanvraag indienen' : 'Volgende')); + protected errorMessage = computed(() => `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 }); @@ -161,7 +181,7 @@ export class IntakeWizardComponent { // 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 })); }); } @@ -198,7 +218,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..dbd5a7f 100644 --- a/src/app/herregistratie/ui/intake.page.ts +++ b/src/app/herregistratie/ui/intake.page.ts @@ -15,7 +15,7 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w 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-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..40183f4 100644 --- a/src/app/registratie/application/submit-registratie.ts +++ b/src/app/registratie/application/submit-registratie.ts @@ -1,19 +1,20 @@ import { Result, ok, err } from '@shared/kernel/fp'; import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { problemDetail } from '@shared/infrastructure/api-error'; /** - * 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 business rule that a manually entered + * diploma cannot be auto-verified now lives server-side; the backend returns it as + * a 422 ProblemDetails, which we surface as the error. 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.'); +export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise> { + try { + const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst }); + return ok(res.referentie ?? ''); + } catch (e) { + return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.')); } - const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000); - return ok(referentie); } 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..aec6497 100644 --- a/src/app/registratie/infrastructure/dashboard-view.adapter.ts +++ b/src/app/registratie/infrastructure/dashboard-view.adapter.ts @@ -1,20 +1,22 @@ -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. 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/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index 55b6b3b..4586777 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -18,61 +18,69 @@ import { BigProfileStore } from '@registratie/application/big-profile.store'; ], template: ` - @if (store.pendingHerregistratie()) { - Uw herregistratie-aanvraag is in behandeling. - } +
+ @if (store.pendingHerregistratie()) { + Uw herregistratie-aanvraag is in behandeling. + } - - - - -
- Persoonsgegevens (BRP) -
- - - -
-
-
- - - -
- -
- Specialismen en aantekeningen - - - + + + + +
+ Persoonsgegevens (BRP) +
+ + + +
+
- - - -

U heeft nog geen specialismen of aantekeningen.

+
-
-

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

-

- Gegevens bekijken of een wijziging doorgeven → -

-

- Herregistratie aanvragen → -

-

- Herregistratie-intake (vragenlijst met vertakkingen) → -

-

- Functionele patronen (impossible states) → -

+
+ Specialismen en aantekeningen + + + + + + + + +

U heeft nog geen specialismen of aantekeningen.

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

    {{ a.tekst }}

    + {{ a.actie }} → +
  • + } +
+
+
`, }) export class DashboardPage { protected store = inject(BigProfileStore); + + /** Primary actions, rendered as an accessible card grid. */ + protected readonly acties = [ + { to: '/registreren', titel: 'Inschrijven', tekst: 'Schrijf u in in het BIG-register via de registratiewizard.', actie: 'Start inschrijving' }, + { to: '/registratie', titel: 'Mijn gegevens', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' }, + { to: '/herregistratie', titel: 'Herregistratie', tekst: 'Vraag uw herregistratie aan.', actie: 'Vraag aan' }, + { to: '/intake', titel: 'Herregistratie-intake', tekst: 'Vragenlijst met vertakkingen.', actie: 'Start intake' }, + { to: '/concepts', titel: 'Functionele patronen', tekst: 'Onmogelijke toestanden onmogelijk maken.', actie: 'Bekijk patronen' }, + ]; } 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..5f7aba9 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -1,14 +1,13 @@ -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 { 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'; @@ -28,6 +27,7 @@ import { STEPS, } from '@registratie/domain/registratie-wizard.machine'; import { submitRegistratie } from '@registratie/application/submit-registratie'; +import { ApiClient } from '@shared/infrastructure/api-client'; 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' }]; @@ -44,17 +44,27 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" selector: 'app-registratie-wizard', imports: [ FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, - AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, + AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent, AddressFieldsComponent, ...ASYNC, ], template: ` - @switch (state().tag) { - @case ('Invullen') { - -

{{ stepTitle() }}

-
- @switch (step()) { - @case ('adres') { + + + @switch (step()) { + @case ('adres') { @if (adresStatus() === 'laden') { } @else { @@ -140,38 +150,22 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" 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.
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(); @@ -186,9 +180,6 @@ export class RegistratieWizardComponent { 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)); protected cursor = computed(() => this.invullen()?.cursor ?? 0); protected draft = computed(() => this.invullen()?.draft ?? { antwoorden: {} }); @@ -196,6 +187,30 @@ export class RegistratieWizardComponent { 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 : '')); + + // --- Presentational wiring for the shared wizard shell --------------------- + protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende')); + protected errorMessage = computed(() => `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(', '); @@ -302,18 +317,8 @@ 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 { @@ -350,7 +355,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/shared/infrastructure/api-client.provider.ts b/src/app/shared/infrastructure/api-client.provider.ts new file mode 100644 index 0000000..de4a978 --- /dev/null +++ b/src/app/shared/infrastructure/api-client.provider.ts @@ -0,0 +1,49 @@ +import { Provider } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { ApiClient, ProblemDetails } from './api-client'; + +/** + * Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated + * client expects, so every API call flows through HttpClient interceptors — the + * `?scenario=` toggle (scenario.interceptor.ts) and any future auth header. The + * generated client is the only place HTTP shapes are known; this is the only + * place it meets Angular's HTTP stack. + */ +function httpClientFetch(http: HttpClient) { + return { + async fetch(url: RequestInfo, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + const headers = (init?.headers ?? {}) as Record; + try { + const res = await firstValueFrom( + http.request(method, url as string, { + body: init?.body as string | undefined, + headers, + observe: 'response', + responseType: 'text', + }), + ); + return new Response(res.body ?? '', { status: res.status || 200 }); + } catch (e) { + 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 (relative `/api` base URL). */ +export function provideApiClient(): Provider { + return { + provide: ApiClient, + useFactory: (http: HttpClient) => new ApiClient('', 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..5d4f07b --- /dev/null +++ b/src/app/shared/infrastructure/api-client.ts @@ -0,0 +1,474 @@ +//---------------------- +// +// 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 + */ + dashboardView(): Promise { + let url_ = this.baseUrl + "/api/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/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/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/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/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/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/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/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); + } +} + +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 DashboardViewDto { + registration?: RegistrationDto; + person?: PersonDto; + decisions?: HerregistratieDecisionsDto; +} + +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; +} + +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; +} + +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 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..8572810 --- /dev/null +++ b/src/app/shared/infrastructure/api-error.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { problemDetail } 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'); + }); +}); diff --git a/src/app/shared/infrastructure/api-error.ts b/src/app/shared/infrastructure/api-error.ts new file mode 100644 index 0000000..f65dbb4 --- /dev/null +++ b/src/app/shared/infrastructure/api-error.ts @@ -0,0 +1,14 @@ +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; +} 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/layout/wizard-shell/wizard-shell.component.ts b/src/app/shared/layout/wizard-shell/wizard-shell.component.ts new file mode 100644 index 0000000..a07b935 --- /dev/null +++ b/src/app/shared/layout/wizard-shell/wizard-shell.component.ts @@ -0,0 +1,115 @@ +import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core'; +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 { StepperComponent } from '@shared/ui/stepper/stepper.component'; + +/** A flat validation error pointing at a field: `id` matches the field's anchor. */ +export interface WizardError { + readonly id: string; + readonly message: string; +} + +export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed'; + +/** + * Template: the canonical shell every wizard renders into, so they cannot drift. + * It owns the consistent outline — stepper + focusable step heading + error + * summary + the
+ the Back/Next/Cancel action bar + the submitting/ + * submitted/failed states — and the a11y focus management. + * + * Presentational and unidirectional: all state stays in the wizard container + * (the Elm-style store). Inputs flow down; the container reacts to the outputs + * and dispatches messages. The step's own fields are projected as the default + * slot; the success screen is projected via [wizardSuccess]. + */ +@Component({ + selector: 'app-wizard-shell', + imports: [ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent], + styles: [` + .es-title{margin:0 0 var(--rhc-space-max-sm)} + .es-list{margin:0;padding-inline-start:var(--rhc-space-max-xl)} + `], + template: ` + @switch (status()) { + @case ('editing') { + +

{{ stepTitle() }}

+ @if (errors().length) { + + } + + +
+ @if (canGoBack()) { + Vorige + } + {{ primaryLabel() }} + Annuleren +
+ + } + @case ('submitting') { + {{ submittingLabel() }} + } + @case ('submitted') { + + } + @case ('failed') { + {{ errorMessage() }} +
+ Opnieuw proberen +
+ } + } + `, +}) +export class WizardShellComponent { + steps = input.required(); + current = input.required(); + stepTitle = input.required(); + status = input.required(); + primaryLabel = input.required(); + canGoBack = input(false); + errors = input([]); + errorMessage = input(''); + submittingLabel = input('Aanvraag wordt verwerkt…'); + + primary = output(); + back = output(); + cancel = output(); + retry = output(); + + private stepHeading = viewChild>('stepHeading'); + private errorSummary = viewChild>('errorSummary'); + + constructor() { + // A11y: move focus to the step heading when the step changes (skip first run + // so we don't grab focus on initial load). Tracks current(), which is value- + // stable across keystrokes, so typing never steals focus. + let firstStep = true; + effect(() => { + this.current(); + if (firstStep) { firstStep = false; return; } + untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus())); + }); + // A11y: when validation errors appear (after a failed submit), move focus to + // the error summary so it's announced. The reducer returns a fresh errors + // object per attempt, so resubmitting the same invalid step re-announces. + let firstErr = true; + effect(() => { + const has = this.errors().length > 0; + if (firstErr) { firstErr = false; return; } + if (has) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus())); + }); + } +} diff --git a/src/app/shared/layout/wizard-shell/wizard-shell.stories.ts b/src/app/shared/layout/wizard-shell/wizard-shell.stories.ts new file mode 100644 index 0000000..29f3a09 --- /dev/null +++ b/src/app/shared/layout/wizard-shell/wizard-shell.stories.ts @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { WizardShellComponent } from './wizard-shell.component'; + +const meta: Meta = { + title: 'Templates/WizardShell', + component: WizardShellComponent, + render: (args) => ({ + props: args, + template: ` + +

Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).

+

Uw aanvraag is ontvangen.

+
`, + }), +}; +export default meta; +type Story = StoryObj; + +const steps = ['Adres', 'Beroep', 'Controle']; +const base = { steps, current: 1, stepTitle: 'Beroep op basis van uw diploma', primaryLabel: 'Volgende', canGoBack: true, errors: [], errorMessage: '' }; + +export const Editing: Story = { args: { ...base, status: 'editing' } }; +export const EditingMetFouten: Story = { + args: { + ...base, + status: 'editing', + errors: [ + { id: 'uren', message: 'Vul het aantal gewerkte uren in.' }, + { id: 'diploma', message: 'Kies een diploma.' }, + ], + }, +}; +export const Submitting: Story = { args: { ...base, status: 'submitting' } }; +export const Submitted: Story = { args: { ...base, status: 'submitted' } }; +export const Failed: Story = { args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' } }; diff --git a/src/styles.scss b/src/styles.scss index 9ac751d..5094da2 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -31,6 +31,15 @@ html, body { margin: 0; min-height: 100%; } .app-stack > * + * { margin-block-start: var(--rhc-space-max-xl); } .app-section { margin-block-start: var(--rhc-space-max-2xl); } .app-text-subtle { color: var(--rhc-color-foreground-subtle); } +/* responsive card grid: cards wrap and stretch, gap from the spacing scale */ +.app-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--rhc-space-max-xl); + list-style: none; + margin: 0; + padding: 0; +} /* Route transitions (withViewTransitions): cross-fade the routed CONTENT only. The chrome gets its own stable view-transition-name so it's lifted out of the