Compare commits
3
Commits
989a32acb4
...
a7f737e18c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f737e18c | ||
|
|
fa7e9c5cfb | ||
|
|
14210fa2b0 |
@@ -117,6 +117,12 @@ touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-own
|
||||
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
|
||||
but the FE doesn't call them.
|
||||
|
||||
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
|
||||
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
|
||||
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
|
||||
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
|
||||
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
|
||||
|
||||
### 5. Testing
|
||||
|
||||
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
using BigRegister.Stamdata;
|
||||
|
||||
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.
|
||||
/// SERVER-OWNED business rules for diplomas: which profession a study program maps to,
|
||||
/// and which policy questions (geldigheidsvragen) apply to a diploma. The frontend
|
||||
/// renders these; it never derives them.
|
||||
///
|
||||
/// The profession↔program *data* is business-editable stamdata in
|
||||
/// <see cref="Professions"/> (config-as-code, ADR-0004); the *rules* below consume it.
|
||||
/// </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",
|
||||
};
|
||||
|
||||
// RULE: study program → BIG profession (data lives in Stamdata.Professions).
|
||||
public static string ProfessionFor(Diploma d) =>
|
||||
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
||||
Professions.ByProgram.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();
|
||||
public static IReadOnlyList<string> ManualProfessions() => Professions.All();
|
||||
|
||||
// --- Policy questions (geldigheidsvragen) ---
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace BigRegister.Stamdata;
|
||||
|
||||
/// <summary>
|
||||
/// BUSINESS-EDITABLE STAMDATA (config-as-code). Which BIG profession (beroep) each
|
||||
/// study program (opleiding) maps to. This is the one table the business tunes when
|
||||
/// a program starts or stops leading to a registered profession.
|
||||
///
|
||||
/// Change it by editing this file and opening a PR — NOT via a production database.
|
||||
/// The C# compiler catches shape/type mistakes; <c>StamdataValidationTests</c> catches
|
||||
/// the referential integrity it can't (e.g. a seeded diploma whose program has no
|
||||
/// profession here). So a bad edit fails the build, never prod. See ADR-0004
|
||||
/// (docs/reference/architecture/0004-stamdata-as-code.md).
|
||||
///
|
||||
/// This is DATA, not logic: the rules that consume it (which questions a diploma needs,
|
||||
/// how a manual diploma is treated) stay in <c>DiplomaRules</c>.
|
||||
/// </summary>
|
||||
public static class Professions
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> ByProgram =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["geneeskunde"] = "Arts",
|
||||
["verpleegkunde"] = "Verpleegkundige",
|
||||
["fysiotherapie"] = "Fysiotherapeut",
|
||||
["farmacie"] = "Apotheker",
|
||||
["tandheelkunde"] = "Tandarts",
|
||||
};
|
||||
|
||||
/// <summary>Distinct professions, in declaration order — the list a user may declare
|
||||
/// for a manual (unlisted) diploma.</summary>
|
||||
public static IReadOnlyList<string> All() => ByProgram.Values.Distinct().ToList();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Stamdata;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The compile-time gate for business-editable stamdata (ADR-0004). The C# compiler
|
||||
/// already catches shape/type mistakes; these tests catch the referential integrity it
|
||||
/// can't, so a bad config edit fails the build instead of reaching production.
|
||||
/// </summary>
|
||||
public class StamdataValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Every_seeded_diploma_program_maps_to_a_known_profession()
|
||||
{
|
||||
// The dangling-reference guard: a seed program with no entry in Professions would
|
||||
// silently render "Onbekend" to the user. Fail the build instead.
|
||||
foreach (var d in SeedData.Diplomas)
|
||||
Assert.True(DiplomaRules.ProfessionFor(d) != "Onbekend",
|
||||
$"Diploma program '{d.Opleiding}' has no profession in Stamdata.Professions.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profession_map_has_no_blank_programs_or_professions()
|
||||
{
|
||||
Assert.All(Professions.ByProgram, kv =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(kv.Key), "A profession-map program key is blank.");
|
||||
Assert.False(string.IsNullOrWhiteSpace(kv.Value), $"Program '{kv.Key}' maps to a blank profession.");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_professions_are_non_empty_and_distinct()
|
||||
{
|
||||
var professions = DiplomaRules.ManualProfessions();
|
||||
Assert.NotEmpty(professions);
|
||||
Assert.Equal(professions.Count, professions.Distinct().Count());
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ condensed, cross-linked curriculum.
|
||||
| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). |
|
||||
| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. |
|
||||
| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. |
|
||||
| [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). |
|
||||
| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. |
|
||||
| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). |
|
||||
| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR-0004 — Stamdata as code (config-as-code, not a production database)
|
||||
|
||||
Status: Accepted · Date: 2026-07-20
|
||||
|
||||
## Context
|
||||
|
||||
The business needs to control certain inputs that change over time — the clearest example
|
||||
being **which professions link to which diplomas** (`geneeskunde → Arts`, …), but also
|
||||
tunable thresholds, policy-question text, document-category definitions, and some letter
|
||||
copy. Two hard constraints:
|
||||
|
||||
1. **No managing this through a production database.** A live admin surface writing DB rows
|
||||
means a bad value ships silently and is discovered in production.
|
||||
2. **Issues must be caught at compile time.** A change should be typed, reviewed, and
|
||||
versioned before it can affect anyone.
|
||||
|
||||
The codebase already leans this way but had never named it as a pattern, and one key table
|
||||
was neither isolated nor validated:
|
||||
|
||||
- All reference data and thresholds are **compiled-in C# constants**, served through
|
||||
screen-shaped BFF-lite endpoints; the frontend renders decisions and holds no reference
|
||||
data (ADR-0001).
|
||||
- User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a
|
||||
second locale is a translation file, not a code change. That is already the compile-time
|
||||
model for text.
|
||||
- The profession↔diploma map lived as a *private* `Dictionary` inside `DiplomaRules`, mixed
|
||||
in with the rules that consume it, with **no cross-reference check**: a diploma whose
|
||||
program wasn't in the map silently rendered `"Onbekend"`.
|
||||
|
||||
## Decision
|
||||
|
||||
Treat business-tunable reference data as **stamdata-as-code**: typed, checked-in
|
||||
configuration, changed through the normal git → PR → build → deploy pipeline. Never a
|
||||
production database, never runtime-editable.
|
||||
|
||||
1. **One home, typed.** Business-editable reference data lives in the
|
||||
`BigRegister.Stamdata` namespace (`backend/src/BigRegister.Api/Stamdata/`), one file per
|
||||
concern, as plain typed C# data (records / dictionaries). Separate the **data** (what the
|
||||
business tunes) from the **rules** (dev-owned logic that consumes it): the profession
|
||||
*table* is `Stamdata.Professions`; the *rule* "an English diploma needs a B2 question"
|
||||
stays in `DiplomaRules`.
|
||||
2. **Served unchanged.** The existing BFF-lite endpoints keep serving this data
|
||||
(`/duo/diplomas`, `/intake/policy`, `/uploads/categories`, …). No frontend change — the
|
||||
FE still renders decisions.
|
||||
3. **Two gates.** The **C# compiler** catches shape and type mistakes. A build-time
|
||||
**`StamdataValidationTests`** catches the referential integrity the compiler can't —
|
||||
every seeded diploma program resolves to a real profession, no blank keys/values,
|
||||
thresholds in range. CI runs it, so a bad edit fails the build and never merges.
|
||||
4. **Business control = config-as-code (GitOps).** The business owns the content of these
|
||||
files; a change is a reviewed edit, not a live DB write. A future low-code editor could
|
||||
commit a PR on their behalf without changing this model (the compile-time gate stays).
|
||||
|
||||
### Where each kind of business-controllable thing lives
|
||||
|
||||
| Kind | Home | Gate |
|
||||
| --- | --- | --- |
|
||||
| Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# | compiler + `StamdataValidationTests` |
|
||||
| User-facing UI copy | `$localize` → `src/locale/*.xlf` | build (`i18nMissingTranslation: error`) |
|
||||
| Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests |
|
||||
|
||||
### The deliberate exception: org-templates
|
||||
|
||||
Per-organization letterhead (return address, footer, signature, margins) **is**
|
||||
runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is
|
||||
intentional and does not contradict this ADR: it is *operational configuration* owned by an
|
||||
admin persona, versioned with publish/rollback inside the app, and specific to one
|
||||
sub-organization's identity — not the shared business rules a wrong value would break for
|
||||
everyone. Stamdata (the rules and reference tables the whole register runs on) stays code.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **+** Every change is typed, reviewed, versioned, and rollback-able through git; zero
|
||||
production-DB risk; a dangling reference fails the build with a clear message instead of
|
||||
reaching users.
|
||||
- **−** A change needs the PR pipeline — not instant, and a non-developer may need dev
|
||||
assistance to edit C# (mitigated later by a low-code editor that emits a PR, or by a
|
||||
data-file format if hand-editing ergonomics ever outweigh maximal compile-time safety).
|
||||
- **Pilot shipped with this ADR:** the profession↔diploma map extracted to
|
||||
`Stamdata/Professions.cs`, `DiplomaRules` refactored to consume it (behaviour unchanged),
|
||||
and `StamdataValidationTests` added. Policy-question text and document-category
|
||||
definitions follow the same pattern as obvious next steps; not moved yet.
|
||||
@@ -333,6 +333,39 @@ stores the result. The route guard (`auth/auth.guard.ts`) just reads
|
||||
`store.isAuthenticated()` and redirects to `/login` if you're not signed in.
|
||||
Protected routes list `canActivate: [authGuard]` in `app.routes.ts`.
|
||||
|
||||
### 2g. Autosave — keystroke → model → debounced sync (not on blur)
|
||||
|
||||
A common assumption is "the form saves on blur." It doesn't. **Blur only marks a field
|
||||
_touched_** so validation can show; it never writes the value or hits the network. In the
|
||||
shared atoms, `(blur)="onTouched()"` is the `ControlValueAccessor` touched callback and
|
||||
nothing more; the value is pushed on `(input)`, every keystroke
|
||||
([`text-input.component.ts`](../../../src/app/shared/ui/text-input/text-input.component.ts):
|
||||
`(input)` L29 → `onChange` L62, vs `(blur)="onTouched()"` L30).
|
||||
|
||||
The real flow has two stages, neither keyed on focus:
|
||||
|
||||
1. **Keystroke → Model.** A field binds `(ngModelChange)`/`(input)` and dispatches
|
||||
`{ tag: 'SetField', key, value }`. The pure reducer stores it immediately — so the
|
||||
Model is always current, on every keystroke, while editing.
|
||||
([`herregistratie-wizard.component.ts`](../../../src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
|
||||
L78 → [`herregistratie.machine.ts`](../../../src/app/herregistratie/domain/herregistratie.machine.ts)
|
||||
L138-142, `setField`.)
|
||||
2. **Model → backend (600 ms debounce).** A signal `effect` tracks the machine
|
||||
`snapshot()`; each change resets a 600 ms timer whose callback does I/O **only** (it
|
||||
never dispatches, so it can't livelock the store). On the first save it lazily creates
|
||||
the application and stamps `?aanvraag=<id>` into the URL, so a reload resumes the draft.
|
||||
([`draft-sync.ts`](../../../src/app/registratie/application/draft-sync.ts):
|
||||
`DEBOUNCE_MS` L34, `effect` L102-108, `flush` L88-98 → `ApplicationsAdapter.syncDraft`.)
|
||||
|
||||
The **brief** context uses the same 600 ms idiom in its own store: `edit()` applies the
|
||||
edit optimistically in the reducer and records an undo step, then `scheduleSave()` →
|
||||
`flushSave()` flips a `saveState` (Saving/Saved/Error) and calls `adapter.save`
|
||||
([`brief.store.ts`](../../../src/app/brief/application/brief.store.ts) L157-166, L192-209).
|
||||
|
||||
So it _feels_ like save-on-blur only because you usually stop typing when you leave a
|
||||
field, and the debounce fires ~600 ms later. The trigger is **"stopped changing," not
|
||||
"lost focus."** Submit is a separate, explicit action (§2d).
|
||||
|
||||
---
|
||||
|
||||
## 3. "Parse, don't validate" — value objects
|
||||
@@ -469,6 +502,50 @@ Practical notes, kept lazy:
|
||||
single place the wire format meets our types.
|
||||
- Nothing else moves: `<app-async>`, the stores, and every page keep working unchanged.
|
||||
|
||||
### 6a. The request lifecycle today
|
||||
|
||||
The sketch above is the _rationale_; the shipped shape has since firmed up. The contract is
|
||||
no longer hand-written DTOs — it's an **NSwag-generated typed client**
|
||||
([`api-client.ts`](../../../src/app/shared/infrastructure/api-client.ts), regenerate with
|
||||
`npm run gen:api` per [`nswag.json`](../../../nswag.json)) — and the boundary is a
|
||||
`parse*` returning `Result` rather than `httpResource({ parse })`. End to end:
|
||||
|
||||
- **Proxy.** The app uses a relative base URL (`apiBaseUrl: ''`), so `/api` calls are
|
||||
same-origin and `ng serve` proxies them to the backend on `:5000`.
|
||||
([`environment.ts`](../../../src/environments/environment.ts),
|
||||
[`proxy.conf.json`](../../../proxy.conf.json); `proxy.conf.docker.json` targets the
|
||||
compose service.)
|
||||
- **Client → HttpClient seam.** The NSwag client's `fetch` is routed through Angular's
|
||||
`HttpClient` by `httpClientFetch` — the one place cross-cutting concerns live:
|
||||
`X-Correlation-Id` on every call, `Idempotency-Key` on non-GETs, a 10 s timeout, and
|
||||
GET-only retry. Routing through `HttpClient` is exactly what lets the interceptors see API
|
||||
traffic. ([`api-client.provider.ts`](../../../src/app/shared/infrastructure/api-client.provider.ts):
|
||||
`httpClientFetch` L47-82, `provideApiClient` L86-92; registered in
|
||||
[`app.config.ts`](../../../src/app/app.config.ts) L37.)
|
||||
- **Interceptors (dev-only, stripped in prod).** `scenario.interceptor.ts` (the `?scenario=`
|
||||
toggle) and `role.interceptor.ts` (`X-Role` on role-aware endpoints).
|
||||
|
||||
**A read (dashboard):** `<app-async [data]="store.profile()">` →
|
||||
[`BigProfileStore`](../../../src/app/registratie/application/big-profile.store.ts) →
|
||||
`DashboardViewAdapter.dashboardViewResource()` = `resource({ loader: () =>
|
||||
client.dashboardView() })`
|
||||
([`dashboard-view.adapter.ts`](../../../src/app/registratie/infrastructure/dashboard-view.adapter.ts))
|
||||
→ GET `/api/v1/dashboard-view` → `httpClientFetch` → proxy → backend → back through the
|
||||
`parseDashboardView(json): Result` trust boundary → `RemoteData<DashboardView>` → rendered.
|
||||
|
||||
**A write (change address):** `runIfSubmitting()` (§2d) → `createSubmitChangeRequest`
|
||||
([`submit-change-request.ts`](../../../src/app/registratie/application/submit-change-request.ts))
|
||||
→ `runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807
|
||||
ProblemDetails → string ([`submit.ts`](../../../src/app/shared/application/submit.ts)) →
|
||||
[`change-request.adapter.ts`](../../../src/app/registratie/infrastructure/change-request.adapter.ts)
|
||||
→ POST `/api/v1/change-requests` → `ok(referentie)` / `err(detail)` → dispatch
|
||||
`SubmitConfirmed` / `SubmitFailed`.
|
||||
|
||||
**Backend.** A single minimal-API host computes business decisions server-side (BFF-lite),
|
||||
returns ProblemDetails on rule rejection, and dedupes replays via `Idempotency-Key`
|
||||
([`Program.cs`](../../../backend/src/BigRegister.Api/Program.cs): `/dashboard-view` L80,
|
||||
`/change-requests` L120).
|
||||
|
||||
---
|
||||
|
||||
## 7. Mini-glossary
|
||||
|
||||
@@ -153,6 +153,11 @@ the *outcome*. Reducer = "what the new state is"; command = "go do it, then say
|
||||
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
|
||||
steps are `visibleSteps(answers)`, not a stored field.
|
||||
|
||||
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
|
||||
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
|
||||
draft to the backend, an effect that lives *outside* the reducer. See
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §2g.
|
||||
|
||||
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
|
||||
length). Read the generated Model / Msg / reduce and its spec.
|
||||
|
||||
@@ -359,7 +364,8 @@ one is the **authority**?
|
||||
|
||||
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
|
||||
`docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §4. For how contexts scale to a second
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE
|
||||
request lifecycle, read + write, with file links). For how contexts scale to a second
|
||||
app and actor-based authorization, ADR-0002 (the advanced read).
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user