Compare commits

...

11 Commits

Author SHA1 Message Date
44eb2d2186 chore(deps): update npm packages within declared ranges; reformat for prettier 3.9.4
Some checks failed
CI / frontend (push) Successful in 1m46s
CI / storybook-a11y (push) Successful in 4m23s
CI / backend (push) Successful in 1m14s
CI / codeql (csharp) (push) Has been cancelled
CI / codeql (javascript-typescript) (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / e2e (push) Has been cancelled
npm update brought every package to the latest version its existing package.json
range allows (Angular tooling 22.0.2/22.0.4 -> 22.0.5, prettier 3.8.4 -> 3.9.4,
typescript-eslint 8.62.0 -> 8.62.1); package.json itself needed no range changes.

Auditing actual deprecation warnings (not just outdated versions) found nothing
further to fix: @angular/platform-browser-dynamic and @angular-devkit/build-angular
are deprecated by Angular but still required peer dependencies of the latest
published @storybook/angular (10.4.6 — peer range still `>=18.0.0 < 22.0.0`,
already why .npmrc sets legacy-peer-deps); jest-process-manager/expect-playwright
are transitive-only through @storybook/test-runner's latest stable (0.24.4). No
newer version of either Storybook package exists yet that drops them. The
remaining npm audit advisory (@babel/core, low severity) is the same
already-documented, deliberately-left issue in README.md (fixing it downgrades
Angular). Left package.json's overrides untouched.

The prettier bump alone changed formatting opinions on files this session didn't
otherwise touch (a stale markdown italics marker, a few object-literal wrap
points) — reformatted everything so `format:check` (part of CI) doesn't regress.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:29:36 +02:00
556f2f47bf feat(fp): WP-22 — durable persistence (SQLite/EF Core)
Applications, documents (+ audit log) and the brief move off static in-memory
Dictionaries onto a real SQLite file via EF Core, so demo data survives a
process restart or `docker compose restart api` for the first time. The three
stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public
signatures and static-class shape — no DI, no async ripple into Program.cs's
minimal-API handlers — each method just opens a short-lived AppDbContext via
Db.Create() under the same lock it already had. Opaque nested shapes (a
wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
JSON text columns rather than redesigned into relational tables, matching the
existing "don't interpret it" posture.

Found two things the WP's own text got wrong, corrected in
docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never
seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which
stay in-memory) so there's no seed step; and no new docker-compose volume is
needed since the existing bind mount already covers the SQLite file — verified
against this environment's real podman-backed compose stack, not just by
reading the file.

Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own
transitive default bundles a pre-3.50.2 SQLite with a known high-severity
memory-corruption advisory); found and fixed a real xUnit test race where
concurrent test-class hosts stomped a shared static connection-string field,
fixed by disabling cross-class test parallelization rather than adding DI the
stores don't otherwise need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:19:23 +02:00
40dbcb2606 feat(fp): WP-21 — resilience seams (correlation-id, idempotency, retry)
Correlation id becomes real ASP.NET Core middleware instead of a per-endpoint
read: every request gets one (client-supplied or generated), it's echoed as
an X-Correlation-Id response header, and pushed into the logging scope so
every log line for that request carries it — not just the Submit helper's,
verified against LogBrief which never threads it explicitly.

Idempotency-Key moves from per-HTTP-attempt (defeating its own purpose) to
per-logical-submit: runSubmit mints one key and threads it through a small
bridge (withIdempotencyKey/currentIdempotencyKey) since the NSwag-generated
client has no per-call header hook. Backend gains an IdempotencyStore that
short-circuits a replayed key to the first call's result instead of minting
a second reference — scoped to the Submit-helper endpoints per the WP's own
decision.

GET requests now retry transient failures (rxjs retry({count:2, delay:500}));
writes never auto-retry. Proven with a fake-HttpClient spec
(api-client.provider.spec.ts) rather than a manual network-tab check — the
WP's suggested `?scenario=error` check turned out not to exercise a real
network call at all (the interceptor throws before calling next()), so the
automated test is the actual proof.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 20:03:41 +02:00
e276629107 feat(fp): WP-20 — second locale proof (nl/en build seam)
angular.json gains an i18n block (sourceLocale nl, en translation file) and
an `en` build/serve configuration with i18nMissingTranslation: "error" so a
new $localize string without an English unit fails the build, not silently
falls back. CI now runs `ng build --localize` to build both locales every
run. Verified end-to-end, not just "the build succeeded": the nl bundle
ships "Inloggen met DigiD", the en bundle ships "Log in with DigiD".

Incidental: prettier/compodoc regen noise in docs/wcag-checklist.md,
src/docs/a11y.mdx, documentation.json from the same working session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:16:11 +02:00
26c2c5acd0 feat(fp): WP-19 — Playwright e2e smoke against the real FE+backend
Adds a happy-path spec (login → dashboard → registratie wizard, including
a real identity-document upload → real submit) and a degraded-path spec
(?scenario=error → <app-async> error slot → retry), both driving the real
app against the real .NET backend, plus a CI job that boots both.

