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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user