Add ASP.NET Core backend hosting business rules; FE consumes via typed client

Move the authoritative business rules off the frontend into a real backend,
realising the BFF-lite + decision-DTO design (ADR-0001) that until now lived
only in static mock JSON.

Backend (backend/):
- ASP.NET Core (.NET 10) minimal API, contract-first, Swagger UI at /swagger.
- DDD Domain/ rules layer: profession derivation + applicable policy questions
  (DiplomaRules), herregistratie eligibility + reason (HerregistratieRule),
  scholing threshold (IntakePolicy), submit rejections + reference generation
  (SubmissionRules). In-memory seeded data, ProblemDetails (RFC 7807) errors.
- 27 xUnit tests: rule units + endpoint integration incl. BRP no-address and
  DUO not-found fallbacks and 422 submit paths.

Frontend (only infrastructure/ + contracts/ change, as the architecture promised):
- NSwag-generated typed client (api-client.ts), routed through Angular HttpClient
  via a small fetch adapter so the ?scenario= interceptor still applies.
- GET adapters use resource({ loader: client.x }); submit commands call the client
  and map ProblemDetails -> err. The hardcoded uren==0 / manual-diploma rules are
  deleted (now server-side). Domain, stores, UI and format validators unchanged.
- Deleted the now-dead public/mock/*.json.

Tooling/docs:
- npm start proxies /api -> backend; npm run gen:api regenerates the client;
  docker compose up runs both (bind mounts use :z for SELinux/Fedora).
- backend/README.md walkthrough: adding a policy question is a one-file backend
  change, no FE change, no client regen. Updated CLAUDE.md + ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 20:05:53 +02:00
parent 4e9af05cc1
commit cf570a8132
62 changed files with 2618 additions and 394 deletions

2
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bin/
obj/

8
backend/BigRegister.slnx Normal file
View File

@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/BigRegister.Api/BigRegister.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/BigRegister.Tests/BigRegister.Tests.csproj" />
</Folder>
</Solution>

102
backend/README.md Normal file
View File

@@ -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: <http://localhost:4200>
- Swagger UI: <http://localhost:5000/swagger>
### 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<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
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.

13
backend/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "10.2.3",
"commands": [
"swagger"
],
"rollForward": false
}
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
</Project>

View File

@@ -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<PolicyQuestionDto> PolicyQuestions);
public sealed record ManualDiplomaPolicyDto(IReadOnlyList<string> Beroepen, IReadOnlyList<PolicyQuestionDto> PolicyQuestions);
public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> 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);

View File

@@ -0,0 +1,33 @@
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations;
namespace BigRegister.Api.Contracts;
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
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());
}

View File

@@ -0,0 +1,42 @@
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations;
namespace BigRegister.Api.Data;
/// <summary>
/// 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.
/// </summary>
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"));
/// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres;
public static readonly IReadOnlyList<Diploma> 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"),
};
}

View File

@@ -0,0 +1,22 @@
namespace BigRegister.Domain.Diplomas;
public enum QuestionType
{
JaNee,
Tekst,
}
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
/// <summary>
/// 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 (<see cref="Opleiding"/>, <see cref="Engelstalig"/>).
/// </summary>
public sealed record Diploma(
string Id,
string Naam,
string Instelling,
int Jaar,
string Opleiding,
bool Engelstalig);

View File

@@ -0,0 +1,68 @@
namespace BigRegister.Domain.Diplomas;
/// <summary>
/// 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.
/// </summary>
public static class DiplomaRules
{
// RULE: study program → BIG profession.
private static readonly Dictionary<string, string> 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";
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> 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);
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
if (d.Engelstalig)
questions.Add(NlTaalEngelstalig);
return questions;
}
/// <summary>
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
/// </summary>
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
new[] { NlTaalManual, DiplomaErkend, Toelichting };
}

View File

@@ -0,0 +1,11 @@
namespace BigRegister.Domain.Intake;
/// <summary>
/// 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.
/// </summary>
public static class IntakePolicy
{
public const int ScholingThreshold = 1000;
}

View File

@@ -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);

View File

@@ -0,0 +1,32 @@
namespace BigRegister.Domain.Registrations;
/// <summary>
/// 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.
/// </summary>
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}.");
}
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
}

View File

@@ -0,0 +1,28 @@
namespace BigRegister.Domain.Registrations;
/// <summary>The three states a BIG registration can be in.</summary>
public enum StatusTag
{
Geregistreerd,
Geschorst,
Doorgehaald,
}
/// <summary>
/// Status as a flat record: only <see cref="StatusTag.Geregistreerd"/> carries a
/// herregistratie deadline. The frontend mirrors this as a discriminated union.
/// </summary>
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);

View File

@@ -0,0 +1,23 @@
namespace BigRegister.Domain.Submissions;
/// <summary>
/// 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.
/// </summary>
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);
}

View File

@@ -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<ReferentieResponse>()
.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<ReferentieResponse>()
.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<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
app.Run();
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
public partial class Program { }

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

545
backend/swagger.json Normal file
View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task DashboardView_computes_eligibility_decision()
{
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/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<List<AantekeningDto>>("/api/notes");
Assert.Equal(3, notes!.Count);
}
[Fact]
public async Task Brp_returns_address()
{
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/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<DuoLookupDto>("/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<IntakePolicyDto>("/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<ReferentieResponse>();
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();
}
}

View File

@@ -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));
}