Writing the retry spec surfaced a real bug: AsyncComponent's retry() only
reloads a [resource]-fed instance, so every real page (all [data]-fed via
a store's RemoteData) had a silently no-op retry button. Added a
retryClicked output and wired it on the dashboard's two async blocks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 10:13:40 +02:00
e272869f00 feat(fp): WP-17 — app-level a11y: route focus, template lint, WCAG checklist
Adds route-change focus management (new page's h1, afterNextRender) plus
scroll-position restoration wired once in app.config.ts; angular-eslint's
templateAccessibility bundle linting every inline template via
processInlineTemplates (verified firing with a planted violation, one real
hit fixed in rich-text-editor); docs/wcag-checklist.md and Foundations/
Accessibility MDX tying the four a11y layers (axe, lint, play tests,
manual checklist) together. The checklist pass already earned its keep —
it found a real 320px overflow in aanvraag-block's warning alert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 08:26:01 +02:00
f3de30b72c feat(fp): WP-16 — component a11y: description wiring + alert role
Wires text-input's aria-describedby to the form-field description div
(the BSN hint was rendered but never announced), pins desc-before-error
ordering, and switches alert to role=alert for errors vs role=status
for info/ok/warning. Composition contract enforced by story play tests
(form-field+text-input, alert per variant) run in the WP-01 CI gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 08:15:24 +02:00
85c805b8bd docs(backlog): backfill WP-15 commit hash
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:38:07 +02:00
0cfb01f12c feat(fp): WP-15 — missing stories: shell + brief components
Add the 7 stories CLAUDE.md's testing rule ("UI is exercised via Storybook
stories") was missing: shared/layout/shell (Design System/Templates/Shell)
and all six previously-unstoried brief components (passage-picker,
rejection-comments, diagnostics-panel, letter-block, letter-preview,
letter-section — Domein/Brief/*), each with a default state plus the
meaningful variants (locked/editable, findings/clean, show/entry, etc).
Every *.component.ts in the repo now has a co-located story; *.page.ts
files stay unstoried, matching the existing norm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:37:58 +02:00
ac1f0b6aeb docs(backlog): backfill WP-14 commit hash
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:32:26 +02:00
8b19fad558 feat(fp): WP-14 — Storybook taxonomy reorg + Layers MDX
Retitle all 49 stories into a sidebar that makes the DDD seam visible:
Foundations (curriculum) -> Design System (Atoms/Molecules/Organisms/
Templates/Devtools, everything in shared/ui + shared/layout) -> Domein
(Registratie/Herregistratie/Auth/Brief, everything in a context's ui/).
Pin the order via storySort. Add layers.mdx explaining the split and
linking the enforcing eslint rules; document the story-title convention
in CLAUDE.md. Fix a stale "status banner" reference in atomic-design.mdx
left over from WP-13's upload-status-banner deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:32:19 +02:00
128 changed files with 9663 additions and 2961 deletions

View File

@@ -30,7 +30,10 @@ jobs:
- run: npm run format:check
- run: npm run check:tokens
- run: npm test
- run: npm run build
# --localize builds every configured locale (nl + en, angular.json's i18n
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails
# this step if messages.en.xlf is missing a unit the source (WP-20) gains.
- run: npx ng build --localize
# The shipped bundle must stay clean; dev-only advisories are excluded.
- run: npm audit --omit=dev
@@ -60,6 +63,29 @@ jobs:
- run: dotnet format backend/BigRegister.slnx --verify-no-changes
- run: dotnet test backend/BigRegister.slnx
e2e:
# Smoke-level Playwright run against the REAL FE+backend (WP-19) — a fresh
# runner checkout per run, so there's no bigregister.db (WP-22, gitignored)
# left over from a prior run to leak state in; the backend creates + migrates
# an empty one on this boot, same as a fresh clone always has.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: dotnet run --project backend/src/BigRegister.Api --urls http://localhost:5000 &
- run: npx ng serve --proxy-config proxy.conf.json &
- run: npx wait-on http://localhost:5000/swagger http://localhost:4200
- run: npm run e2e
codeql:
# Static analysis (SAST) for both sides; results appear under the Security tab.
runs-on: ubuntu-latest

6
.gitignore vendored
View File

@@ -45,3 +45,9 @@ Thumbs.db
*storybook.log
storybook-static
# Playwright e2e
/test-results
/playwright-report
/blob-report
/playwright/.cache

View File

@@ -25,6 +25,18 @@ const preview: Preview = {
date: /Date$/i,
},
},
// Sidebar tells the DDD seam: reusable design system, then domain contexts.
// See src/docs/layers.mdx.
options: {
storySort: {
order: [
'Foundations',
'Design System',
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
'Domein',
],
},
},
},
};

View File

@@ -9,8 +9,10 @@ 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. 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.
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
typed client. The FE renders the backend's decisions. Reference data mimicking
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
persist to a SQLite file via EF Core (WP-22) — `docs/backlog/WP-22-durable-persistence.md`.
## Commands
@@ -80,7 +82,7 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
model keeps prefixed *value* exports instead (`initialUpload`/`reduceUpload`,
model keeps prefixed _value_ exports instead (`initialUpload`/`reduceUpload`,
see `upload.machine.ts`) — prefixing there avoids alias noise at the
composition site.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
@@ -120,8 +122,12 @@ but the FE doesn't call them.
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
Storybook stories (`*.stories.ts` co-located, titled `Layer/Name`, a11y addon on),
not heavy component tests.
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
**Story titles mirror the sidebar's Design System/Domein split** (see
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
## Conventions

View File

@@ -31,6 +31,7 @@ cd backend && dotnet run --project src/BigRegister.Api # API → http://localh
npm run storybook # component library, organized by atomic layer
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
```
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
@@ -166,12 +167,21 @@ degrade to an instant navigation.
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
consumes an **NSwag-generated** typed client (`npm run gen:api`).
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
**dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified).
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
`en` — a genuine second-locale build, not just an unexercised claim — into
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
English, not machine-untranslated placeholders; `angular.json`'s
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
### Dependency security
@@ -185,6 +195,11 @@ We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 2
### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
itself _is_ implemented.)
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
Server — SQLite persists applications/documents/the brief + a real audit table,
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
proven (see above) but
production-quality translation, a runtime locale switcher, and RTL/pluralization
edge cases are not — the `en` file is demo-quality, and locale is a build-time
choice, not a switch in the running app.

View File

@@ -17,6 +17,14 @@
"root": "",
"sourceRoot": "src",
"prefix": "app",
"i18n": {
"sourceLocale": "nl",
"locales": {
"en": {
"translation": "src/locale/messages.en.xlf"
}
}
},
"architect": {
"build": {
"builder": "@angular/build:application",
@@ -31,7 +39,8 @@
}
],
"styles": ["src/styles.scss"],
"polyfills": ["@angular/localize/init"]
"polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
},
"configurations": {
"production": {
@@ -59,6 +68,9 @@
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"en": {
"localize": ["en"]
}
},
"defaultConfiguration": "production"
@@ -71,6 +83,9 @@
},
"development": {
"buildTarget": "atomic-design-poc:build:development"
},
"en": {
"buildTarget": "atomic-design-poc:build:development,en"
}
},
"defaultConfiguration": "development"

5
backend/.gitignore vendored
View File

@@ -1,2 +1,7 @@
bin/
obj/
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
bigregister.db
bigregister.db-shm
bigregister.db-wal

View File

@@ -4,8 +4,18 @@ 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.
No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs,
status codes and error envelope are production-shaped.
**Applications, documents and the brief persist** to a SQLite file
(`src/BigRegister.Api/bigregister.db`, EF Core-backed — `Data/AppDbContext.cs`,
`Data/Db.cs`) created and migrated on first run; restarting the process (or
`docker compose restart api` — the existing `./backend:/src` bind mount already
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
reset demo data back to empty, the same state a fresh clone starts from. This is
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
`docs/backlog/WP-22-durable-persistence.md`.
## Run

View File

@@ -8,6 +8,13 @@
"swagger"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -7,6 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<!-- Pin over EF Core Sqlite's own transitive default (2.1.11): that version
bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption
advisory (GHSA-2m69-gcr7-jv3q). 3.0.3 bundles a patched SQLite. -->
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>

View File

@@ -0,0 +1,63 @@
using System.Text.Json;
using BigRegister.Api.Contracts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace BigRegister.Api.Data;
/// <summary>
/// EF Core/SQLite persistence for the three stores that used to be static
/// in-memory dictionaries (WP-22): <see cref="Aanvraag"/>, <see cref="StoredDocument"/>
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes
/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
/// JSON text columns rather than redesigned into relational tables — the backend
/// already treats them as opaque (see BriefStore's own header comment), so a JSON
/// column matches that "don't interpret it" posture with zero schema redesign,
/// per this WP's own decision to relocate shapes, not redesign them.
/// </summary>
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<StoredDocument> Documents => Set<StoredDocument>();
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<StoredDocument>().HasKey(d => d.DocumentId);
modelBuilder.Entity<AuditEntry>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Id).ValueGeneratedOnAdd();
});
modelBuilder.Entity<Aanvraag>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Draft).HasConversion(DraftConverter);
e.Property(a => a.DocumentIds).HasConversion(Json<List<string>>());
});
modelBuilder.Entity<BriefEntity>(e =>
{
e.HasKey(b => b.BriefId);
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
});
}
// A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON
// text. `.Clone()` on read detaches the element from the short-lived JsonDocument
// that parsed it (same rule ApplicationStore.SyncDraft already follows for the
// request body — an uncloned element is invalid once its JsonDocument is GC'd).
private static readonly ValueConverter<JsonElement?, string?> DraftConverter = new(
v => v.HasValue ? v.Value.GetRawText() : null,
v => string.IsNullOrEmpty(v) ? (JsonElement?)null : JsonDocument.Parse(v).RootElement.Clone());
private static ValueConverter<T, string> Json<T>() => new(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<T>(v, (JsonSerializerOptions?)null)!);
}

View File

@@ -29,34 +29,48 @@ public sealed class Aanvraag
}
/// <summary>
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
/// locks if it ever serves load.
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
/// tolerates only one writer at a time anyway, and this was already a single
/// coarse gate before the DB existed.
/// </summary>
public static class ApplicationStore
{
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
private static readonly Dictionary<string, Aanvraag> _apps = new();
private static readonly object _gate = new();
public static Aanvraag Create(string type, string owner)
{
var now = DateTimeOffset.UtcNow;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
lock (_gate) _apps[a.Id] = a;
lock (_gate)
{
using var db = Db.Create();
db.Applications.Add(a);
db.SaveChanges();
}
return a;
}
public static Aanvraag? Get(string id, string owner)
{
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
return a is not null && a.Owner == owner ? a : null;
}
}
public static IReadOnlyList<Aanvraag> List(string owner)
{
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
lock (_gate)
{
using var db = Db.Create();
return db.Applications.Where(a => a.Owner == owner).ToList();
}
}
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
@@ -64,12 +78,15 @@ public static class ApplicationStore
{
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return false;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex;
a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
db.SaveChanges();
return true;
}
}
@@ -81,9 +98,12 @@ public static class ApplicationStore
List<string> docs;
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner) return false;
docs = a.DocumentIds.ToList();
_apps.Remove(id);
db.Applications.Remove(a);
db.SaveChanges();
}
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
@@ -96,7 +116,9 @@ public static class ApplicationStore
{
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return null;
a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value;
@@ -104,6 +126,7 @@ public static class ApplicationStore
a.AutoApprovable = autoApprovable;
a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
db.SaveChanges();
return a;
}
}

View File

@@ -1,14 +1,16 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
using Microsoft.EntityFrameworkCore;
namespace BigRegister.Api.Data;
/// <summary>
/// The letter (brief) — one demo brief per owner, created from a template on first
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. The status machine and
/// its guards live here (the server is authoritative for transitions); the FE mirrors
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
/// stub does not interpret it (no server-side placeholder linting in this slice).
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not
/// interpret it (no server-side placeholder linting in this slice).
/// </summary>
public sealed class BriefEntity
{
@@ -24,6 +26,8 @@ public sealed class BriefEntity
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
}
/// <summary>ponytail: one global lock, same as before this WP — SQLite tolerates
/// only one writer at a time anyway, and this was already a single coarse gate.</summary>
public static class BriefStore
{
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
@@ -32,16 +36,18 @@ public static class BriefStore
public enum Outcome { Ok, Forbidden, Conflict }
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
private static readonly object _gate = new();
public static BriefEntity GetOrCreate(string owner)
{
lock (_gate)
{
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
using var db = Db.Create();
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (existing is not null) return existing;
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
db.Briefs.Add(created);
db.SaveChanges();
return created;
}
}
@@ -52,11 +58,14 @@ public static class BriefStore
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
db.SaveChanges();
return (Outcome.Ok, e);
}
}
@@ -65,10 +74,13 @@ public static class BriefStore
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
db.SaveChanges();
return (Outcome.Ok, e);
}
}
@@ -85,9 +97,12 @@ public static class BriefStore
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
db.SaveChanges();
return (Outcome.Ok, e);
}
}
@@ -95,7 +110,11 @@ public static class BriefStore
/// Test seam: clear the demo brief between tests.
public static void Reset()
{
lock (_gate) _byOwner.Clear();
lock (_gate)
{
using var db = Db.Create();
db.Briefs.ExecuteDelete();
}
}
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
@@ -104,9 +123,11 @@ public static class BriefStore
{
lock (_gate)
{
_byOwner.Remove(owner);
using var db = Db.Create();
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
db.Briefs.Add(created);
db.SaveChanges();
return created;
}
}
@@ -120,10 +141,13 @@ public static class BriefStore
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next();
db.SaveChanges();
return (Outcome.Ok, e);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace BigRegister.Api.Data;
/// <summary>
/// Factory for short-lived <see cref="AppDbContext"/> instances. The three stores
/// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape
/// predates WP-22 and this WP keeps it — so they can't take a constructor-injected
/// DbContext; each store method opens one here, uses it, and disposes it under its
/// own lock instead.
/// </summary>
public static class Db
{
public static string ConnectionString { get; set; } = "Data Source=bigregister.db";
public static AppDbContext Create() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(ConnectionString).Options);
}
/// <summary>Lets `dotnet ef migrations add` construct a context at design time
/// without booting the full app (no DI registration exists for AppDbContext —
/// see Db.Create above for why). Auto-discovered by EF Core's tooling.</summary>
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args) => Db.Create();
}

View File

@@ -1,8 +1,8 @@
namespace BigRegister.Api.Data;
/// <summary>
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
/// serialized into a JSON response; only the dedicated content endpoint streams them.
/// </summary>
@@ -13,33 +13,48 @@ public sealed record StoredDocument(
public bool Linked { get; set; }
}
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
/// <summary>Id is EF Core's auto-increment key — not part of the positional
/// constructor, so every existing `new AuditEntry(at, action, ...)` call site
/// keeps working unchanged; EF Core assigns it on insert.</summary>
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor)
{
public long Id { get; init; }
}
/// <summary>
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
/// for a single-process demo store; swap for per-key locks if it ever serves load.
/// The audit log holds metadata only (never file content or other PII).
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
/// one writer at a time anyway, and this process already serialized all access
/// through a single gate, so it now doubles as a coarse single-writer guard for
/// the DB file. The audit log holds metadata only (never file content or other PII).
/// </summary>
public static class DocumentStore
{
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
private static readonly Dictionary<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc;
lock (_gate)
{
using var db = Db.Create();
db.Documents.Add(doc);
db.SaveChanges();
}
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
}
public static StoredDocument? Get(string documentId)
{
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Find(documentId);
}
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
@@ -47,15 +62,26 @@ public static class DocumentStore
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
}
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
{
using var db = Db.Create();
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
{
var d = db.Documents.Find(id);
if (d is not null) d.Linked = true;
}
db.SaveChanges();
}
}
public enum DeleteResult { Ok, NotFound, Linked }
@@ -66,10 +92,13 @@ public static class DocumentStore
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
_docs.Remove(documentId);
db.Documents.Remove(d);
db.SaveChanges();
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
@@ -82,9 +111,12 @@ public static class DocumentStore
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d)) return false;
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
db.Documents.Remove(d);
db.SaveChanges();
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
@@ -92,11 +124,23 @@ public static class DocumentStore
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
lock (_gate)
{
using var db = Db.Create();
db.AuditEntries.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
db.SaveChanges();
}
}
public static IReadOnlyList<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
get
{
lock (_gate)
{
using var db = Db.Create();
return db.AuditEntries.ToList();
}
}
}
}

View File

@@ -0,0 +1,25 @@
namespace BigRegister.Api.Data;
/// <summary>
/// In-memory idempotency-key ledger for the submit endpoints (Program.cs's `Submit`
/// helper): a replayed `Idempotency-Key` returns the exact result computed the first
/// time, instead of re-running the submission and minting a new reference.
/// ponytail: one global lock + no TTL/eviction — fine for a single-process demo;
/// swap for a TTL'd cache before this ever serves real traffic (an unbounded
/// dictionary keyed on client-supplied strings is a memory leak at scale).
/// </summary>
public static class IdempotencyStore
{
private static readonly Dictionary<string, IResult> _results = new();
private static readonly object _gate = new();
public static bool TryGet(string key, out IResult? result)
{
lock (_gate) return _results.TryGetValue(key, out result);
}
public static void Set(string key, IResult result)
{
lock (_gate) _results[key] = result;
}
}

View File

@@ -0,0 +1,195 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260704190822_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Applications",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
Draft = table.Column<string>(type: "TEXT", nullable: true),
StepIndex = table.Column<int>(type: "INTEGER", nullable: false),
StepCount = table.Column<int>(type: "INTEGER", nullable: false),
DocumentIds = table.Column<string>(type: "TEXT", nullable: false),
Referentie = table.Column<string>(type: "TEXT", nullable: true),
AutoApprovable = table.Column<bool>(type: "INTEGER", nullable: false),
Reden = table.Column<string>(type: "TEXT", nullable: true),
Submitted = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Applications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AuditEntries",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
At = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Action = table.Column<string>(type: "TEXT", nullable: false),
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
Actor = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditEntries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Briefs",
columns: table => new
{
BriefId = table.Column<string>(type: "TEXT", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
Beroep = table.Column<string>(type: "TEXT", nullable: false),
TemplateId = table.Column<string>(type: "TEXT", nullable: false),
DrafterId = table.Column<string>(type: "TEXT", nullable: false),
Placeholders = table.Column<string>(type: "TEXT", nullable: false),
Sections = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Briefs", x => x.BriefId);
});
migrationBuilder.CreateTable(
name: "Documents",
columns: table => new
{
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
LocalId = table.Column<string>(type: "TEXT", nullable: false),
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
WizardId = table.Column<string>(type: "TEXT", nullable: false),
FileName = table.Column<string>(type: "TEXT", nullable: false),
SizeBytes = table.Column<long>(type: "INTEGER", nullable: false),
ContentType = table.Column<string>(type: "TEXT", nullable: false),
Content = table.Column<byte[]>(type: "BLOB", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
UploadedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Linked = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Documents", x => x.DocumentId);
});
migrationBuilder.CreateIndex(
name: "IX_Briefs_Owner",
table: "Briefs",
column: "Owner",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Applications");
migrationBuilder.DropTable(
name: "AuditEntries");
migrationBuilder.DropTable(
name: "Briefs");
migrationBuilder.DropTable(
name: "Documents");
}
}
}

View File

@@ -0,0 +1,192 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -8,6 +8,8 @@ using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Console;
var builder = WebApplication.CreateBuilder(args);
@@ -20,13 +22,46 @@ builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
// So the correlation-id scope pushed by the middleware below actually shows up in
// the console, not just in memory for a formatter that never renders it.
builder.Logging.AddSimpleConsole(o => o.IncludeScopes = true);
const string SpaCors = "spa";
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
// WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static
// classes that open their own short-lived AppDbContext per call (see Db.Create),
// not DI-injected, so there's no builder.Services.AddDbContext here. Configuring
// the connection string still goes through IConfiguration so tests/deployments can
// override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
var app = builder.Build();
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only
// reference fixtures (registration/diplomas/notes — untouched by this WP, still
// static in-memory), Applications/Documents/Briefs never had seed data — they
// started empty and accumulated through normal use before this WP too. A fresh
// SQLite file just starts empty again, same as the old in-memory dictionaries did.
using (var db = Db.Create())
db.Database.Migrate();
// Every request gets a correlation id (client-supplied X-Correlation-Id if present,
// else generated), pushed into the logging scope for every log line the request
// produces (not just the Submit helper's) and echoed back as a response header for
// support/debugging correlation. Runs first so nothing downstream logs without it.
app.Use(async (ctx, next) =>
{
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: Guid.NewGuid().ToString();
ctx.Items["CorrelationId"] = cid;
ctx.Response.Headers["X-Correlation-Id"] = cid;
using (app.Logger.BeginScope("CorrelationId:{CorrelationId}", cid))
await next(ctx);
});
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(SpaCors);
@@ -345,19 +380,30 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store).
// real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
// — so a retried submit dedupes instead of minting a second reference.
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: "none";
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
? k.ToString()
: null;
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
{
app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid);
return cached!;
}
IResult result;
if (reject is not null)
{
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
result = Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
}
else
{
if (documents is not null)
{
// Link digital documents (blocks later user delete) and record post-delivery
@@ -371,7 +417,11 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow);
return Results.Ok(new ReferentieResponse(reference));
result = Results.Ok(new ReferentieResponse(reference));
}
if (idemKey is not null) IdempotencyStore.Set(idemKey, result);
return result;
}
// Exposed so the integration tests can spin up the app with WebApplicationFactory.

View File

@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();

View File

@@ -11,7 +11,7 @@ namespace BigRegister.Tests;
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
/// header: absent = drafter, "approver" = a different identity.
/// </summary>
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();

View File

@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
@@ -125,6 +125,22 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Correlation_id_supplied_by_the_caller_is_echoed_back()
{
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/notes");
req.Headers.Add("X-Correlation-Id", "test-cid-123");
var res = await _client.SendAsync(req);
Assert.Equal("test-cid-123", res.Headers.GetValues("X-Correlation-Id").Single());
}
[Fact]
public async Task Correlation_id_is_generated_when_the_caller_omits_it()
{
var res = await _client.GetAsync("/api/v1/notes");
Assert.NotEmpty(res.Headers.GetValues("X-Correlation-Id").Single());
}
// --- Document upload ---
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)

View File

@@ -0,0 +1,69 @@
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private static HttpRequestMessage ChangeRequestWithKey(string key)
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
{
Content = JsonContent.Create(new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" }),
};
req.Headers.Add("Idempotency-Key", key);
return req;
}
[Fact]
public async Task Replaying_the_same_idempotency_key_returns_the_same_reference_not_a_new_one()
{
var key = Guid.NewGuid().ToString();
var first = await _client.SendAsync(ChangeRequestWithKey(key));
first.EnsureSuccessStatusCode();
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
var replay = await _client.SendAsync(ChangeRequestWithKey(key));
replay.EnsureSuccessStatusCode();
var replayBody = await replay.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.Equal(firstBody!.Referentie, replayBody!.Referentie);
}
[Fact]
public async Task Different_idempotency_keys_are_independent_submissions()
{
var first = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
var second = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
var secondBody = await second.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
}
[Fact]
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
{
var key = Guid.NewGuid().ToString();
var badRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
{
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
};
badRequest.Headers.Add("Idempotency-Key", key);
var first = await _client.SendAsync(badRequest);
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, first.StatusCode);
var replayRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
{
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
};
replayRequest.Headers.Add("Idempotency-Key", key);
var replay = await _client.SendAsync(replayRequest);
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, replay.StatusCode);
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
// WP-22's stores read a single static Db.ConnectionString (there's no DI, matching
// their pre-WP-22 static-Dictionary shape — see Data/Db.cs). That's correct for a
// real single-instance process, but xUnit's default parallel-across-classes
// execution would run multiple WebApplicationFactory hosts concurrently in this
// ONE test process, each overwriting that same static field with its own temp-file
// path — a real race (caught as "table already exists" from two Migrate() calls
// interleaving on whichever file won the race), not a hypothetical one. Serializing
// test classes is the fix, not a redesign of the stores for a test-only concern.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace BigRegister.Tests;
/// <summary>
/// WP-22 moved Applications/Documents/Briefs off in-memory dictionaries onto a real
/// SQLite file (see Data/Db.cs). Unlike static dictionaries, a shared file path
/// would let concurrent test classes' WebApplicationFactory instances hit the same
/// file at once — xUnit runs different test classes in parallel by default, and
/// SQLite tolerates only one writer at a time, so that's a real "database is
/// locked" flake risk, not a hypothetical one. Every test class below points at its
/// own throwaway file instead, deleted when the factory (and its class's tests) are
/// done — the same one-store-per-class isolation the old dictionaries gave for free.
/// </summary>
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db");
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}");
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing) return;
File.Delete(_dbPath);
File.Delete(_dbPath + "-shm"); // ponytail: best-effort — WAL sidecar files if SQLite created any.
File.Delete(_dbPath + "-wal");
}
}

View File

@@ -9,6 +9,10 @@ services:
- ASPNETCORE_ENVIRONMENT=Development
volumes:
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
# WP-22: no separate volume needed for the SQLite file — `dotnet run` sets
# its cwd to the project dir (src/BigRegister.Api), which this bind mount
# already covers, so bigregister.db lands on the host and survives
# `docker compose restart api` / `down` + `up` for free (gitignored).
- ./backend:/src:z
- api-bin:/src/src/BigRegister.Api/bin
- api-obj:/src/src/BigRegister.Api/obj

View File

@@ -33,6 +33,10 @@ npm run test-storybook:ci
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
only needs re-running if a WP unexpectedly touches `backend/`.
From WP-19 onward, `npm run e2e` is part of CI (its own job) but NOT part of the local
GREEN one-liner above — it needs the real backend + `npm start` already running (see
WP-19's own file), so it's a separate manual/CI step, not chained into the others.
## Order
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
@@ -53,12 +57,12 @@ for its existing violations, so every WP ends green.
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | todo |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | todo |

View File

@@ -4,7 +4,7 @@ Status: done (69880ef)
Phase: 2 — CIBG fidelity
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
> .btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
.btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope

View File

@@ -1,8 +1,17 @@
# WP-14 — Storybook taxonomy reorg + Layers MDX
Status: todo
Status: done (8b19fad)
Phase: 3 — Storybook as curriculum
> **Deviation:** the "Layout/" bucket (breadcrumb, site-footer, site-header) wasn't in the
> Decisions block's explicit scheme, so each got folded into Atoms/Molecules/Organisms by
> its own doc-comment classification (breadcrumb → Molecules, site-footer/site-header →
> Organisms, both already documented as such in their component header comments) rather
> than kept as a separate bucket. Fixing `atomic-design.mdx`'s "status banner" reference
> (stale since WP-13 deleted `upload-status-banner`) was caught as a side effect of
> reviewing every MDX page for broken references — not itself a retitle issue, but the
> same "unbroken MDX" acceptance criterion covers it.
## Why
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
@@ -58,11 +67,11 @@ Domein/
## Acceptance criteria
- [ ] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
- [x] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
pinned.
- [ ] Zero story titles outside the scheme (grep `title:` and eyeball).
- [ ] `layers.mdx` renders; existing MDX pages unbroken.
- [ ] Convention in CLAUDE.md.
- [x] Zero story titles outside the scheme (grep `title:` and eyeball).
- [x] `layers.mdx` renders; existing MDX pages unbroken.
- [x] Convention in CLAUDE.md.
## Verification

View File

@@ -1,9 +1,18 @@
# WP-15 — Missing stories: shell + brief components
Status: todo
Status: done (0cfb01f)
Phase: 3 — Storybook as curriculum
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically)
> **Note:** fixture duplication across the four brief stories that need `Brief`/
> `LetterSection`/`LetterBlock` shapes (letter-block, letter-preview, letter-section, plus
> the pre-existing letter-composer) didn't bite enough to justify the shared-fixtures
> escape hatch — each story only builds the minimal slice it actually renders (letter-block
> needs one block, not a whole `Brief`), so the co-located fixtures stayed small and
> non-duplicative in practice. A pre-existing, unrelated axe finding on
> `text-input--invalid` (informational only — `test-storybook:ci` doesn't fail on it) shows
> up in the run; it predates this WP and isn't caused by anything here.
## Why
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none:
@@ -44,11 +53,12 @@ invisible to the axe gate.
## Acceptance criteria
- [ ] Every component in `src/app` has ≥1 story (verify: list components without a
- [x] Every component in `src/app` has ≥1 story (verify: list components without a
co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
norm — note the norm in this file when checked).
- [ ] All new stories pass the axe gate (or carry a justified skip).
- [ ] Titles follow WP-14.
norm — note the norm in this file when checked). **Confirmed norm:** `*.page.ts`
files (9 of them) have never had stories; every `*.component.ts` now does.
- [x] All new stories pass the axe gate (or carry a justified skip).
- [x] Titles follow WP-14.
## Verification

View File

@@ -1,6 +1,6 @@
# WP-16 — Component a11y: description wiring + alert role
Status: todo
Status: done (pending commit)
Phase: 4 — a11y
## Why
@@ -57,12 +57,25 @@ Audit findings axe can't (fully) catch:
## Acceptance criteria
- [ ] Description text is programmatically associated in the canonical composition;
- [x] Description text is programmatically associated in the canonical composition;
login-form BSN hint announced.
- [ ] `-error` id appended exactly when invalid; order stable.
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
- [x] `-error` id appended exactly when invalid; order stable.
- [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
CI gate.
## Deviation from the original plan
`radio-group`/`checkbox` were left unchanged — grepping every call site found zero
consumers pairing either with a `form-field` `description` (only the login-form BSN
field, which uses `text-input`). The WP's own Files note ("describedby joins; only
where the component takes a hint") already carved out this exact case — adding
`hasDescription` to atoms with no live description consumer would be unused surface,
not a fix. `radio-group` already had correct `-error`-only wiring; untouched.
`describedBy()` was added directly on `text-input` rather than factored into a shared
`shared/kernel` helper — one consumer, ~5 lines, not worth the indirection yet.
Manual screen-reader spot check (optional per the WP) skipped; the play test is the
enforced check going forward.
## Verification
GREEN + `npm run test-storybook:ci` (includes the new play tests).

View File

@@ -1,6 +1,6 @@
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
Status: todo
Status: done (pending commit)
Phase: 4 — a11y
## Why
@@ -59,11 +59,30 @@ Three app-level gaps close the WCAG story:
## Acceptance criteria
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
view transitions still play.
- [ ] Template a11y rules active and _proven_ to fire; lint green.
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
- [x] Template a11y rules active and _proven_ to fire; lint green.
- [x] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [x] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
## Deviation from the original plan
- Used the official `angular.configs.templateAccessibility` bundle (11 rules) instead of
hand-listing the 6 named in this WP's Decisions — it's a strict superset (includes
`no-autofocus`, `no-distracting-elements`, `mouse-events-have-key-events`,
`role-has-required-aria`, `table-scope` on top of the 6 named), maintained upstream,
and is exactly what `@angular-eslint/schematics`' own generated config uses for this
setup. Less code to hand-maintain, same coverage plus more.
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its
`.feedback` flex row doesn't wrap. Documented in `docs/wcag-checklist.md` with the
root cause, **not fixed** — fixing live component CSS found via the checklist is the
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
WP's own deliverable. Flagged here so it isn't lost.
- "Screen reader" column left unfilled for every page — the pass available in this
environment was a headless-browser keyboard/DOM/computed-style check, not an actual
NVDA/VoiceOver run. The checklist says so explicitly rather than implying more
coverage than was done.
## Verification

View File

@@ -1,6 +1,6 @@
# WP-19 — Playwright e2e smoke
Status: todo
Status: done (pending commit)
Phase: 5 — productie-volwassenheid
## Why
@@ -70,14 +70,46 @@ serve` backgrounded), wait-on both ports, `npm run e2e`. `timeout-minutes: 15`
## Acceptance criteria
- [ ] `npm run e2e` passes locally against `docker compose up` or `npm start` +
- [x] `npm run e2e` passes locally against `docker compose up` or `npm start` +
`dotnet run` run manually.
- [ ] CI job `e2e` is green and runs on every PR alongside the existing jobs.
- [ ] The happy-path spec exercises a real wizard submit against the real backend
- [x] CI job `e2e` is green and runs on every PR alongside the existing jobs.
- [x] The happy-path spec exercises a real wizard submit against the real backend
(not mocked) and asserts on the resulting UI state.
- [ ] The error-path spec exercises `<app-async>`'s error slot + retry via the real
- [x] The error-path spec exercises `<app-async>`'s error slot + retry via the real
`?scenario=error` toggle, not a mocked HTTP response.
## Deviation from the original plan
- **Found and fixed a real bug while writing the error-path spec**: `AsyncComponent`'s
built-in `retry()` only calls `.reload()` on a `[resource]` input — every real page
(`dashboard`, `registration-detail`, `aanvraag-detail`, `brief`) feeds `<app-async>`
via `[data]` (a store's combined `RemoteData`), so clicking "Opnieuw proberen" was a
silent no-op everywhere except the showcase teaching page. Added a `retryClicked`
output that fires regardless of feed mode, and wired the two dashboard instances
(`BigProfileStore.reloadProfile()`/`reloadAantekeningen()`) since that's what this
WP's spec exercises. **Not fixed**: `registration-detail`, `aanvraag-detail`, and
`brief` pages still have the same latent no-op retry — same "found via testing,
fixing the whole surface is beyond this WP" call as WP-17's dashboard CSS finding.
Flagging here so it isn't lost.
- Confirmed `currentScenario()` reads `window.location.search` fresh on every call —
the error-path spec's retry assertion had to change from "counts a browser network
request" (the scenario interceptor never reaches the real transport; it substitutes
`throwError` in the rxjs pipe before `next(req)`) to "observes a real Loading→Failure
reload cycle via `aria-busy`". The Steps section's literal suggestion ("assert it
re-fetches... via a network tab") didn't hold; adapted per the WP's own Risks note
to verify actual interceptor behavior first.
- Verified the suite isn't a no-op per the Verification section: temporarily broke
`diplomaOptions`' `value: d.id` (appended `-x`), watched `smoke.spec.ts` fail on the
now-missing `#diploma-d1` selector, reverted.
- The registratie wizard's minimum path needed an actual file upload (`identiteit` is
always required for `registratie` regardless of diploma choice, per
`DocumentRules.CategoriesFor` — only `diploma`/`taalvaardigheid` are answer-gated).
Picked the first DUO diploma (non-English, `Engelstalig: false`) specifically because
it carries zero policy questions, keeping the happy path to one upload.
- CIBG-styled radios hide the native `<input>` behind its `<label>` — Playwright's
`.check()` on the input times out fighting the label for pointer events; the specs
click the `label[for=...]` instead (also more representative of a real click).
## Verification
`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears

View File

@@ -1,6 +1,6 @@
# WP-20 — Second locale proof
Status: todo
Status: done (pending commit)
Phase: 5 — productie-volwassenheid
## Why
@@ -73,12 +73,14 @@ seam is built into every component but never proven to actually work end to end.
## Acceptance criteria
- [ ] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
- [ ] `messages.en.xlf` exists with a translation for every extracted unit.
- [ ] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output
- [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
- [x] `messages.en.xlf` exists with a translation for every extracted unit.
- [x] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output
bundle in CI, and CI fails if the `en` file is missing a unit the source gains.
- [ ] Manually verified: the `en` build actually shows English strings in a browser,
not just "the build succeeded."
- [x] Manually verified: the `en` build actually shows English strings in a browser,
not just "the build succeeded." (`login.submit`: `nl` bundle ships "Inloggen
met DigiD", `en` bundle ships "Log in with DigiD" — checked in the built JS,
not just that the build succeeded.)
## Verification

View File

@@ -1,6 +1,6 @@
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
Status: todo
Status: done (pending commit)
Phase: 5 — productie-volwassenheid
## Why
@@ -98,22 +98,41 @@ delay })` in `httpClientFetch`'s pipe, per the existing header comment's own
## Acceptance criteria
- [ ] Every backend log line for a given request shares one correlation id (not
- [x] Every backend log line for a given request shares one correlation id (not
just lines inside `Submit`); the id is echoed in the response headers.
- [ ] Replaying a submit with the same `Idempotency-Key` returns the same result
without minting a second reference (backend test proves this).
- [ ] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
Verified manually: `LogBrief`'s log line — which never interpolates a `Cid`
itself — now prints `=> CorrelationId:scope-check-5` from the middleware's
`BeginScope`, and `EndpointTests.Correlation_id_supplied_by_the_caller_is_echoed_back`
/ `..._is_generated_when_the_caller_omits_it` cover the response header.
- [x] Replaying a submit with the same `Idempotency-Key` returns the same result
without minting a second reference (backend test proves this —
`IdempotencyTests`, 3 cases: same key twice, different keys, a replayed
rejection).
- [x] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
any FE-side retry of that submit (not regenerated per HTTP attempt).
- [ ] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
or a forced 5xx); POST/PUT/DELETE never auto-retry.
`runSubmit` mints it once and threads it via `withIdempotencyKey`; covered by
`api-client.provider.spec.ts`.
- [x] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
or a forced 5xx); POST/PUT/DELETE never auto-retry. Covered by
`api-client.provider.spec.ts` (3 retries on GET, 1 attempt on POST).
## Verification
GREEN + `cd backend && dotnet test`. Manual: `?scenario=error` on a GET-backed page,
confirm a retry attempt happens (network tab shows 2 requests) before the error
state renders; submit a wizard twice with a manually replayed idempotency key (e.g.
via curl against the backend directly) and confirm the second call doesn't create a
duplicate application.
GREEN + `cd backend && dotnet test` (84 passing) — done. Manual: confirmed via curl
against a locally running backend that `X-Correlation-Id` is echoed (client-supplied
and server-generated) and that replaying `/api/v1/change-requests` with the same
`Idempotency-Key` returns the identical `referentie` while a different key mints a
new one; tailed the console log to confirm the correlation id shows up on a brief
endpoint's log line that never explicitly threads it.
**Deviation**: the `?scenario=error` network-tab check this section originally
proposed doesn't actually work — `scenario.interceptor.ts`'s `error` case
`throwError`s before ever calling `next(req)`, so no real request reaches the
network stack and devtools shows nothing to retry. Automated tests
(`api-client.provider.spec.ts`, using a fake `HttpClient`-shaped `.request()` so no
TestBed/HttpClientTestingModule is needed) are the actual proof of the retry
mechanism instead — a more reliable check than a manual browser pass would have
been anyway.
## Out of scope

View File

@@ -1,6 +1,6 @@
# WP-22 — Durable persistence (optional tier)
Status: todo
Status: done (pending commit)
Phase: 5 — productie-volwassenheid
## Why
@@ -98,22 +98,30 @@ speculatively.
## Acceptance criteria
- [ ] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
- [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
`Data/*.cs` for application/document/brief state.
- [ ] Every existing backend test passes unchanged (signatures didn't change).
- [ ] Restarting the backend process preserves previously created applications,
- [x] Every existing backend test passes unchanged (signatures didn't change).
84/84 green, stable across repeated runs (see Deviations for a real race this
surfaced).
- [x] Restarting the backend process preserves previously created applications,
documents, and brief drafts (manually verified).
- [ ] The audit log survives a restart and is queryable (even if no new endpoint
exposes it yet — persistence is the bar, not a new audit UI).
- [ ] `docker compose up` with the new volume also survives a container restart.
- [x] The audit log survives a restart and is queryable (even if no new endpoint
exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries`
is a real table now; not separately re-verified across restart beyond the
applications/brief checks (same store mechanism, same `Db.Create()` seam).
- [x] `docker compose up` with a container restart preserves data — **no new
volume** turned out to be needed (see Deviations).
## Verification
`cd backend && dotnet test`. Manual: `dotnet run --project src/BigRegister.Api`,
create an application via the FE or a curl request, kill and restart the process,
confirm `GET /api/v1/applications` still returns it. Repeat with `docker compose up`
- `docker compose restart api`.
`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
src/BigRegister.Api`, created an application via curl, killed and restarted the
process, confirmed `GET /api/v1/applications` still returned it (repeated for the
brief). Repeated the same check against the **real** `docker compose up` stack
(this environment has an actual podman-backed compose, not a mock) — created an
application via `curl localhost:5000`, ran `docker compose restart api`, confirmed
it survived, and confirmed on the host that `backend/src/BigRegister.Api/bigregister.db`
is the file being written (gitignored, not tracked).
## Out of scope
@@ -131,3 +139,47 @@ synchronously; converting to `async`/`await` may ripple further than "just the
Data/ layer" if minimal-API handlers aren't already `async`. Check this before
starting and budget for handler signature changes (still not a _behavior_ change,
but a wider diff than the Files section implies if handlers need `async` added).
**Resolved**: didn't ripple at all. EF Core's SQLite provider fully supports
synchronous APIs (`.Find()`, `.ToList()`, `.SaveChanges()`, `.ExecuteDelete()`); every
store method stayed synchronous, so `Program.cs`'s minimal-API handlers needed zero
changes. The stores stayed **static classes** with no DI — each method opens its
own short-lived `AppDbContext` via a small `Db.Create()` factory (`Data/Db.cs`) under
the same `lock (_gate)` each store already had, which now doubles as a single-writer
guard for the SQLite file (SQLite tolerates only one writer at a time anyway).
## Deviations from the plan
- **No SeedData → DB seed step.** The WP's own "Decisions"/"Files" sections assumed
`SeedData` populates the three stores and needs a "seed if empty" migration. It
doesn't — `SeedData` only backs the read-only BRP/DUO-mimicking GET endpoints
(registration, person, diplomas, notes), which stay in-memory and are untouched by
this WP. Applications/Documents/Briefs never had seed data; they started empty
before this WP and still do. One less step than planned.
- **No new docker-compose volume.** The existing `./backend:/src` bind mount already
covers `bigregister.db` (it's written under `src/BigRegister.Api/`, itself inside
the bind-mounted tree — confirmed empirically, not just by reading the compose
file), so a container restart already persists it for free. Added a comment
instead of a redundant `volumes:` entry.
- **Opaque nested shapes (wizard draft, brief sections/placeholders/status) became
JSON text columns**, not new relational tables — matches the WP's own "relocate
the shape, don't redesign it" instruction and the existing "the backend treats
brief content as opaque" posture.
- **Found and fixed a real test race, not a hypothetical one.** The stores read a
single static `Db.ConnectionString` (matching their pre-WP-22 static-Dictionary
shape — no DI). xUnit's default parallel-across-classes execution ran multiple
`WebApplicationFactory` hosts concurrently in the one test process, each
overwriting that same static field with its own temp-file path — caught as a
`SQLite Error 1: 'table "Applications" already exists'` from two `Migrate()` calls
interleaving on whichever file won the race. Fixed with
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
(`TestWebApplicationFactory.cs`) rather than redesigning the stores' DI shape for
a test-only concern. Reran `dotnet test` 3× in a row to confirm the race was
actually gone, not just less likely.
- **Pinned `SQLitePCLRaw.bundle_e_sqlite3` to 3.0.3** — `Microsoft.EntityFrameworkCore.Sqlite`
10.0.9's own transitive default (2.1.11) bundles a pre-3.50.2 SQLite with a known
high-severity memory-corruption advisory (GHSA-2m69-gcr7-jv3q); 3.0.3 bundles a
patched one and built/tested cleanly as a drop-in.
- **`dotnet-ef` added to the existing `backend/dotnet-tools.json`** (not a new
`.config/dotnet-tools.json`) — this repo already keeps its one CLI tool manifest
there (`swashbuckle.aspnetcore.cli`); matched that convention.

59
docs/wcag-checklist.md Normal file
View File

@@ -0,0 +1,59 @@
# WCAG manual checklist
Automation (axe on every story — WP-01; template a11y lint — WP-17; the form-field/alert
play tests — WP-16) catches structural and component-level issues. It cannot catch
cross-page flows: tab order across a whole page, focus traps, zoom/reflow, or how a
screen reader actually narrates a journey. This checklist is the manual complement —
a living doc, filled in per page as it's walked, not a one-time sign-off.
See `src/docs/a11y.mdx` (Storybook → Foundations → Accessibility) for how this fits
with the automated layers.
## How to run a page through this checklist
1. **Keyboard walk**: `Tab`/`Shift+Tab` through the whole page. Every interactive
element reachable, in a sensible order, with a visible focus ring; no trap (you can
always tab back out).
2. **No traps**: a modal/dropdown/menu, if present, returns focus on close/`Escape`.
3. **200% zoom / reflow**: browser zoom to 200% (or a 320px-wide viewport). Content
reflows to one column; nothing is clipped or requires horizontal scroll.
4. **Screen reader pass**: NVDA (Windows) or VoiceOver (macOS) — navigate by heading
and by tab; confirm labels, descriptions, and error announcements are heard, not
just visible.
5. **Visible focus**: every focused element has a visible indicator (no
`outline: none` without a replacement).
6. **Error announcement**: submitting an invalid form announces the error (this is
what WP-16's `role="alert"` + `aria-describedby` wiring is for) — confirm it's
actually heard, not just present in the DOM.
## Status
Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked
| Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement |
| -------------------------- | :-----------: | :------: | :--------------: | :-----------: | :-----------: | :----------------: |
| Login (`/login`) | ✅ | ✅ | ✅ | — | ✅ | n/a¹ |
| Dashboard (`/dashboard`) | — | — | ❌² | — | — | — |
| Registratie wizard | — | — | — | — | ✅³ | ✅³ |
| Herregistratie wizard | — | — | — | — | — | — |
| Brief (letter composition) | — | — | — | — | — | — |
¹ Login's demo form has no client-side validation/error state to exercise.
² **Real finding, not fixed here**: `aanvraag-block`'s warning `app-alert` (two
`app-button` actions) overflows the viewport at a 320px width — its `.feedback` flex
row doesn't wrap, pushing the second button past the edge. Fixing it is a genuine
CSS change to a live component, which is exactly the "full manual audit" scope this
WP defers (see Out of scope) — logged here instead of silently fixed or silently
ignored.
³ Spot-checked only: submitting the wizard with required fields empty renders
`role="alert"` error elements (2 found) — confirms WP-16's error-announcement wiring
works end-to-end on a real form, not just in the play test's synthetic composition.
Full keyboard walk / zoom / screen-reader pass on this page not yet done.
"Screen reader" is unfilled everywhere — this pass used a headless browser (keyboard
emulation + computed styles + DOM queries), not an actual NVDA/VoiceOver run. Don't
read the automation above as a substitute for that row; it isn't one.
Filling in the remaining rows (and fixing finding ²) is ongoing work (WP-17's
out-of-scope note) — this table makes what's been checked, and what hasn't, visible
rather than assumed.

File diff suppressed because one or more lines are too long

32
e2e/error-state.spec.ts Normal file
View File

@@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
// The dev-only `?scenario=error` toggle forces the scenario.interceptor to fail
// the request WITHOUT ever reaching the real HTTP transport (it substitutes a
// `throwError` in the rxjs pipe before `next(req)` runs) — so this is not
// observable as a browser network request. It IS observable as a real resource
// reload: `retry()` calls `resource.reload()`, which flips <app-async> back to
// its Loading (`aria-busy="true"`) state before failing again 400ms later.
// `currentScenario()` re-reads `window.location.search` on every call, not a
// one-shot value, so as long as the query param is still there, the retry
// genuinely re-runs and genuinely fails the same way — that's what's asserted
// here: a real reload cycle, not a no-op button.
test('dashboard error state renders, retry re-fetches (and fails again)', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456789');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto('/dashboard?scenario=error');
// The dashboard has more than one independent <app-async> (profile,
// aantekeningen) — both fail under the blanket ?scenario=error, so this text
// legitimately appears more than once. Assert at least one, not exactly one.
const errorAlert = page.getByText('Er ging iets mis bij het laden van de gegevens.').first();
await expect(errorAlert).toBeVisible();
const retry = page.getByRole('button', { name: 'Opnieuw proberen' }).first();
await expect(retry).toBeVisible();
await retry.click();
// A real reload cycle: back to Loading (aria-busy) before failing again.
await expect(page.locator('[aria-busy="true"]').first()).toBeAttached({ timeout: 1_000 });
await expect(errorAlert).toBeVisible({ timeout: 5_000 });
});

59
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
// One happy-path flow through the real FE+backend: log in, land on the real
// dashboard, run the registratie wizard's minimum required path (a DUO diploma
// with zero policy questions, so the only required upload is identiteit), submit,
// and see the real confirmation. Not a full wizard-coverage suite — see WP-19.
//
// The backend is in-memory and shared across runs; this test mutates real state
// (creates a registratie application for the fixed demo identity). Restart the
// backend between CI runs — a second run would see a leftover Concept/submitted
// application on the dashboard, which this test doesn't assert against, but a
// stricter future test might.
test('login → dashboard → registratie wizard → submitted', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456789');
await page.getByLabel('Wachtwoord').fill('demo');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { level: 1, name: 'Mijn overzicht' })).toBeVisible();
// Real backend content, not a loading/error state.
await expect(page.getByText('Persoonsgegevens (BRP)')).toBeVisible();
await page.goto('/registreren');
await expect(
page.getByRole('heading', { level: 1, name: 'Inschrijven in het BIG-register' }),
).toBeVisible();
// Step 1 — adres: BRP prefill is real backend data; "Post" skips the email field.
// The CIBG-styled radio hides the native input behind its label, so click the
// label (real user interaction) rather than `.check()` the covered input.
await expect(page.getByText('Vooraf ingevuld op basis van de BRP')).toBeVisible();
await page.locator('label[for="correspondentie-post"]').click();
await page.locator('button[type="submit"]').click();
// Step 2 — beroep: the first DUO diploma (Geneeskunde, non-English) carries zero
// policy questions, so the only required document is identiteit.
await expect(page.locator('#diploma-d1')).toBeVisible({ timeout: 10_000 });
await page.locator('label[for="diploma-d1"]').click();
await expect(page.getByText('Beroep (afgeleid uit diploma)')).toBeVisible();
await page.locator('#identiteit-file').setInputFiles({
name: 'identiteit.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 fake e2e fixture'),
});
// Real upload against the backend: wait for the row to leave "uploading".
await expect(page.locator('.upload-progress')).toHaveCount(0, { timeout: 10_000 });
await expect(page.locator('.icon-remove')).toBeVisible();
await page.locator('button[type="submit"]').click();
// Step 3 — controle: review + real submit.
await expect(page.getByText('Controleer uw gegevens en dien de registratie in.')).toBeVisible();
await page.locator('button[type="submit"]').click();
await expect(page.getByText('Uw registratie is ontvangen')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/Uw referentienummer is/)).toBeVisible();
});

View File

@@ -1,4 +1,5 @@
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
/**
* Enforces the architecture's working agreements that were previously only
@@ -28,8 +29,18 @@ export default [
},
plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-explicit-any': 'error' },
// This repo has no .html files — every template is inline. This processor
// extracts each component's template string into a virtual `*.html` file,
// which the template-a11y block below (`files: ['src/**/*.html']`) then lints.
processor: angular.processInlineTemplates,
},
// Template a11y (WP-17): the official angular-eslint accessibility bundle
// (alt-text, label-has-associated-control, click/mouse-events-have-key-events,
// interactive-supports-focus, valid-aria, no-autofocus, and more) linting the
// virtual `.html` files the processor above extracts from inline templates.
...angular.configs.templateAccessibility.map((c) => ({ ...c, files: ['src/**/*.html'] })),
// Tests legitimately use `any` to feed invalid messages/states into reducers.
{
files: ['src/**/*.spec.ts'],

4370
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,9 @@
"build-storybook": "ng run atomic-design-poc:build-storybook",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"",
"check:tokens": "bash scripts/check-tokens.sh"
"check:tokens": "bash scripts/check-tokens.sh",
"e2e": "playwright test",
"extract-i18n": "ng extract-i18n --output-path src/locale"
},
"private": true,
"packageManager": "npm@11.12.1",
@@ -39,11 +41,13 @@
"@angular/localize": "^22.0.4",
"@angular/platform-browser-dynamic": "^22.0.0",
"@compodoc/compodoc": "^1.2.1",
"@playwright/test": "^1.61.1",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6",
"@storybook/test-runner": "^0.24.4",
"angular-eslint": "^22.0.0",
"axe-playwright": "^2.2.2",
"concurrently": "^10.0.3",
"eslint": "^10.6.0",

29
playwright.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from '@playwright/test';
// Smoke-level e2e (WP-19): one happy path, one degraded path, against the REAL
// backend — proving the FE+BE seam, not replacing component/unit tests.
const baseURL = process.env['E2E_BASE_URL'] ?? 'http://localhost:4200';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
fullyParallel: true,
retries: process.env['CI'] ? 1 : 0,
reporter: process.env['CI'] ? 'github' : 'list',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
// CI starts `ng serve` + the backend as separate job steps (both need to be up
// before the suite runs); locally, boot `npm start` automatically so `npm run e2e`
// works standalone — the backend still needs `dotnet run` running separately.
webServer: process.env['CI']
? undefined
: {
command: 'npm start',
url: baseURL,
reuseExistingServer: true,
timeout: 120_000,
},
});

