Compare commits
11 Commits
cbf697b8fa
...
44eb2d2186
| Author | SHA1 | Date | |
|---|---|---|---|
| 44eb2d2186 | |||
| 556f2f47bf | |||
| 40dbcb2606 | |||
| e276629107 | |||
| 26c2c5acd0 | |||
| e272869f00 | |||
| f3de30b72c | |||
| 85c805b8bd | |||
| 0cfb01f12c | |||
| ac1f0b6aeb | |||
| 8b19fad558 |
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -30,7 +30,10 @@ jobs:
|
|||||||
- run: npm run format:check
|
- run: npm run format:check
|
||||||
- run: npm run check:tokens
|
- run: npm run check:tokens
|
||||||
- run: npm test
|
- 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.
|
# The shipped bundle must stay clean; dev-only advisories are excluded.
|
||||||
- run: npm audit --omit=dev
|
- run: npm audit --omit=dev
|
||||||
|
|
||||||
@@ -60,6 +63,29 @@ jobs:
|
|||||||
- run: dotnet format backend/BigRegister.slnx --verify-no-changes
|
- run: dotnet format backend/BigRegister.slnx --verify-no-changes
|
||||||
- run: dotnet test backend/BigRegister.slnx
|
- 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:
|
codeql:
|
||||||
# Static analysis (SAST) for both sides; results appear under the Security tab.
|
# Static analysis (SAST) for both sides; results appear under the Security tab.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -45,3 +45,9 @@ Thumbs.db
|
|||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
|
# Playwright e2e
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
/playwright/.cache
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ const preview: Preview = {
|
|||||||
date: /Date$/i,
|
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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -9,8 +9,10 @@ update this file.
|
|||||||
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
||||||
view their registration, apply for re-registration). Angular 22, standalone,
|
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
|
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
|
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
||||||
through an NSwag-generated typed client. The FE renders the backend's decisions.
|
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
|
## 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`,
|
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
|
||||||
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
|
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
|
||||||
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
|
`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
|
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
||||||
composition site.
|
composition site.
|
||||||
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
- **`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
|
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
||||||
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
||||||
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
|
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),
|
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
|
||||||
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
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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 storybook # component library, organized by atomic layer
|
||||||
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
|
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**.
|
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
|
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
|
||||||
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
||||||
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
||||||
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
|
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
|
||||||
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
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
|
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
|
||||||
**dev-only** — it is not wired into production builds.
|
**dev-only** — it is not wired into production builds.
|
||||||
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
||||||
Angular 22; the builder runs fine (build verified).
|
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
|
### 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)
|
### Deliberately out of scope (POC)
|
||||||
|
|
||||||
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
|
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
|
||||||
NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
|
Server — SQLite persists applications/documents/the brief + a real audit table,
|
||||||
itself _is_ implemented.)
|
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.
|
||||||
|
|||||||
17
angular.json
17
angular.json
@@ -17,6 +17,14 @@
|
|||||||
"root": "",
|
"root": "",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
|
"i18n": {
|
||||||
|
"sourceLocale": "nl",
|
||||||
|
"locales": {
|
||||||
|
"en": {
|
||||||
|
"translation": "src/locale/messages.en.xlf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
@@ -31,7 +39,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
"polyfills": ["@angular/localize/init"]
|
"polyfills": ["@angular/localize/init"],
|
||||||
|
"i18nMissingTranslation": "error"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -59,6 +68,9 @@
|
|||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"localize": ["en"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
@@ -71,6 +83,9 @@
|
|||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "atomic-design-poc:build:development"
|
"buildTarget": "atomic-design-poc:build:development"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"buildTarget": "atomic-design-poc:build:development,en"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
|
|||||||
5
backend/.gitignore
vendored
5
backend/.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|
||||||
|
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
|
||||||
|
bigregister.db
|
||||||
|
bigregister.db-shm
|
||||||
|
bigregister.db-wal
|
||||||
|
|||||||
@@ -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
|
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`).
|
(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`),
|
No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
|
||||||
but the endpoints, DTOs, status codes and error envelope are production-shaped.
|
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
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
"swagger"
|
"swagger"
|
||||||
],
|
],
|
||||||
"rollForward": false
|
"rollForward": false
|
||||||
|
},
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.9",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,15 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
63
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal file
63
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal 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)!);
|
||||||
|
}
|
||||||
@@ -29,34 +29,48 @@ public sealed class Aanvraag
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
|
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
|
||||||
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
|
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
|
||||||
/// locks if it ever serves load.
|
/// tolerates only one writer at a time anyway, and this was already a single
|
||||||
|
/// coarse gate before the DB existed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ApplicationStore
|
public static class ApplicationStore
|
||||||
{
|
{
|
||||||
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
||||||
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
||||||
|
|
||||||
private static readonly Dictionary<string, Aanvraag> _apps = new();
|
|
||||||
private static readonly object _gate = new();
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
public static Aanvraag Create(string type, string owner)
|
public static Aanvraag Create(string type, string owner)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
|
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;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Aanvraag? Get(string id, string owner)
|
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)
|
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.
|
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
||||||
@@ -64,12 +78,15 @@ public static class ApplicationStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
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.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
|
||||||
a.StepIndex = stepIndex;
|
a.StepIndex = stepIndex;
|
||||||
a.StepCount = stepCount;
|
a.StepCount = stepCount;
|
||||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
db.SaveChanges();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,9 +98,12 @@ public static class ApplicationStore
|
|||||||
List<string> docs;
|
List<string> docs;
|
||||||
lock (_gate)
|
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();
|
docs = a.DocumentIds.ToList();
|
||||||
_apps.Remove(id);
|
db.Applications.Remove(a);
|
||||||
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
||||||
return true;
|
return true;
|
||||||
@@ -96,7 +116,9 @@ public static class ApplicationStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
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.Submitted = true;
|
||||||
a.SubmittedAt = DateTimeOffset.UtcNow;
|
a.SubmittedAt = DateTimeOffset.UtcNow;
|
||||||
a.UpdatedAt = a.SubmittedAt.Value;
|
a.UpdatedAt = a.SubmittedAt.Value;
|
||||||
@@ -104,6 +126,7 @@ public static class ApplicationStore
|
|||||||
a.AutoApprovable = autoApprovable;
|
a.AutoApprovable = autoApprovable;
|
||||||
a.Reden = reject;
|
a.Reden = reject;
|
||||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||||
|
db.SaveChanges();
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The letter (brief) — one demo brief per owner, created from a template on first
|
/// 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
|
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
|
||||||
/// its guards live here (the server is authoritative for transitions); the FE mirrors
|
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
|
||||||
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
|
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
|
||||||
/// stub does not interpret it (no server-side placeholder linting in this slice).
|
/// 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>
|
/// </summary>
|
||||||
public sealed class BriefEntity
|
public sealed class BriefEntity
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,8 @@ public sealed class BriefEntity
|
|||||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
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
|
public static class BriefStore
|
||||||
{
|
{
|
||||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
// 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 }
|
public enum Outcome { Ok, Forbidden, Conflict }
|
||||||
|
|
||||||
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
|
|
||||||
private static readonly object _gate = new();
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
public static BriefEntity GetOrCreate(string owner)
|
public static BriefEntity GetOrCreate(string owner)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
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);
|
var created = BriefSeed.NewBrief(owner);
|
||||||
_byOwner[owner] = created;
|
db.Briefs.Add(created);
|
||||||
|
db.SaveChanges();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,11 +58,14 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
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 (!isDrafter) return (Outcome.Forbidden, null);
|
||||||
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||||
e.Sections = sections.ToList();
|
e.Sections = sections.ToList();
|
||||||
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,10 +74,13 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
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 (!isDrafter) return (Outcome.Forbidden, null);
|
||||||
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||||
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,9 +97,12 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
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);
|
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +110,11 @@ public static class BriefStore
|
|||||||
/// Test seam: clear the demo brief between tests.
|
/// Test seam: clear the demo brief between tests.
|
||||||
public static void Reset()
|
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
|
/// 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)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_byOwner.Remove(owner);
|
using var db = Db.Create();
|
||||||
|
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
|
||||||
var created = BriefSeed.NewBrief(owner);
|
var created = BriefSeed.NewBrief(owner);
|
||||||
_byOwner[owner] = created;
|
db.Briefs.Add(created);
|
||||||
|
db.SaveChanges();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,10 +141,13 @@ public static class BriefStore
|
|||||||
{
|
{
|
||||||
lock (_gate)
|
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 (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
||||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||||
e.Status = next();
|
e.Status = next();
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
backend/src/BigRegister.Api/Data/Db.cs
Normal file
27
backend/src/BigRegister.Api/Data/Db.cs
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
namespace BigRegister.Api.Data;
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
|
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
|
||||||
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
|
/// (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
|
/// 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.
|
/// serialized into a JSON response; only the dedicated content endpoint streams them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -13,33 +13,48 @@ public sealed record StoredDocument(
|
|||||||
public bool Linked { get; set; }
|
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>
|
/// <summary>
|
||||||
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
|
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
|
||||||
/// for a single-process demo store; swap for per-key locks if it ever serves load.
|
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
|
||||||
/// The audit log holds metadata only (never file content or other PII).
|
/// 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>
|
/// </summary>
|
||||||
public static class DocumentStore
|
public static class DocumentStore
|
||||||
{
|
{
|
||||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||||
public const string DemoOwner = "19012345601";
|
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();
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
|
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);
|
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);
|
Audit("upload", doc.DocumentId, categoryId, owner);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StoredDocument? Get(string documentId)
|
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
|
/// 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)
|
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
||||||
{
|
{
|
||||||
var set = localIds.ToHashSet();
|
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).
|
/// Mark digital documents as linked to a finalised submission (blocks user delete).
|
||||||
public static void Link(IEnumerable<string> documentIds)
|
public static void Link(IEnumerable<string> documentIds)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
foreach (var id in documentIds)
|
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 }
|
public enum DeleteResult { Ok, NotFound, Linked }
|
||||||
@@ -66,10 +92,13 @@ public static class DocumentStore
|
|||||||
string categoryId;
|
string categoryId;
|
||||||
lock (_gate)
|
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;
|
if (d.Linked) return DeleteResult.Linked;
|
||||||
categoryId = d.CategoryId;
|
categoryId = d.CategoryId;
|
||||||
_docs.Remove(documentId);
|
db.Documents.Remove(d);
|
||||||
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
Audit("delete-user", documentId, categoryId, owner);
|
Audit("delete-user", documentId, categoryId, owner);
|
||||||
return DeleteResult.Ok;
|
return DeleteResult.Ok;
|
||||||
@@ -82,9 +111,12 @@ public static class DocumentStore
|
|||||||
string categoryId;
|
string categoryId;
|
||||||
lock (_gate)
|
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;
|
categoryId = d.CategoryId;
|
||||||
_docs.Remove(documentId);
|
db.Documents.Remove(d);
|
||||||
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
Audit("delete-admin", documentId, categoryId, actor);
|
Audit("delete-admin", documentId, categoryId, actor);
|
||||||
return true;
|
return true;
|
||||||
@@ -92,11 +124,23 @@ public static class DocumentStore
|
|||||||
|
|
||||||
public static void Audit(string action, string documentId, string categoryId, string actor)
|
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
|
public static IReadOnlyList<AuditEntry> AuditLog
|
||||||
{
|
{
|
||||||
get { lock (_gate) return _audit.ToList(); }
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
return db.AuditEntries.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
backend/src/BigRegister.Api/Data/IdempotencyStore.cs
Normal file
25
backend/src/BigRegister.Api/Data/IdempotencyStore.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
195
backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs
generated
Normal file
195
backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ using BigRegister.Domain.Documents;
|
|||||||
using BigRegister.Domain.Intake;
|
using BigRegister.Domain.Intake;
|
||||||
using BigRegister.Domain.Registrations;
|
using BigRegister.Domain.Registrations;
|
||||||
using BigRegister.Domain.Submissions;
|
using BigRegister.Domain.Submissions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Console;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -20,13 +22,46 @@ builder.Services.ConfigureHttpJsonOptions(o =>
|
|||||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
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";
|
const string SpaCors = "spa";
|
||||||
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||||
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
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();
|
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.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
app.UseCors(SpaCors);
|
app.UseCors(SpaCors);
|
||||||
@@ -345,33 +380,48 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
|
|||||||
|
|
||||||
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
||||||
// generated reference and the caller's correlation id (the observability seam — a
|
// 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)
|
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)
|
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
|
||||||
? v.ToString()
|
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||||
: "none";
|
? 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)
|
if (reject is not null)
|
||||||
{
|
{
|
||||||
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
|
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
|
if (documents is not null)
|
||||||
// intent so a caseworker knows to expect the physical document.
|
{
|
||||||
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
// Link digital documents (blocks later user delete) and record post-delivery
|
||||||
foreach (var d in documents.Where(d => d.Channel == "post"))
|
// intent so a caseworker knows to expect the physical document.
|
||||||
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
||||||
|
foreach (var d in documents.Where(d => d.Channel == "post"))
|
||||||
|
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var reference = SubmissionRules.NewReference();
|
||||||
|
app.Logger.LogInformation(
|
||||||
|
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
||||||
|
kind, reference, cid, DateTimeOffset.UtcNow);
|
||||||
|
result = Results.Ok(new ReferentieResponse(reference));
|
||||||
}
|
}
|
||||||
|
|
||||||
var reference = SubmissionRules.NewReference();
|
if (idemKey is not null) IdempotencyStore.Set(idemKey, result);
|
||||||
app.Logger.LogInformation(
|
return result;
|
||||||
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
|
||||||
kind, reference, cid, DateTimeOffset.UtcNow);
|
|
||||||
return Results.Ok(new ReferentieResponse(reference));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
|
|
||||||
namespace BigRegister.Tests;
|
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();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace BigRegister.Tests;
|
|||||||
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
||||||
/// header: absent = drafter, "approver" = a different identity.
|
/// header: absent = drafter, "approver" = a different identity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client = factory.CreateClient();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
|
|
||||||
namespace BigRegister.Tests;
|
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();
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
@@ -125,6 +125,22 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
|
|||||||
res.EnsureSuccessStatusCode();
|
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 ---
|
// --- Document upload ---
|
||||||
|
|
||||||
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
||||||
|
|||||||
69
backend/tests/BigRegister.Tests/IdempotencyTests.cs
Normal file
69
backend/tests/BigRegister.Tests/IdempotencyTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs
Normal file
41
backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ services:
|
|||||||
- ASPNETCORE_ENVIRONMENT=Development
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
volumes:
|
volumes:
|
||||||
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
# ':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
|
- ./backend:/src:z
|
||||||
- api-bin:/src/src/BigRegister.Api/bin
|
- api-bin:/src/src/BigRegister.Api/bin
|
||||||
- api-obj:/src/src/BigRegister.Api/obj
|
- api-obj:/src/src/BigRegister.Api/obj
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ npm run test-storybook:ci
|
|||||||
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
|
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
|
||||||
only needs re-running if a WP unexpectedly touches `backend/`.
|
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
|
## Order
|
||||||
|
|
||||||
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
|
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-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-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-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-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 | todo |
|
| [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 | todo |
|
| [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 | todo |
|
| [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-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-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-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 |
|
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | todo |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Status: done (69880ef)
|
|||||||
Phase: 2 — CIBG fidelity
|
Phase: 2 — CIBG fidelity
|
||||||
|
|
||||||
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
|
> **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
|
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
|
||||||
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
|
> `.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
|
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
# WP-14 — Storybook taxonomy reorg + Layers MDX
|
# WP-14 — Storybook taxonomy reorg + Layers MDX
|
||||||
|
|
||||||
Status: todo
|
Status: done (8b19fad)
|
||||||
Phase: 3 — Storybook as curriculum
|
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
|
## Why
|
||||||
|
|
||||||
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
|
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
|
||||||
@@ -58,11 +67,11 @@ Domein/
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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.
|
pinned.
|
||||||
- [ ] Zero story titles outside the scheme (grep `title:` and eyeball).
|
- [x] Zero story titles outside the scheme (grep `title:` and eyeball).
|
||||||
- [ ] `layers.mdx` renders; existing MDX pages unbroken.
|
- [x] `layers.mdx` renders; existing MDX pages unbroken.
|
||||||
- [ ] Convention in CLAUDE.md.
|
- [x] Convention in CLAUDE.md.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
# WP-15 — Missing stories: shell + brief components
|
# WP-15 — Missing stories: shell + brief components
|
||||||
|
|
||||||
Status: todo
|
Status: done (0cfb01f)
|
||||||
Phase: 3 — Storybook as curriculum
|
Phase: 3 — Storybook as curriculum
|
||||||
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically)
|
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
|
## Why
|
||||||
|
|
||||||
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none:
|
"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
|
## 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
|
co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
|
||||||
norm — note the norm in this file when checked).
|
norm — note the norm in this file when checked). **Confirmed norm:** `*.page.ts`
|
||||||
- [ ] All new stories pass the axe gate (or carry a justified skip).
|
files (9 of them) have never had stories; every `*.component.ts` now does.
|
||||||
- [ ] Titles follow WP-14.
|
- [x] All new stories pass the axe gate (or carry a justified skip).
|
||||||
|
- [x] Titles follow WP-14.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-16 — Component a11y: description wiring + alert role
|
# WP-16 — Component a11y: description wiring + alert role
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 4 — a11y
|
Phase: 4 — a11y
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -57,12 +57,25 @@ Audit findings axe can't (fully) catch:
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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.
|
login-form BSN hint announced.
|
||||||
- [ ] `-error` id appended exactly when invalid; order stable.
|
- [x] `-error` id appended exactly when invalid; order stable.
|
||||||
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
|
- [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
|
||||||
CI gate.
|
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
|
## Verification
|
||||||
|
|
||||||
GREEN + `npm run test-storybook:ci` (includes the new play tests).
|
GREEN + `npm run test-storybook:ci` (includes the new play tests).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
|
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 4 — a11y
|
Phase: 4 — a11y
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -59,11 +59,30 @@ Three app-level gaps close the WCAG story:
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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.
|
view transitions still play.
|
||||||
- [ ] Template a11y rules active and _proven_ to fire; lint green.
|
- [x] Template a11y rules active and _proven_ to fire; lint green.
|
||||||
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
- [x] 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] `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
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-19 — Playwright e2e smoke
|
# WP-19 — Playwright e2e smoke
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -70,14 +70,46 @@ serve` backgrounded), wait-on both ports, `npm run e2e`. `timeout-minutes: 15`
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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.
|
`dotnet run` run manually.
|
||||||
- [ ] CI job `e2e` is green and runs on every PR alongside the existing jobs.
|
- [x] 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] The happy-path spec exercises a real wizard submit against the real backend
|
||||||
(not mocked) and asserts on the resulting UI state.
|
(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.
|
`?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
|
## Verification
|
||||||
|
|
||||||
`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears
|
`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-20 — Second locale proof
|
# WP-20 — Second locale proof
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -73,12 +73,14 @@ seam is built into every component but never proven to actually work end to end.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
|
- [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
|
||||||
- [ ] `messages.en.xlf` exists with a translation for every extracted unit.
|
- [x] `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 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.
|
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,
|
- [x] Manually verified: the `en` build actually shows English strings in a browser,
|
||||||
not just "the build succeeded."
|
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
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -98,22 +98,41 @@ delay })` in `httpClientFetch`'s pipe, per the existing header comment's own
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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.
|
just lines inside `Submit`); the id is echoed in the response headers.
|
||||||
- [ ] Replaying a submit with the same `Idempotency-Key` returns the same result
|
Verified manually: `LogBrief`'s log line — which never interpolates a `Cid`
|
||||||
without minting a second reference (backend test proves this).
|
itself — now prints `=> CorrelationId:scope-check-5` from the middleware's
|
||||||
- [ ] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
|
`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).
|
any FE-side retry of that submit (not regenerated per HTTP attempt).
|
||||||
- [ ] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
|
`runSubmit` mints it once and threads it via `withIdempotencyKey`; covered by
|
||||||
or a forced 5xx); POST/PUT/DELETE never auto-retry.
|
`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
|
## Verification
|
||||||
|
|
||||||
GREEN + `cd backend && dotnet test`. Manual: `?scenario=error` on a GET-backed page,
|
GREEN + `cd backend && dotnet test` (84 passing) — done. Manual: confirmed via curl
|
||||||
confirm a retry attempt happens (network tab shows 2 requests) before the error
|
against a locally running backend that `X-Correlation-Id` is echoed (client-supplied
|
||||||
state renders; submit a wizard twice with a manually replayed idempotency key (e.g.
|
and server-generated) and that replaying `/api/v1/change-requests` with the same
|
||||||
via curl against the backend directly) and confirm the second call doesn't create a
|
`Idempotency-Key` returns the identical `referentie` while a different key mints a
|
||||||
duplicate application.
|
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
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-22 — Durable persistence (optional tier)
|
# WP-22 — Durable persistence (optional tier)
|
||||||
|
|
||||||
Status: todo
|
Status: done (pending commit)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -98,22 +98,30 @@ speculatively.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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.
|
`Data/*.cs` for application/document/brief state.
|
||||||
- [ ] Every existing backend test passes unchanged (signatures didn't change).
|
- [x] Every existing backend test passes unchanged (signatures didn't change).
|
||||||
- [ ] Restarting the backend process preserves previously created applications,
|
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).
|
documents, and brief drafts (manually verified).
|
||||||
- [ ] The audit log survives a restart and is queryable (even if no new endpoint
|
- [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).
|
exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries`
|
||||||
- [ ] `docker compose up` with the new volume also survives a container restart.
|
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
|
## Verification
|
||||||
|
|
||||||
`cd backend && dotnet test`. Manual: `dotnet run --project src/BigRegister.Api`,
|
`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
|
||||||
create an application via the FE or a curl request, kill and restart the process,
|
src/BigRegister.Api`, created an application via curl, killed and restarted the
|
||||||
confirm `GET /api/v1/applications` still returns it. Repeat with `docker compose up`
|
process, confirmed `GET /api/v1/applications` still returned it (repeated for the
|
||||||
|
brief). Repeated the same check against the **real** `docker compose up` stack
|
||||||
- `docker compose restart api`.
|
(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
|
## 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
|
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,
|
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).
|
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
59
docs/wcag-checklist.md
Normal 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
32
e2e/error-state.spec.ts
Normal 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
59
e2e/smoke.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
import angular from 'angular-eslint';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enforces the architecture's working agreements that were previously only
|
* Enforces the architecture's working agreements that were previously only
|
||||||
@@ -28,8 +29,18 @@ export default [
|
|||||||
},
|
},
|
||||||
plugins: { '@typescript-eslint': tseslint.plugin },
|
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||||
rules: { '@typescript-eslint/no-explicit-any': 'error' },
|
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.
|
// Tests legitimately use `any` to feed invalid messages/states into reducers.
|
||||||
{
|
{
|
||||||
files: ['src/**/*.spec.ts'],
|
files: ['src/**/*.spec.ts'],
|
||||||
|
|||||||
4372
package-lock.json
generated
4372
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
|||||||
"build-storybook": "ng run atomic-design-poc:build-storybook",
|
"build-storybook": "ng run atomic-design-poc:build-storybook",
|
||||||
"test-storybook": "test-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\"",
|
"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,
|
"private": true,
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
@@ -39,11 +41,13 @@
|
|||||||
"@angular/localize": "^22.0.4",
|
"@angular/localize": "^22.0.4",
|
||||||
"@angular/platform-browser-dynamic": "^22.0.0",
|
"@angular/platform-browser-dynamic": "^22.0.0",
|
||||||
"@compodoc/compodoc": "^1.2.1",
|
"@compodoc/compodoc": "^1.2.1",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@storybook/addon-a11y": "^10.4.6",
|
"@storybook/addon-a11y": "^10.4.6",
|
||||||
"@storybook/addon-docs": "^10.4.6",
|
"@storybook/addon-docs": "^10.4.6",
|
||||||
"@storybook/addon-onboarding": "^10.4.6",
|
"@storybook/addon-onboarding": "^10.4.6",
|
||||||
"@storybook/angular": "^10.4.6",
|
"@storybook/angular": "^10.4.6",
|
||||||
"@storybook/test-runner": "^0.24.4",
|
"@storybook/test-runner": "^0.24.4",
|
||||||
|
"angular-eslint": "^22.0.0",
|
||||||
"axe-playwright": "^2.2.2",
|
"axe-playwright": "^2.2.2",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
|
|||||||
29
playwright.config.ts
Normal file
29
playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
isDevMode,
|
isDevMode,
|
||||||
provideBrowserGlobalErrorListeners,
|
provideBrowserGlobalErrorListeners,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideRouter, withViewTransitions } from '@angular/router';
|
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||||
import type { ActivatedRouteSnapshot } from '@angular/router';
|
import type { ActivatedRouteSnapshot } from '@angular/router';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { registerLocaleData } from '@angular/common';
|
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 { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
import { SESSION_PORT } from '@shared/application/session.port';
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
import { SessionStore } from '@auth/application/session.store';
|
import { SessionStore } from '@auth/application/session.store';
|
||||||
|
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||||
|
|
||||||
registerLocaleData(localeNl);
|
registerLocaleData(localeNl);
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(
|
provideRouter(
|
||||||
routes,
|
routes,
|
||||||
|
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
|
||||||
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
|
// 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
|
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
|
||||||
// animate: for the transition's duration Firefox's `::view-transition`
|
// animate: for the transition's duration Firefox's `::view-transition`
|
||||||
@@ -48,5 +50,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideApiClient(),
|
provideApiClient(),
|
||||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||||
|
provideRouteFocus(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
|||||||
i18n-description="@@login.bsnDescription"
|
i18n-description="@@login.bsnDescription"
|
||||||
description="9 cijfers (demo: vul iets in)"
|
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>
|
||||||
|
|
||||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { LoginFormComponent } from './login-form.component';
|
import { LoginFormComponent } from './login-form.component';
|
||||||
|
|
||||||
const meta: Meta<LoginFormComponent> = {
|
const meta: Meta<LoginFormComponent> = {
|
||||||
title: 'Organisms/Login Form',
|
title: 'Domein/Auth/Login Form',
|
||||||
component: LoginFormComponent,
|
component: LoginFormComponent,
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|||||||
|
|
||||||
approveResult = {
|
approveResult = {
|
||||||
ok: true,
|
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();
|
await store.approve();
|
||||||
expect(store.busy()).toBe(false);
|
expect(store.busy()).toBe(false);
|
||||||
|
|||||||
@@ -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: [] } };
|
||||||
47
src/app/brief/ui/letter-block/letter-block.stories.ts
Normal file
47
src/app/brief/ui/letter-block/letter-block.stories.ts
Normal 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 } };
|
||||||
@@ -118,7 +118,7 @@ const render = (b: Brief, decisions: BriefDecisions) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const meta: Meta<LetterComposerComponent> = {
|
const meta: Meta<LetterComposerComponent> = {
|
||||||
title: 'Organisms/Letter Composer',
|
title: 'Domein/Brief/Letter Composer',
|
||||||
component: LetterComposerComponent,
|
component: LetterComposerComponent,
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
88
src/app/brief/ui/letter-preview/letter-preview.stories.ts
Normal file
88
src/app/brief/ui/letter-preview/letter-preview.stories.ts
Normal 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: [] } };
|
||||||
55
src/app/brief/ui/letter-section/letter-section.stories.ts
Normal file
55
src/app/brief/ui/letter-section/letter-section.stories.ts
Normal 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 } };
|
||||||
37
src/app/brief/ui/passage-picker/passage-picker.stories.ts
Normal file
37
src/app/brief/ui/passage-picker/passage-picker.stories.ts
Normal 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 = {};
|
||||||
@@ -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 } };
|
||||||
@@ -10,7 +10,7 @@ import { Uren } from '@registratie/domain/value-objects/uren';
|
|||||||
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
||||||
|
|
||||||
const meta: Meta<HerregistratieWizardComponent> = {
|
const meta: Meta<HerregistratieWizardComponent> = {
|
||||||
title: 'Herregistratie/Wizard',
|
title: 'Domein/Herregistratie/Wizard',
|
||||||
component: HerregistratieWizardComponent,
|
component: HerregistratieWizardComponent,
|
||||||
// The wizard injects BigProfileStore (for the optimistic cross-page flag),
|
// The wizard injects BigProfileStore (for the optimistic cross-page flag),
|
||||||
// which creates httpResources — so the story needs an HttpClient.
|
// which creates httpResources — so the story needs an HttpClient.
|
||||||
|
|||||||
@@ -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 validData = { werktBuitenland: false, uren: 4160 as Uren, punten: 200 as Uren };
|
||||||
|
|
||||||
const meta: Meta<IntakeWizardComponent> = {
|
const meta: Meta<IntakeWizardComponent> = {
|
||||||
title: 'Herregistratie/IntakeWizard',
|
title: 'Domein/Herregistratie/IntakeWizard',
|
||||||
component: IntakeWizardComponent,
|
component: IntakeWizardComponent,
|
||||||
// Injects BigProfileStore (optimistic flag) which creates httpResources.
|
// Injects BigProfileStore (optimistic flag) which creates httpResources.
|
||||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||||
|
|||||||
@@ -72,4 +72,12 @@ export class BigProfileStore {
|
|||||||
rollbackHerregistratie() {
|
rollbackHerregistratie() {
|
||||||
this.pending.set(false); // submission failed — undo the optimistic flag
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
|||||||
const postcode = parsePostcode('2514 EA');
|
const postcode = parsePostcode('2514 EA');
|
||||||
if (!postcode.ok) throw new Error('fixture postcode should parse');
|
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>) {
|
function setup(adapter: Partial<ChangeRequestAdapter>) {
|
||||||
TestBed.configureTestingModule({ providers: [{ provide: ChangeRequestAdapter, useValue: adapter }] });
|
TestBed.configureTestingModule({
|
||||||
|
providers: [{ provide: ChangeRequestAdapter, useValue: adapter }],
|
||||||
|
});
|
||||||
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
|
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ describe('change-request reduce', () => {
|
|||||||
it('SetField updates the draft while editing', () => {
|
it('SetField updates the draft while editing', () => {
|
||||||
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
|
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
|
||||||
expect(s.tag).toBe('Editing');
|
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', () => {
|
it('Submit with an invalid draft stays Editing and reports field errors', () => {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { parseAantekening } from './big-register.adapter';
|
|||||||
|
|
||||||
describe('big-register.adapter parse boundary', () => {
|
describe('big-register.adapter parse boundary', () => {
|
||||||
it('parses known aantekening types', () => {
|
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,
|
ok: true,
|
||||||
value: { type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' },
|
value: { type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const base = {
|
|||||||
} satisfies Omit<Aanvraag, 'status'>;
|
} satisfies Omit<Aanvraag, 'status'>;
|
||||||
|
|
||||||
const meta: Meta<AanvraagBlockComponent> = {
|
const meta: Meta<AanvraagBlockComponent> = {
|
||||||
title: 'Organisms/Aanvraag Block',
|
title: 'Domein/Registratie/Aanvraag Block',
|
||||||
component: AanvraagBlockComponent,
|
component: AanvraagBlockComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { moduleMetadata } from '@storybook/angular';
|
|||||||
import { AddressFieldsComponent } from './address-fields.component';
|
import { AddressFieldsComponent } from './address-fields.component';
|
||||||
|
|
||||||
const meta: Meta<AddressFieldsComponent> = {
|
const meta: Meta<AddressFieldsComponent> = {
|
||||||
title: 'Organisms/Address Fields',
|
title: 'Domein/Registratie/Address Fields',
|
||||||
component: AddressFieldsComponent,
|
component: AddressFieldsComponent,
|
||||||
decorators: [moduleMetadata({ imports: [AddressFieldsComponent] })],
|
decorators: [moduleMetadata({ imports: [AddressFieldsComponent] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
} from '@registratie/ui/address-fields/address-fields.component';
|
} from '@registratie/ui/address-fields/address-fields.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { whenTag } from '@shared/kernel/fp';
|
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';
|
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const validData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const meta: Meta<ChangeRequestFormComponent> = {
|
const meta: Meta<ChangeRequestFormComponent> = {
|
||||||
title: 'Organisms/Change Request Form',
|
title: 'Domein/Registratie/Change Request Form',
|
||||||
component: ChangeRequestFormComponent,
|
component: ChangeRequestFormComponent,
|
||||||
// The form injects ApiClient (over HttpClient) for the submit command.
|
// The form injects ApiClient (over HttpClient) for the submit command.
|
||||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||||
|
|||||||
@@ -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>
|
<ng-template appAsyncLoaded>
|
||||||
@if (profile(); as p) {
|
@if (profile(); as p) {
|
||||||
@let tasks = tasksFor(p.registration);
|
@let tasks = tasksFor(p.registration);
|
||||||
@@ -153,7 +153,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
|
|||||||
>Specialismen en aantekeningen</app-heading
|
>Specialismen en aantekeningen</app-heading
|
||||||
>
|
>
|
||||||
<div class="app-section">
|
<div class="app-section">
|
||||||
<app-async [data]="store.aantekeningen()">
|
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
|
||||||
<ng-template appAsyncLoaded>
|
<ng-template appAsyncLoaded>
|
||||||
@if (aantekeningen(); as r) {
|
@if (aantekeningen(); as r) {
|
||||||
<app-registration-table [rows]="r" />
|
<app-registration-table [rows]="r" />
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const validData: ValidRegistratie = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const meta: Meta<RegistratieWizardComponent> = {
|
const meta: Meta<RegistratieWizardComponent> = {
|
||||||
title: 'Registratie/RegistratieWizard',
|
title: 'Domein/Registratie/RegistratieWizard',
|
||||||
component: RegistratieWizardComponent,
|
component: RegistratieWizardComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const base = {
|
|||||||
} satisfies Omit<Registration, 'status'>;
|
} satisfies Omit<Registration, 'status'>;
|
||||||
|
|
||||||
const meta: Meta<RegistrationSummaryComponent> = {
|
const meta: Meta<RegistrationSummaryComponent> = {
|
||||||
title: 'Organisms/Registration Summary',
|
title: 'Domein/Registratie/Registration Summary',
|
||||||
component: RegistrationSummaryComponent,
|
component: RegistrationSummaryComponent,
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { RegistrationTableComponent } from './registration-table.component';
|
import { RegistrationTableComponent } from './registration-table.component';
|
||||||
|
|
||||||
const meta: Meta<RegistrationTableComponent> = {
|
const meta: Meta<RegistrationTableComponent> = {
|
||||||
title: 'Organisms/Registration Table',
|
title: 'Domein/Registratie/Registration Table',
|
||||||
component: RegistrationTableComponent,
|
component: RegistrationTableComponent,
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
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
|
* 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
|
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
|
||||||
* its own payload mapping. The backend re-validates and returns a 422
|
* its own payload mapping. The backend re-validates and returns a 422
|
||||||
* ProblemDetails on rejection, surfaced here as the error string.
|
* 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>(
|
export async function runSubmit<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
fallback: string,
|
fallback: string,
|
||||||
): Promise<Result<string, T>> {
|
): Promise<Result<string, T>> {
|
||||||
try {
|
try {
|
||||||
return ok(await fn());
|
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return err(problemDetail(e, fallback));
|
return err(problemDetail(e, fallback));
|
||||||
}
|
}
|
||||||
|
|||||||
85
src/app/shared/infrastructure/api-client.provider.spec.ts
Normal file
85
src/app/shared/infrastructure/api-client.provider.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,34 @@
|
|||||||
import { Provider } from '@angular/core';
|
import { Provider } from '@angular/core';
|
||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
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 { ApiClient, ProblemDetails } from './api-client';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
/** Single place every API call passes through: the seam for cross-cutting concerns. */
|
/** Single place every API call passes through: the seam for cross-cutting concerns. */
|
||||||
const REQUEST_TIMEOUT_MS = 10_000;
|
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
|
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
|
||||||
* client expects, so every API call flows through HttpClient interceptors (the
|
* 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:
|
* Angular's HTTP stack — i.e. the one seam to add:
|
||||||
* - timeout (done — REQUEST_TIMEOUT_MS),
|
* - timeout (done — REQUEST_TIMEOUT_MS),
|
||||||
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
|
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
|
||||||
* - idempotency key for writes (done — Idempotency-Key; a real retry would thread
|
* - idempotency key for writes (done — Idempotency-Key, stable per logical
|
||||||
* a STABLE key per logical submit so re-sends dedupe; here it's per-attempt),
|
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
|
||||||
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
|
* - 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 {
|
return {
|
||||||
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||||
const method = (init?.method ?? 'GET').toUpperCase();
|
const method = (init?.method ?? 'GET').toUpperCase();
|
||||||
@@ -28,17 +52,18 @@ function httpClientFetch(http: HttpClient) {
|
|||||||
...((init?.headers ?? {}) as Record<string, string>),
|
...((init?.headers ?? {}) as Record<string, string>),
|
||||||
'X-Correlation-Id': crypto.randomUUID(),
|
'X-Correlation-Id': crypto.randomUUID(),
|
||||||
};
|
};
|
||||||
if (method !== 'GET') headers['Idempotency-Key'] = crypto.randomUUID();
|
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
|
||||||
try {
|
try {
|
||||||
|
const request$ = http
|
||||||
|
.request(method, url as string, {
|
||||||
|
body: init?.body as string | undefined,
|
||||||
|
headers,
|
||||||
|
observe: 'response',
|
||||||
|
responseType: 'text',
|
||||||
|
})
|
||||||
|
.pipe(timeout(REQUEST_TIMEOUT_MS));
|
||||||
const res = await firstValueFrom(
|
const res = await firstValueFrom(
|
||||||
http
|
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
|
||||||
.request(method, url as string, {
|
|
||||||
body: init?.body as string | undefined,
|
|
||||||
headers,
|
|
||||||
observe: 'response',
|
|
||||||
responseType: 'text',
|
|
||||||
})
|
|
||||||
.pipe(timeout(REQUEST_TIMEOUT_MS)),
|
|
||||||
);
|
);
|
||||||
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
|
// 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;
|
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ export function assertNever(x: never): never {
|
|||||||
/** A computation that either succeeded with a value or failed with an error.
|
/** A computation that either succeeded with a value or failed with an error.
|
||||||
Plain objects (no classes) to match the signal/httpResource ergonomics. */
|
Plain objects (no classes) to match the signal/httpResource ergonomics. */
|
||||||
export type Result<E, T> =
|
export type Result<E, T> =
|
||||||
| { readonly ok: true; readonly value: T }
|
{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
|
||||||
| { readonly ok: false; readonly error: E };
|
|
||||||
|
|
||||||
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
|
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
|
||||||
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
|
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { BreadcrumbComponent } from './breadcrumb.component';
|
import { BreadcrumbComponent } from './breadcrumb.component';
|
||||||
|
|
||||||
const meta: Meta<BreadcrumbComponent> = {
|
const meta: Meta<BreadcrumbComponent> = {
|
||||||
title: 'Layout/Breadcrumb',
|
title: 'Design System/Molecules/Breadcrumb',
|
||||||
component: BreadcrumbComponent,
|
component: BreadcrumbComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PageShellComponent } from './page-shell.component';
|
|||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
|
||||||
const meta: Meta<PageShellComponent> = {
|
const meta: Meta<PageShellComponent> = {
|
||||||
title: 'Templates/PageShell',
|
title: 'Design System/Templates/PageShell',
|
||||||
component: PageShellComponent,
|
component: PageShellComponent,
|
||||||
decorators: [
|
decorators: [
|
||||||
applicationConfig({ providers: [provideRouter([])] }),
|
applicationConfig({ providers: [provideRouter([])] }),
|
||||||
|
|||||||
45
src/app/shared/layout/route-focus.ts
Normal file
45
src/app/shared/layout/route-focus.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/app/shared/layout/shell/shell.stories.ts
Normal file
16
src/app/shared/layout/shell/shell.stories.ts
Normal 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 = {};
|
||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { SiteFooterComponent } from './site-footer.component';
|
import { SiteFooterComponent } from './site-footer.component';
|
||||||
|
|
||||||
const meta: Meta<SiteFooterComponent> = {
|
const meta: Meta<SiteFooterComponent> = {
|
||||||
title: 'Layout/Site Footer',
|
title: 'Design System/Organisms/Site Footer',
|
||||||
component: SiteFooterComponent,
|
component: SiteFooterComponent,
|
||||||
render: () => ({ template: `<app-site-footer />` }),
|
render: () => ({ template: `<app-site-footer />` }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { SiteHeaderComponent } from './site-header.component';
|
import { SiteHeaderComponent } from './site-header.component';
|
||||||
|
|
||||||
const meta: Meta<SiteHeaderComponent> = {
|
const meta: Meta<SiteHeaderComponent> = {
|
||||||
title: 'Layout/Site Header',
|
title: 'Design System/Organisms/Site Header',
|
||||||
component: SiteHeaderComponent,
|
component: SiteHeaderComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { WizardShellComponent } from './wizard-shell.component';
|
import { WizardShellComponent } from './wizard-shell.component';
|
||||||
|
|
||||||
const meta: Meta<WizardShellComponent> = {
|
const meta: Meta<WizardShellComponent> = {
|
||||||
title: 'Templates/WizardShell',
|
title: 'Design System/Templates/WizardShell',
|
||||||
component: WizardShellComponent,
|
component: WizardShellComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
@@ -19,8 +19,7 @@ const meta: Meta<WizardShellComponent> = {
|
|||||||
cibgGap: true,
|
cibgGap: true,
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component: 'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
|
||||||
'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ const ICON_LABELS: Record<AlertType, string> = {
|
|||||||
/** Atom: alert/message banner — the CIBG Huisstijl "melding"
|
/** Atom: alert/message banner — the CIBG Huisstijl "melding"
|
||||||
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
|
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
|
||||||
`.feedback feedback-*` classes: the design system owns surface + icon; we add
|
`.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({
|
@Component({
|
||||||
selector: 'app-alert',
|
selector: 'app-alert',
|
||||||
styles: [
|
styles: [
|
||||||
@@ -31,7 +34,7 @@ const ICON_LABELS: Record<AlertType, string> = {
|
|||||||
[class.feedback-success]="type() === 'ok'"
|
[class.feedback-success]="type() === 'ok'"
|
||||||
[class.feedback-warning]="type() === 'warning'"
|
[class.feedback-warning]="type() === 'warning'"
|
||||||
[class.feedback-error]="type() === 'error'"
|
[class.feedback-error]="type() === 'error'"
|
||||||
role="status"
|
[attr.role]="type() === 'error' ? 'alert' : 'status'"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>
|
>
|
||||||
<span class="icon"
|
<span class="icon"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/angular';
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
import { AlertComponent } from './alert.component';
|
import { AlertComponent } from './alert.component';
|
||||||
|
|
||||||
const meta: Meta<AlertComponent> = {
|
const meta: Meta<AlertComponent> = {
|
||||||
title: 'Atoms/Alert',
|
title: 'Design System/Atoms/Alert',
|
||||||
component: AlertComponent,
|
component: AlertComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
@@ -12,7 +13,28 @@ const meta: Meta<AlertComponent> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<AlertComponent>;
|
type Story = StoryObj<AlertComponent>;
|
||||||
|
|
||||||
export const Info: Story = { args: { type: 'info' } };
|
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
|
||||||
export const Ok: Story = { args: { type: 'ok' } };
|
export const Info: Story = {
|
||||||
export const Warning: Story = { args: { type: 'warning' } };
|
args: { type: 'info' },
|
||||||
export const Error: Story = { args: { type: 'error' } };
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { ApplicationLinkComponent } from './application-link.component';
|
import { ApplicationLinkComponent } from './application-link.component';
|
||||||
|
|
||||||
const meta: Meta<ApplicationLinkComponent> = {
|
const meta: Meta<ApplicationLinkComponent> = {
|
||||||
title: 'Molecules/Application Link',
|
title: 'Design System/Molecules/Application Link',
|
||||||
component: ApplicationLinkComponent,
|
component: ApplicationLinkComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ApplicationListComponent } from './application-list.component';
|
|||||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
||||||
|
|
||||||
const meta: Meta<ApplicationListComponent> = {
|
const meta: Meta<ApplicationListComponent> = {
|
||||||
title: 'Molecules/Application List',
|
title: 'Design System/Molecules/Application List',
|
||||||
component: ApplicationListComponent,
|
component: ApplicationListComponent,
|
||||||
decorators: [
|
decorators: [
|
||||||
applicationConfig({ providers: [provideRouter([])] }),
|
applicationConfig({ providers: [provideRouter([])] }),
|
||||||
|
|||||||
@@ -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 { NgTemplateOutlet } from '@angular/common';
|
||||||
import type { Resource } from '@angular/core';
|
import type { Resource } from '@angular/core';
|
||||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
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 = () => {
|
retry = () => {
|
||||||
const r = this.resource();
|
const r = this.resource();
|
||||||
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
|
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
|
||||||
(r as { reload: () => void }).reload();
|
(r as { reload: () => void }).reload();
|
||||||
}
|
}
|
||||||
|
this.retryClicked.emit();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta: Meta = {
|
const meta: Meta = {
|
||||||
title: 'Molecules/Async States',
|
title: 'Design System/Molecules/Async States',
|
||||||
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
|
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
// isEmpty is a function — Storybook strips function args, so set it here.
|
// isEmpty is a function — Storybook strips function args, so set it here.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { ButtonComponent } from './button.component';
|
import { ButtonComponent } from './button.component';
|
||||||
|
|
||||||
const meta: Meta<ButtonComponent> = {
|
const meta: Meta<ButtonComponent> = {
|
||||||
title: 'Atoms/Button',
|
title: 'Design System/Atoms/Button',
|
||||||
component: ButtonComponent,
|
component: ButtonComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { CardComponent } from './card.component';
|
import { CardComponent } from './card.component';
|
||||||
|
|
||||||
const meta: Meta<CardComponent> = {
|
const meta: Meta<CardComponent> = {
|
||||||
title: 'Molecules/Card',
|
title: 'Design System/Molecules/Card',
|
||||||
component: CardComponent,
|
component: CardComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { CheckboxComponent } from './checkbox.component';
|
import { CheckboxComponent } from './checkbox.component';
|
||||||
|
|
||||||
const meta: Meta<CheckboxComponent> = {
|
const meta: Meta<CheckboxComponent> = {
|
||||||
title: 'Atoms/Checkbox',
|
title: 'Design System/Atoms/Checkbox',
|
||||||
component: CheckboxComponent,
|
component: CheckboxComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { ChoiceLinkComponent } from './choice-link.component';
|
import { ChoiceLinkComponent } from './choice-link.component';
|
||||||
|
|
||||||
const meta: Meta<ChoiceLinkComponent> = {
|
const meta: Meta<ChoiceLinkComponent> = {
|
||||||
title: 'Molecules/Choice Link',
|
title: 'Design System/Molecules/Choice Link',
|
||||||
component: ChoiceLinkComponent,
|
component: ChoiceLinkComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChoiceListComponent } from './choice-list.component';
|
|||||||
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
|
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
|
||||||
|
|
||||||
const meta: Meta<ChoiceListComponent> = {
|
const meta: Meta<ChoiceListComponent> = {
|
||||||
title: 'Molecules/Choice List',
|
title: 'Design System/Molecules/Choice List',
|
||||||
component: ChoiceListComponent,
|
component: ChoiceListComponent,
|
||||||
decorators: [
|
decorators: [
|
||||||
applicationConfig({ providers: [provideRouter([])] }),
|
applicationConfig({ providers: [provideRouter([])] }),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { ConfirmationComponent } from './confirmation.component';
|
import { ConfirmationComponent } from './confirmation.component';
|
||||||
|
|
||||||
const meta: Meta<ConfirmationComponent> = {
|
const meta: Meta<ConfirmationComponent> = {
|
||||||
title: 'Molecules/Confirmation',
|
title: 'Design System/Molecules/Confirmation',
|
||||||
component: ConfirmationComponent,
|
component: ConfirmationComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DataBlockComponent } from './data-block.component';
|
|||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
|
|
||||||
const meta: Meta<DataBlockComponent> = {
|
const meta: Meta<DataBlockComponent> = {
|
||||||
title: 'Molecules/Data Block',
|
title: 'Design System/Molecules/Data Block',
|
||||||
component: DataBlockComponent,
|
component: DataBlockComponent,
|
||||||
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
|
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { DataRowComponent } from './data-row.component';
|
import { DataRowComponent } from './data-row.component';
|
||||||
|
|
||||||
const meta: Meta<DataRowComponent> = {
|
const meta: Meta<DataRowComponent> = {
|
||||||
title: 'Molecules/Data Row',
|
title: 'Design System/Molecules/Data Row',
|
||||||
component: DataRowComponent,
|
component: DataRowComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { DebugStateComponent } from './debug-state.component';
|
|||||||
|
|
||||||
// Devtools, not a design-system atom — titled accordingly.
|
// Devtools, not a design-system atom — titled accordingly.
|
||||||
const meta: Meta<DebugStateComponent> = {
|
const meta: Meta<DebugStateComponent> = {
|
||||||
title: 'Devtools/State debug',
|
title: 'Design System/Devtools/State debug',
|
||||||
component: DebugStateComponent,
|
component: DebugStateComponent,
|
||||||
parameters: {
|
parameters: {
|
||||||
cibgGap: true,
|
cibgGap: true,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/angular';
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
import { moduleMetadata } from '@storybook/angular';
|
import { moduleMetadata } from '@storybook/angular';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
import { FormFieldComponent } from './form-field.component';
|
import { FormFieldComponent } from './form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||||
|
|
||||||
const meta: Meta<FormFieldComponent> = {
|
const meta: Meta<FormFieldComponent> = {
|
||||||
title: 'Molecules/Form Field',
|
title: 'Design System/Molecules/Form Field',
|
||||||
component: FormFieldComponent,
|
component: FormFieldComponent,
|
||||||
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
|
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
@@ -13,7 +14,7 @@ const meta: Meta<FormFieldComponent> = {
|
|||||||
template: `
|
template: `
|
||||||
<form class="form-horizontal">
|
<form class="form-horizontal">
|
||||||
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required">
|
<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>
|
</app-form-field>
|
||||||
</form>`,
|
</form>`,
|
||||||
}),
|
}),
|
||||||
@@ -23,6 +24,13 @@ type Story = StoryObj<FormFieldComponent>;
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
|
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 = {
|
export const WithError: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -31,4 +39,23 @@ export const WithError: Story = {
|
|||||||
error: 'Dit veld is verplicht.',
|
error: 'Dit veld is verplicht.',
|
||||||
required: true,
|
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');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { HeadingComponent } from './heading.component';
|
import { HeadingComponent } from './heading.component';
|
||||||
|
|
||||||
const meta: Meta<HeadingComponent> = {
|
const meta: Meta<HeadingComponent> = {
|
||||||
title: 'Atoms/Heading',
|
title: 'Design System/Atoms/Heading',
|
||||||
component: HeadingComponent,
|
component: HeadingComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { LinkComponent } from './link.component';
|
import { LinkComponent } from './link.component';
|
||||||
|
|
||||||
const meta: Meta<LinkComponent> = {
|
const meta: Meta<LinkComponent> = {
|
||||||
title: 'Atoms/Link',
|
title: 'Design System/Atoms/Link',
|
||||||
component: LinkComponent,
|
component: LinkComponent,
|
||||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
||||||
|
|
||||||
const meta: Meta<PlaceholderChipComponent> = {
|
const meta: Meta<PlaceholderChipComponent> = {
|
||||||
title: 'Atoms/Placeholder Chip',
|
title: 'Design System/Atoms/Placeholder Chip',
|
||||||
component: PlaceholderChipComponent,
|
component: PlaceholderChipComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
|||||||
import { RadioGroupComponent } from './radio-group.component';
|
import { RadioGroupComponent } from './radio-group.component';
|
||||||
|
|
||||||
const meta: Meta<RadioGroupComponent> = {
|
const meta: Meta<RadioGroupComponent> = {
|
||||||
title: 'Atoms/RadioGroup',
|
title: 'Design System/Atoms/RadioGroup',
|
||||||
component: RadioGroupComponent,
|
component: RadioGroupComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user