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:
eho
2026-06-26 20:05:53 +02:00
co-authored by Claude Opus 4.8
parent 4e9af05cc1
commit cf570a8132
62 changed files with 2618 additions and 394 deletions
@@ -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);
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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;
}
@@ -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);
@@ -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);
}