View File

@@ -4,7 +4,7 @@ import {
isDevMode,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router';
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
import type { ActivatedRouteSnapshot } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
@@ -16,6 +16,7 @@ import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus';
registerLocaleData(localeNl);
@@ -24,6 +25,7 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
// animate: for the transition's duration Firefox's `::view-transition`
@@ -48,5 +50,6 @@ export const appConfig: ApplicationConfig = {
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
{ provide: LOCALE_ID, useValue: 'nl' },
provideRouteFocus(),
],
};

View File

@@ -24,7 +24,13 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
i18n-description="@@login.bsnDescription"
description="9 cijfers (demo: vul iets in)"
>
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
<app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456789"
/>
</app-form-field>
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { LoginFormComponent } from './login-form.component';
const meta: Meta<LoginFormComponent> = {
title: 'Organisms/Login Form',
title: 'Domein/Auth/Login Form',
component: LoginFormComponent,
};
export default meta;

View File

@@ -79,7 +79,10 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
approveResult = {
ok: true,
value: { ...view, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } } },
value: {
...view,
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
},
};
await store.approve();
expect(store.busy()).toBe(false);

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Diagnostic } from '@brief/domain/placeholders';
import { DiagnosticsPanelComponent } from './diagnostics-panel.component';
const location = { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 };
const errorAndWarning: Diagnostic[] = [
{
severity: 'error',
code: 'unknown-placeholder',
placeholderKey: 'onbekend_veld',
location,
message: 'Onbekend veld "onbekend_veld" — controleer de spelling.',
},
{
severity: 'warning',
code: 'unresolved-at-send',
placeholderKey: 'reden_besluit',
location,
message: '"Reden besluit" is nog niet ingevuld.',
},
];
const meta: Meta<DiagnosticsPanelComponent> = {
title: 'Domein/Brief/Diagnostics Panel',
component: DiagnosticsPanelComponent,
args: { locate: () => {} },
};
export default meta;
type Story = StoryObj<DiagnosticsPanelComponent>;
export const Findings: Story = { args: { diagnostics: errorAndWarning } };
export const Clean: Story = { args: { diagnostics: [] } };

View File

@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterBlock } from '@brief/domain/brief';
import { LetterBlockComponent } from './letter-block.component';
const passageBlock: LetterBlock = {
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
};
const freeTextBlock: LetterBlock = {
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
};
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
const meta: Meta<LetterBlockComponent> = {
title: 'Domein/Brief/Letter Block',
component: LetterBlockComponent,
args: {
block: passageBlock,
placeholders,
contentChanged: () => {},
removed: () => {},
moved: () => {},
},
};
export default meta;
type Story = StoryObj<LetterBlockComponent>;
export const ReadOnlyPassage: Story = { args: { editable: false } };
export const EditableFreeText: Story = { args: { block: freeTextBlock, editable: true } };

View File

@@ -118,7 +118,7 @@ const render = (b: Brief, decisions: BriefDecisions) => ({
});
const meta: Meta<LetterComposerComponent> = {
title: 'Organisms/Letter Composer',
title: 'Domein/Brief/Letter Composer',
component: LetterComposerComponent,
};
export default meta;

View File

@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders';
import { LetterPreviewComponent } from './letter-preview.component';
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte heer/mevrouw ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
],
},
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
},
],
};
const diagnostics: Diagnostic[] = [
{
severity: 'warning',
code: 'unresolved-at-send',
placeholderKey: 'reden_besluit',
location: { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 },
message: '"Reden besluit" is nog niet ingevuld.',
},
];
const meta: Meta<LetterPreviewComponent> = {
title: 'Domein/Brief/Letter Preview',
component: LetterPreviewComponent,
args: { brief, diagnostics },
};
export default meta;
type Story = StoryObj<LetterPreviewComponent>;
export const Default: Story = {};
export const ZonderBevindingen: Story = { args: { diagnostics: [] } };

View File

@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
import { LetterSectionComponent } from './letter-section.component';
const section: LetterSection = {
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
};
const emptySection: LetterSection = { ...section, blocks: [] };
const passages: LibraryPassage[] = [
{
passageId: 'p2',
scope: 'beroep',
beroep: 'arts',
sectionKey: 'kern',
label: 'Toelichting arts',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
},
];
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
const meta: Meta<LetterSectionComponent> = {
title: 'Domein/Brief/Letter Section',
component: LetterSectionComponent,
args: { section, availablePassages: passages, placeholders, edit: () => {} },
};
export default meta;
type Story = StoryObj<LetterSectionComponent>;
export const ReadOnly: Story = { args: { editable: false } };
export const Editable: Story = { args: { editable: true } };
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LibraryPassage } from '@brief/domain/brief';
import { PassagePickerComponent } from './passage-picker.component';
const passages: LibraryPassage[] = [
{
passageId: 'p1',
scope: 'global',
sectionKey: 'aanhef',
label: 'Standaard aanhef',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
},
{
passageId: 'p2',
scope: 'beroep',
beroep: 'arts',
sectionKey: 'aanhef',
label: 'Aanhef, arts-specifiek',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte collega,' }] }] },
},
];
const meta: Meta<PassagePickerComponent> = {
title: 'Domein/Brief/Passage Picker',
component: PassagePickerComponent,
render: (args) => ({
props: args,
template: `<app-passage-picker [passages]="passages" (insert)="insert($event)" />`,
}),
args: { passages, insert: () => {} },
};
export default meta;
type Story = StoryObj<PassagePickerComponent>;
export const Default: Story = {};

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { RejectionCommentsComponent } from './rejection-comments.component';
const meta: Meta<RejectionCommentsComponent> = {
title: 'Domein/Brief/Rejection Comments',
component: RejectionCommentsComponent,
args: { reject: () => {} },
};
export default meta;
type Story = StoryObj<RejectionCommentsComponent>;
export const Show: Story = {
args: { mode: 'show', comments: 'Graag de aanhef formeler.' },
};
export const Entry: Story = { args: { mode: 'entry' } };
export const EntryBusy: Story = { args: { mode: 'entry', busy: true } };

View File

@@ -10,7 +10,7 @@ import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
const meta: Meta<HerregistratieWizardComponent> = {
title: 'Herregistratie/Wizard',
title: 'Domein/Herregistratie/Wizard',
component: HerregistratieWizardComponent,
// The wizard injects BigProfileStore (for the optimistic cross-page flag),
// which creates httpResources — so the story needs an HttpClient.

View File

@@ -9,7 +9,7 @@ import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { werktBuitenland: false, uren: 4160 as Uren, punten: 200 as Uren };
const meta: Meta<IntakeWizardComponent> = {
title: 'Herregistratie/IntakeWizard',
title: 'Domein/Herregistratie/IntakeWizard',
component: IntakeWizardComponent,
// Injects BigProfileStore (optimistic flag) which creates httpResources.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],

View File

@@ -72,4 +72,12 @@ export class BigProfileStore {
rollbackHerregistratie() {
this.pending.set(false); // submission failed — undo the optimistic flag
}
// Retry hooks for [data]-fed <app-async> instances (they don't own the resource).
reloadProfile() {
this.viewRes.reload();
}
reloadAantekeningen() {
this.aantekeningenRes.reload();
}
}

View File

@@ -8,10 +8,16 @@ import { parsePostcode } from '@registratie/domain/value-objects/postcode';
const postcode = parsePostcode('2514 EA');
if (!postcode.ok) throw new Error('fixture postcode should parse');
const data: Valid = { straat: 'Lange Voorhout 9', postcode: postcode.value, woonplaats: 'Den Haag' };
const data: Valid = {
straat: 'Lange Voorhout 9',
postcode: postcode.value,
woonplaats: 'Den Haag',
};
function setup(adapter: Partial<ChangeRequestAdapter>) {
TestBed.configureTestingModule({ providers: [{ provide: ChangeRequestAdapter, useValue: adapter }] });
TestBed.configureTestingModule({
providers: [{ provide: ChangeRequestAdapter, useValue: adapter }],
});
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
}

View File

@@ -13,7 +13,9 @@ describe('change-request reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
expect(s.tag).toBe('Editing');
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe(
'Lange Voorhout 9',
);
});
it('Submit with an invalid draft stays Editing and reports field errors', () => {

View File

@@ -3,7 +3,9 @@ import { parseAantekening } from './big-register.adapter';
describe('big-register.adapter parse boundary', () => {
it('parses known aantekening types', () => {
expect(parseAantekening({ type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' })).toEqual({
expect(
parseAantekening({ type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' }),
).toEqual({
ok: true,
value: { type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' },
});

View File

@@ -14,7 +14,7 @@ const base = {
} satisfies Omit<Aanvraag, 'status'>;
const meta: Meta<AanvraagBlockComponent> = {
title: 'Organisms/Aanvraag Block',
title: 'Domein/Registratie/Aanvraag Block',
component: AanvraagBlockComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({

View File

@@ -3,7 +3,7 @@ import { moduleMetadata } from '@storybook/angular';
import { AddressFieldsComponent } from './address-fields.component';
const meta: Meta<AddressFieldsComponent> = {
title: 'Organisms/Address Fields',
title: 'Domein/Registratie/Address Fields',
component: AddressFieldsComponent,
decorators: [moduleMetadata({ imports: [AddressFieldsComponent] })],
render: (args) => ({

View File

@@ -10,7 +10,12 @@ import {
} from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { ChangeRequestState, ChangeRequestMsg, initial, reduce } from '@registratie/domain/change-request.machine';
import {
ChangeRequestState,
ChangeRequestMsg,
initial,
reduce,
} from '@registratie/domain/change-request.machine';
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
/**

View File

@@ -12,7 +12,7 @@ const validData = {
};
const meta: Meta<ChangeRequestFormComponent> = {
title: 'Organisms/Change Request Form',
title: 'Domein/Registratie/Change Request Form',
component: ChangeRequestFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],

View File

@@ -86,7 +86,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
>
}
<app-async [data]="store.profile()">
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (profile(); as p) {
@let tasks = tasksFor(p.registration);
@@ -153,7 +153,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
>Specialismen en aantekeningen</app-heading
>
<div class="app-section">
<app-async [data]="store.aantekeningen()">
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
<ng-template appAsyncLoaded>
@if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />

View File

@@ -46,7 +46,7 @@ const validData: ValidRegistratie = {
};
const meta: Meta<RegistratieWizardComponent> = {
title: 'Registratie/RegistratieWizard',
title: 'Domein/Registratie/RegistratieWizard',
component: RegistratieWizardComponent,
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
};

View File

@@ -11,7 +11,7 @@ const base = {
} satisfies Omit<Registration, 'status'>;
const meta: Meta<RegistrationSummaryComponent> = {
title: 'Organisms/Registration Summary',
title: 'Domein/Registratie/Registration Summary',
component: RegistrationSummaryComponent,
};
export default meta;

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { RegistrationTableComponent } from './registration-table.component';
const meta: Meta<RegistrationTableComponent> = {
title: 'Organisms/Registration Table',
title: 'Domein/Registratie/Registration Table',
component: RegistrationTableComponent,
};
export default meta;

View File

@@ -1,18 +1,23 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { problemDetail } from '@shared/infrastructure/api-error';
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
/**
* Run a mutating API call and fold it into a `Result` — the one place the
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
* its own payload mapping. The backend re-validates and returns a 422
* ProblemDetails on rejection, surfaced here as the error string.
*
* Also the one place a logical submit's Idempotency-Key is minted — once per
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
* dedupes on the backend (see `withIdempotencyKey`).
*/
export async function runSubmit<T>(
fn: () => Promise<T>,
fallback: string,
): Promise<Result<string, T>> {
try {
return ok(await fn());
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
} catch (e) {
return err(problemDetail(e, fallback));
}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { of, throwError } from 'rxjs';
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
function fakeHttpClient(
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
): HttpClient {
return { request } as unknown as HttpClient;
}
describe('withIdempotencyKey / currentIdempotencyKey', () => {
it('threads the key to every read made inside the wrapped fn', async () => {
const seen: string[] = [];
await withIdempotencyKey('fixed-key', async () => {
seen.push(currentIdempotencyKey());
seen.push(currentIdempotencyKey());
});
expect(seen).toEqual(['fixed-key', 'fixed-key']);
});
it('clears the key once the wrapped fn settles', async () => {
await withIdempotencyKey('fixed-key', async () => undefined);
expect(currentIdempotencyKey()).not.toBe('fixed-key');
});
it('falls back to a generated uuid-shaped key when none is pending', () => {
expect(currentIdempotencyKey()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
});
describe('httpClientFetch', () => {
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
let sentHeaders: Record<string, string> | undefined;
const http = fakeHttpClient((_method, _url, opts) => {
sentHeaders = opts.headers;
return of(new HttpResponse({ status: 200, body: '' }));
});
await withIdempotencyKey('logical-submit-key', () =>
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
);
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
});
// `http.request(...)` itself is only called once per `fetch()` — it returns a
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
// again (exactly how Angular's real HttpClient triggers a fresh network call
// per subscription). So attempts are counted where the resubscription lands:
// the `throwError` factory, not the outer mock call.
it('retries a failing GET twice before giving up', async () => {
let attempts = 0;
const http = fakeHttpClient(() =>
throwError(() => {
attempts++;
return new HttpErrorResponse({ status: 500 });
}),
);
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
expect(attempts).toBe(3); // 1 original + 2 retries
expect(res.status).toBe(500);
});
it('never retries a failing write', async () => {
let attempts = 0;
const http = fakeHttpClient(() =>
throwError(() => {
attempts++;
return new HttpErrorResponse({ status: 500 });
}),
);
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
expect(attempts).toBe(1);
expect(res.status).toBe(500);
});
});

View File

@@ -1,12 +1,34 @@
import { Provider } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom, timeout, TimeoutError } from 'rxjs';
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
import { ApiClient, ProblemDetails } from './api-client';
import { environment } from '../../../environments/environment';
/** Single place every API call passes through: the seam for cross-cutting concerns. */
const REQUEST_TIMEOUT_MS = 10_000;
/**
* A stable Idempotency-Key threaded down from the command layer (one per logical
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
* every non-GET call made synchronously inside `fn` picks up the same key.
* ponytail: a module-level variable, not a proper async-context primitive — holds
* up because every submit command calls its adapter synchronously (no await
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
* submits ever become possible.
*/
let pendingIdempotencyKey: string | undefined;
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
pendingIdempotencyKey = key;
return fn().finally(() => (pendingIdempotencyKey = undefined));
}
export function currentIdempotencyKey(): string {
return pendingIdempotencyKey ?? crypto.randomUUID();
}
/**
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
* client expects, so every API call flows through HttpClient interceptors (the
@@ -15,12 +37,14 @@ const REQUEST_TIMEOUT_MS = 10_000;
* Angular's HTTP stack — i.e. the one seam to add:
* - timeout (done — REQUEST_TIMEOUT_MS),
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
* - idempotency key for writes (done — Idempotency-Key; a real retry would thread
* a STABLE key per logical submit so re-sends dedupe; here it's per-attempt),
* - idempotency key for writes (done — Idempotency-Key, stable per logical
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
* - retry/backoff: wrap the pipe with rxjs `retry({ count, delay })` here.
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
* never auto-retried, which is exactly what makes the idempotency key above
* matter only for a future/manual retry, not routine traffic).
*/
function httpClientFetch(http: HttpClient) {
export function httpClientFetch(http: HttpClient) {
return {
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
@@ -28,17 +52,18 @@ function httpClientFetch(http: HttpClient) {
...((init?.headers ?? {}) as Record<string, string>),
'X-Correlation-Id': crypto.randomUUID(),
};
if (method !== 'GET') headers['Idempotency-Key'] = crypto.randomUUID();
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
try {
const res = await firstValueFrom(
http
const request$ = http
.request(method, url as string, {
body: init?.body as string | undefined,
headers,
observe: 'response',
responseType: 'text',
})
.pipe(timeout(REQUEST_TIMEOUT_MS)),
.pipe(timeout(REQUEST_TIMEOUT_MS));
const res = await firstValueFrom(
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
);
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;

View File

@@ -12,8 +12,7 @@ export function assertNever(x: never): never {
/** A computation that either succeeded with a value or failed with an error.
Plain objects (no classes) to match the signal/httpResource ergonomics. */
export type Result<E, T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });

View File

@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
import { BreadcrumbComponent } from './breadcrumb.component';
const meta: Meta<BreadcrumbComponent> = {
title: 'Layout/Breadcrumb',
title: 'Design System/Molecules/Breadcrumb',
component: BreadcrumbComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({

View File

@@ -5,7 +5,7 @@ import { PageShellComponent } from './page-shell.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
const meta: Meta<PageShellComponent> = {
title: 'Templates/PageShell',
title: 'Design System/Templates/PageShell',
component: PageShellComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),

View File

@@ -0,0 +1,45 @@
import {
DOCUMENT,
ENVIRONMENT_INITIALIZER,
EnvironmentInjector,
afterNextRender,
inject,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
/** Template-layer wiring (not a component): on every route change after the
initial load, moves focus to the new page's `<h1>` (page-shell always
renders one) so screen-reader/keyboard users land on the new content
instead of wherever focus happened to be. Falls back to `#main` (the
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
so it doesn't race Angular's view-transition DOM swap. */
export function provideRouteFocus() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const router = inject(Router);
const document = inject(DOCUMENT);
const injector = inject(EnvironmentInjector);
let isInitialLoad = true;
router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) return;
if (isInitialLoad) {
isInitialLoad = false;
return;
}
afterNextRender(
() => {
const target =
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
if (!target) return;
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
},
{ injector },
);
});
},
};
}

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ShellComponent } from './shell.component';
const meta: Meta<ShellComponent> = {
title: 'Design System/Templates/Shell',
component: ShellComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<ShellComponent>;
// No route matches, so <router-outlet> renders nothing — this story is about the
// persistent chrome (skip-link, header, footer), not routed page content.
export const Default: Story = {};

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { SiteFooterComponent } from './site-footer.component';
const meta: Meta<SiteFooterComponent> = {
title: 'Layout/Site Footer',
title: 'Design System/Organisms/Site Footer',
component: SiteFooterComponent,
render: () => ({ template: `<app-site-footer />` }),
};

View File

@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
import { SiteHeaderComponent } from './site-header.component';
const meta: Meta<SiteHeaderComponent> = {
title: 'Layout/Site Header',
title: 'Design System/Organisms/Site Header',
component: SiteHeaderComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { WizardShellComponent } from './wizard-shell.component';
const meta: Meta<WizardShellComponent> = {
title: 'Templates/WizardShell',
title: 'Design System/Templates/WizardShell',
component: WizardShellComponent,
render: (args) => ({
props: args,
@@ -19,8 +19,7 @@ const meta: Meta<WizardShellComponent> = {
cibgGap: true,
docs: {
description: {
component:
'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
component: 'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
},
},
},

View File

@@ -13,7 +13,10 @@ const ICON_LABELS: Record<AlertType, string> = {
/** Atom: alert/message banner — the CIBG Huisstijl "melding"
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
`.feedback feedback-*` classes: the design system owns surface + icon; we add
only the icon's a11y label and a content wrapper (`.feedback` is a flex row). */
only the icon's a11y label and a content wrapper (`.feedback` is a flex row).
Errors are `role="alert"` (assertive — interrupts) since they need immediate
attention; other variants stay `role="status"` (polite) so success/info banners
don't interrupt what the user is doing. */
@Component({
selector: 'app-alert',
styles: [
@@ -31,7 +34,7 @@ const ICON_LABELS: Record<AlertType, string> = {
[class.feedback-success]="type() === 'ok'"
[class.feedback-warning]="type() === 'warning'"
[class.feedback-error]="type() === 'error'"
role="status"
[attr.role]="type() === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
<span class="icon"

View File

@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { expect, within } from 'storybook/test';
import { AlertComponent } from './alert.component';
const meta: Meta<AlertComponent> = {
title: 'Atoms/Alert',
title: 'Design System/Atoms/Alert',
component: AlertComponent,
render: (args) => ({
props: args,
@@ -12,7 +13,28 @@ const meta: Meta<AlertComponent> = {
export default meta;
type Story = StoryObj<AlertComponent>;
export const Info: Story = { args: { type: 'info' } };
export const Ok: Story = { args: { type: 'ok' } };
export const Warning: Story = { args: { type: 'warning' } };
export const Error: Story = { args: { type: 'error' } };
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
export const Info: Story = {
args: { type: 'info' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Ok: Story = {
args: { type: 'ok' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Warning: Story = {
args: { type: 'warning' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Error: Story = {
args: { type: 'error' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('alert')).toBeInTheDocument();
},
};

View File

@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
import { ApplicationLinkComponent } from './application-link.component';
const meta: Meta<ApplicationLinkComponent> = {
title: 'Molecules/Application Link',
title: 'Design System/Molecules/Application Link',
component: ApplicationLinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({

View File

@@ -5,7 +5,7 @@ import { ApplicationListComponent } from './application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
const meta: Meta<ApplicationListComponent> = {
title: 'Molecules/Application List',
title: 'Design System/Molecules/Application List',
component: ApplicationListComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),

View File

@@ -1,4 +1,12 @@
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
import {
Component,
Directive,
TemplateRef,
computed,
contentChild,
input,
output,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import type { Resource } from '@angular/core';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
@@ -130,11 +138,16 @@ export class AsyncComponent<T> {
}),
);
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
// combined RemoteData — the component doesn't own that resource) must reload
// it themselves; retryClicked is how they find out a retry was requested.
retryClicked = output<void>();
retry = () => {
const r = this.resource();
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
(r as { reload: () => void }).reload();
}
this.retryClicked.emit();
};
}

View File

@@ -17,7 +17,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
}
const meta: Meta = {
title: 'Molecules/Async States',
title: 'Design System/Molecules/Async States',
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
render: (args) => ({
// isEmpty is a function — Storybook strips function args, so set it here.

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { ButtonComponent } from './button.component';
const meta: Meta<ButtonComponent> = {
title: 'Atoms/Button',
title: 'Design System/Atoms/Button',
component: ButtonComponent,
render: (args) => ({
props: args,

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { CardComponent } from './card.component';
const meta: Meta<CardComponent> = {
title: 'Molecules/Card',
title: 'Design System/Molecules/Card',
component: CardComponent,
render: (args) => ({
props: args,

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { CheckboxComponent } from './checkbox.component';
const meta: Meta<CheckboxComponent> = {
title: 'Atoms/Checkbox',
title: 'Design System/Atoms/Checkbox',
component: CheckboxComponent,
render: (args) => ({
props: args,

View File

@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
import { ChoiceLinkComponent } from './choice-link.component';
const meta: Meta<ChoiceLinkComponent> = {
title: 'Molecules/Choice Link',
title: 'Design System/Molecules/Choice Link',
component: ChoiceLinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({

View File

@@ -5,7 +5,7 @@ import { ChoiceListComponent } from './choice-list.component';
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
const meta: Meta<ChoiceListComponent> = {
title: 'Molecules/Choice List',
title: 'Design System/Molecules/Choice List',
component: ChoiceListComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { ConfirmationComponent } from './confirmation.component';
const meta: Meta<ConfirmationComponent> = {
title: 'Molecules/Confirmation',
title: 'Design System/Molecules/Confirmation',
component: ConfirmationComponent,
render: (args) => ({
props: args,

View File

@@ -4,7 +4,7 @@ import { DataBlockComponent } from './data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
const meta: Meta<DataBlockComponent> = {
title: 'Molecules/Data Block',
title: 'Design System/Molecules/Data Block',
component: DataBlockComponent,
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
render: (args) => ({

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { DataRowComponent } from './data-row.component';
const meta: Meta<DataRowComponent> = {
title: 'Molecules/Data Row',
title: 'Design System/Molecules/Data Row',
component: DataRowComponent,
render: (args) => ({
props: args,

View File

@@ -3,7 +3,7 @@ import { DebugStateComponent } from './debug-state.component';
// Devtools, not a design-system atom — titled accordingly.
const meta: Meta<DebugStateComponent> = {
title: 'Devtools/State debug',
title: 'Design System/Devtools/State debug',
component: DebugStateComponent,
parameters: {
cibgGap: true,

View File

@@ -1,10 +1,11 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { expect, within } from 'storybook/test';
import { FormFieldComponent } from './form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
const meta: Meta<FormFieldComponent> = {
title: 'Molecules/Form Field',
title: 'Design System/Molecules/Form Field',
component: FormFieldComponent,
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
render: (args) => ({
@@ -13,7 +14,7 @@ const meta: Meta<FormFieldComponent> = {
template: `
<form class="form-horizontal">
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required">
<app-text-input [inputId]="fieldId" [invalid]="!!error" placeholder="Vul in" />
<app-text-input [inputId]="fieldId" [hasDescription]="!!description" [invalid]="!!error" placeholder="Vul in" />
</app-form-field>
</form>`,
}),
@@ -23,6 +24,13 @@ type Story = StoryObj<FormFieldComponent>;
export const Default: Story = {
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
// Composition contract: fieldId must equal the input's id — enforced here, not by DI
// (see WP-16). Catches drift in the description→aria-describedby wiring.
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc');
},
};
export const WithError: Story = {
args: {
@@ -31,4 +39,23 @@ export const WithError: Story = {
error: 'Dit veld is verplicht.',
required: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'street-error');
},
};
export const WithDescriptionAndError: Story = {
args: {
label: 'BSN',
fieldId: 'bsn',
description: '9 cijfers',
error: 'Ongeldig BSN.',
required: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc bsn-error');
},
};

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { HeadingComponent } from './heading.component';
const meta: Meta<HeadingComponent> = {
title: 'Atoms/Heading',
title: 'Design System/Atoms/Heading',
component: HeadingComponent,
render: (args) => ({
props: args,

View File

@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
import { LinkComponent } from './link.component';
const meta: Meta<LinkComponent> = {
title: 'Atoms/Link',
title: 'Design System/Atoms/Link',
component: LinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { PlaceholderChipComponent } from './placeholder-chip.component';
const meta: Meta<PlaceholderChipComponent> = {
title: 'Atoms/Placeholder Chip',
title: 'Design System/Atoms/Placeholder Chip',
component: PlaceholderChipComponent,
render: (args) => ({
props: args,

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { RadioGroupComponent } from './radio-group.component';
const meta: Meta<RadioGroupComponent> = {
title: 'Atoms/RadioGroup',
title: 'Design System/Atoms/RadioGroup',
component: RadioGroupComponent,
render: (args) => ({
props: args,

Some files were not shown because too many files have changed in this diff Show More