Compare commits
25 Commits
cbb8ae548c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c837f281 | |||
| 1bb9383344 | |||
| c07a33ee3e | |||
| 5a610c10f0 | |||
| 44eb2d2186 | |||
| 556f2f47bf | |||
| 40dbcb2606 | |||
| e276629107 | |||
| 26c2c5acd0 | |||
| e272869f00 | |||
| f3de30b72c | |||
| 85c805b8bd | |||
| 0cfb01f12c | |||
| ac1f0b6aeb | |||
| 8b19fad558 | |||
| cbf697b8fa | |||
| 9d58f597ea | |||
| 69880efd38 | |||
| 8078c499cb | |||
| 0d623f90e8 | |||
| e3cd908f4f | |||
| 199cbe1f8c | |||
| 34d34512b3 | |||
| 5d6a78d4ec | |||
| 7ec13d8b59 |
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
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
|
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
|
||||||
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
|
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
|
||||||
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||||
|
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
|
||||||
|
<link rel="stylesheet" href="letter.css" />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
30
CLAUDE.md
30
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
|
||||||
|
|
||||||
@@ -68,14 +70,21 @@ Default reflex — **if you're about to add a second/third boolean to track stat
|
|||||||
model a discriminated union instead.** Three tools, all in `shared/application`:
|
model a discriminated union instead.** Three tools, all in `shared/application`:
|
||||||
|
|
||||||
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
||||||
Combine sources with `map`/`map2`/`map3`/`andThen` (Failure > Loading > Success).
|
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
||||||
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
|
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
|
||||||
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
||||||
is delay-gated (~250ms) so fast connections don't flash.
|
is delay-gated (~250ms) so fast connections don't flash.
|
||||||
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
||||||
one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models
|
one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models
|
||||||
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
|
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
|
||||||
send messages, never mutate.
|
send messages, never mutate. **`createStore` is the one wiring idiom** — a page
|
||||||
|
never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level
|
||||||
|
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
|
||||||
|
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
|
||||||
|
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
|
||||||
|
model keeps prefixed _value_ exports instead (`initialUpload`/`reduceUpload`,
|
||||||
|
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
||||||
|
composition site.
|
||||||
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
||||||
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
|
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
|
||||||
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
|
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
|
||||||
@@ -113,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
|
||||||
|
|
||||||
@@ -138,6 +151,11 @@ not heavy component tests.
|
|||||||
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
||||||
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
|
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
|
||||||
fields + ad-hoc error signals.
|
fields + ad-hoc error signals.
|
||||||
|
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
|
||||||
|
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
|
||||||
|
(a domain function, a `$localize` string) uses the one hand-written
|
||||||
|
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
|
||||||
|
`toLocaleDateString` call.
|
||||||
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
||||||
[authGuard]` on protected routes (`app.routes.ts`).
|
[authGuard]` on protected routes (`app.routes.ts`).
|
||||||
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
||||||
|
|||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,47 @@ public sealed record BriefDto(
|
|||||||
IReadOnlyList<LetterSectionDto> Sections,
|
IReadOnlyList<LetterSectionDto> Sections,
|
||||||
BriefStatusDto Status, string DrafterId);
|
BriefStatusDto Status, string DrafterId);
|
||||||
|
|
||||||
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages);
|
// Decision flags for the CURRENT acting principal + this brief's live status
|
||||||
|
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
|
||||||
|
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend);
|
||||||
|
|
||||||
|
// The brief's screen DTO also carries the org template it renders with (WP-23):
|
||||||
|
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at
|
||||||
|
// send time (sent letters are immutable; a republish never re-renders them).
|
||||||
|
public sealed record BriefViewDto(
|
||||||
|
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
|
||||||
|
OrgTemplateDto OrgTemplate);
|
||||||
|
|
||||||
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
||||||
public sealed record RejectBriefRequest(string Comments);
|
public sealed record RejectBriefRequest(string Comments);
|
||||||
|
|
||||||
|
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
|
||||||
|
public sealed record MeDto(IReadOnlyList<string> Capabilities);
|
||||||
|
|
||||||
|
// --- Organization templates (WP-23, Brief v2 PRD §3) ---
|
||||||
|
// The second template axis: appearance/identity per sub-organization (letterhead,
|
||||||
|
// footer, signature, margins). Orthogonal to the case-type template (sections +
|
||||||
|
// placeholders); the two only meet at render time.
|
||||||
|
|
||||||
|
public sealed record MarginsDto(int TopMm, int RightMm, int BottomMm, int LeftMm);
|
||||||
|
|
||||||
|
// Version: 0 = a draft (work in progress), n>0 = the published snapshot it is.
|
||||||
|
public sealed record OrgTemplateDto(
|
||||||
|
string SubOrgId, string OrgName, string ReturnAddress, string? LogoDocumentId,
|
||||||
|
string FooterContact, string FooterLegal,
|
||||||
|
string SignatureName, string SignatureRole, string SignatureClosing,
|
||||||
|
MarginsDto Margins, int Version = 0);
|
||||||
|
|
||||||
|
public sealed record OrgTemplateVersionDto(int Version, string PublishedAt, OrgTemplateDto Template);
|
||||||
|
|
||||||
|
// Screen DTO for the admin editor: the editable draft, what's live, the append-only
|
||||||
|
// history, and the publish-impact count ("dit raakt N nog niet verzonden brieven").
|
||||||
|
public sealed record OrgTemplateAdminViewDto(
|
||||||
|
OrgTemplateDto Draft, int PublishedVersion,
|
||||||
|
IReadOnlyList<OrgTemplateVersionDto> History, int UnsentBriefs);
|
||||||
|
|
||||||
|
public sealed record SubOrgSummaryDto(string SubOrgId, string OrgName, int PublishedVersion);
|
||||||
|
|
||||||
|
public sealed record SaveOrgTemplateRequest(OrgTemplateDto Draft);
|
||||||
|
|
||||||
|
public sealed record PublishOrgTemplateResponse(int Version, int AffectedUnsentBriefs);
|
||||||
|
|||||||
71
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal file
71
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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>();
|
||||||
|
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
|
||||||
|
|
||||||
|
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>());
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<OrgTemplateEntity>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(t => t.SubOrgId);
|
||||||
|
e.Property(t => t.Draft).HasConversion(Json<OrgTemplateDto>());
|
||||||
|
e.Property(t => t.History).HasConversion(Json<List<OrgTemplateVersionDto>>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,13 +1,17 @@
|
|||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Domain.Authorization;
|
||||||
|
using BigRegister.Domain.Letters;
|
||||||
|
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
|
||||||
{
|
{
|
||||||
@@ -19,28 +23,40 @@ public sealed class BriefEntity
|
|||||||
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||||
public List<LetterSectionDto> Sections { get; set; } = new();
|
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||||
public BriefStatusDto Status { get; set; } = new("draft");
|
public BriefStatusDto Status { get; set; } = new("draft");
|
||||||
|
/// Which sub-organization's org template themes this letter (WP-23).
|
||||||
|
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
|
||||||
|
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
|
||||||
|
public int? SentOrgTemplateVersion { get; set; }
|
||||||
|
/// The composed HTML archived at send (WP-25) — from here on the preview endpoint
|
||||||
|
/// serves this verbatim, so a later org-template republish never re-renders it.
|
||||||
|
public string? ArchivedHtml { get; set; }
|
||||||
|
|
||||||
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).
|
||||||
public const string DrafterId = "demo-drafter";
|
public const string DrafterId = "demo-drafter";
|
||||||
public const string ApproverId = "demo-approver";
|
public const string ApproverId = "demo-approver";
|
||||||
|
public const string AdminId = "demo-admin";
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,11 +67,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,27 +83,42 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
|
public static (Outcome, BriefEntity?) Approve(string owner, Principal principal, string at) =>
|
||||||
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
|
Review(owner, principal, BriefAction.Approve, () =>
|
||||||
|
new BriefStatusDto("approved", ApprovedBy: Authz.ActingId(principal), ApprovedAt: at));
|
||||||
|
|
||||||
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
|
public static (Outcome, BriefEntity?) Reject(string owner, Principal principal, string comments, string at) =>
|
||||||
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
|
Review(owner, principal, BriefAction.Reject, () =>
|
||||||
|
new BriefStatusDto("rejected", RejectedBy: Authz.ActingId(principal), RejectedAt: at, Comments: comments));
|
||||||
|
|
||||||
public static (Outcome, BriefEntity?) Send(string owner, string at)
|
public static (Outcome, BriefEntity?) Send(string owner, string at)
|
||||||
{
|
{
|
||||||
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);
|
||||||
|
// Pin the org-template version the letter was sent with (WP-23): from here on
|
||||||
|
// its appearance is frozen — republishing the template touches unsent briefs only.
|
||||||
|
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
|
||||||
|
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint
|
||||||
|
// serves this verbatim once sent, so a later republish never re-renders it.
|
||||||
|
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
|
||||||
|
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
|
||||||
|
db.SaveChanges();
|
||||||
return (Outcome.Ok, e);
|
return (Outcome.Ok, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +126,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
|
||||||
@@ -101,23 +139,31 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve/reject share the guard: must be submitted, and the approver must differ
|
// Approve/reject share the guard: must be submitted, and the approver must differ
|
||||||
// from the drafter (a drafter cannot approve their own letter).
|
// from the drafter (a drafter cannot approve their own letter). The SoD check is
|
||||||
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
|
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
|
||||||
|
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
|
||||||
|
// inline check exactly.
|
||||||
|
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
using var db = Db.Create();
|
||||||
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
|
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||||
|
if (e is null) return (Outcome.Conflict, null);
|
||||||
|
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
||||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||||
e.Status = next(e);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
backend/src/BigRegister.Api/Data/Migrations/20260705085857_OrgTemplates.Designer.cs
generated
Normal file
223
backend/src/BigRegister.Api/Data/Migrations/20260705085857_OrgTemplates.Designer.cs
generated
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// <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("20260705085857_OrgTemplates")]
|
||||||
|
partial class OrgTemplates
|
||||||
|
{
|
||||||
|
/// <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<int?>("SentOrgTemplateVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.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.OrgTemplateEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Draft")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("History")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PublishedVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("SubOrgId");
|
||||||
|
|
||||||
|
b.ToTable("OrgTemplates");
|
||||||
|
});
|
||||||
|
|
||||||
|
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,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class OrgTemplates : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SentOrgTemplateVersion",
|
||||||
|
table: "Briefs",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SubOrgId",
|
||||||
|
table: "Briefs",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
// Briefs from before this migration adopt the seeded default sub-org.
|
||||||
|
defaultValue: "cibg-registers");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OrgTemplates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SubOrgId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Draft = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
PublishedVersion = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
History = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OrgTemplates", x => x.SubOrgId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OrgTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SentOrgTemplateVersion",
|
||||||
|
table: "Briefs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubOrgId",
|
||||||
|
table: "Briefs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs
generated
Normal file
226
backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs
generated
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// <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("20260705101743_ArchivedHtml")]
|
||||||
|
partial class ArchivedHtml
|
||||||
|
{
|
||||||
|
/// <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>("ArchivedHtml")
|
||||||
|
.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<int?>("SentOrgTemplateVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.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.OrgTemplateEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Draft")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("History")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PublishedVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("SubOrgId");
|
||||||
|
|
||||||
|
b.ToTable("OrgTemplates");
|
||||||
|
});
|
||||||
|
|
||||||
|
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,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ArchivedHtml : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ArchivedHtml",
|
||||||
|
table: "Briefs",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ArchivedHtml",
|
||||||
|
table: "Briefs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// <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>("ArchivedHtml")
|
||||||
|
.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<int?>("SentOrgTemplateVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.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.OrgTemplateEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Draft")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("History")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PublishedVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("SubOrgId");
|
||||||
|
|
||||||
|
b.ToTable("OrgTemplates");
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
backend/src/BigRegister.Api/Data/OrgTemplateStore.cs
Normal file
204
backend/src/BigRegister.Api/Data/OrgTemplateStore.cs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Organization template per sub-organization (WP-23, Brief v2 PRD §3): one row per
|
||||||
|
/// sub-org. `Draft` is the work-in-progress payload (Version 0), `History` the
|
||||||
|
/// append-only list of published snapshots, `PublishedVersion` points into it.
|
||||||
|
/// Rollback copies an old snapshot back into the draft — it never rewrites history.
|
||||||
|
/// Mirrors <see cref="BriefStore"/>: static class, short-lived context per call,
|
||||||
|
/// nested DTO shapes stored as JSON text columns (WP-22 posture).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrgTemplateEntity
|
||||||
|
{
|
||||||
|
public required string SubOrgId { get; init; }
|
||||||
|
public OrgTemplateDto Draft { get; set; } = null!;
|
||||||
|
public int PublishedVersion { get; set; }
|
||||||
|
public List<OrgTemplateVersionDto> History { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ponytail: one global lock, same shape as BriefStore — SQLite tolerates
|
||||||
|
/// only one writer at a time anyway.</summary>
|
||||||
|
public static class OrgTemplateStore
|
||||||
|
{
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
|
public static IReadOnlyList<SubOrgSummaryDto> List()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Seeded();
|
||||||
|
return db.OrgTemplates.AsEnumerable()
|
||||||
|
.Select(e => new SubOrgSummaryDto(e.SubOrgId, Published(e).OrgName, e.PublishedVersion))
|
||||||
|
.OrderBy(s => s.SubOrgId)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrgTemplateAdminViewDto? AdminView(string subOrgId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Seeded();
|
||||||
|
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||||
|
return e is null ? null : ToAdminView(db, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the draft. Caller validates first (OrgTemplateRules); null = unknown sub-org.
|
||||||
|
public static OrgTemplateAdminViewDto? SaveDraft(string subOrgId, OrgTemplateDto draft)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Seeded();
|
||||||
|
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||||
|
if (e is null) return null;
|
||||||
|
// Normalize identity fields the client can't be trusted with: the row key and
|
||||||
|
// the draft marker (Version 0) always win over whatever was posted.
|
||||||
|
e.Draft = draft with { SubOrgId = subOrgId, Version = 0 };
|
||||||
|
db.SaveChanges();
|
||||||
|
return ToAdminView(db, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draft → published: append a snapshot to history, advance the pointer. Returns
|
||||||
|
/// the new version + how many unsent briefs of this sub-org it re-themes.
|
||||||
|
public static PublishOrgTemplateResponse? Publish(string subOrgId, string at)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Seeded();
|
||||||
|
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||||
|
if (e is null) return null;
|
||||||
|
var version = e.PublishedVersion + 1;
|
||||||
|
// Reassign (not mutate) the list: the JSON ValueConverter has no ValueComparer,
|
||||||
|
// so EF only sees the change when the property reference changes.
|
||||||
|
e.History = new List<OrgTemplateVersionDto>(e.History)
|
||||||
|
{
|
||||||
|
new(version, at, e.Draft with { Version = version }),
|
||||||
|
};
|
||||||
|
e.PublishedVersion = version;
|
||||||
|
db.SaveChanges();
|
||||||
|
return new PublishOrgTemplateResponse(version, CountUnsent(db, subOrgId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rollback = copy an old published snapshot into the draft (admin republishes to
|
||||||
|
/// make it live). History stays append-only. Null = unknown sub-org or version.
|
||||||
|
public static OrgTemplateAdminViewDto? Rollback(string subOrgId, int version)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Seeded();
|
||||||
|
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
|
||||||
|
var old = e?.History.FirstOrDefault(h => h.Version == version);
|
||||||
|
if (e is null || old is null) return null;
|
||||||
|
e.Draft = old.Template with { Version = 0 };
|
||||||
|
db.SaveChanges();
|
||||||
|
return ToAdminView(db, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The template a brief renders with: the pinned version once sent (immutable
|
||||||
|
/// letters), else the sub-org's current published version.
|
||||||
|
public static OrgTemplateDto TemplateForBrief(string subOrgId, int? pinnedVersion)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Seeded();
|
||||||
|
// ponytail: briefs from before this WP carry an empty SubOrgId — fall back to
|
||||||
|
// the first seeded sub-org instead of failing the whole screen.
|
||||||
|
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
|
||||||
|
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
|
||||||
|
var pinned = pinnedVersion is { } v ? e.History.FirstOrDefault(h => h.Version == v)?.Template : null;
|
||||||
|
return pinned ?? Published(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int PublishedVersionOf(string subOrgId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Seeded();
|
||||||
|
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
|
||||||
|
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
|
||||||
|
return e.PublishedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test seam: drop all rows so the next call re-seeds.
|
||||||
|
public static void Reset()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
db.OrgTemplates.RemoveRange(db.OrgTemplates);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrgTemplateDto Published(OrgTemplateEntity e) =>
|
||||||
|
e.History.First(h => h.Version == e.PublishedVersion).Template;
|
||||||
|
|
||||||
|
private static OrgTemplateAdminViewDto ToAdminView(AppDbContext db, OrgTemplateEntity e) =>
|
||||||
|
new(e.Draft, e.PublishedVersion, e.History, CountUnsent(db, e.SubOrgId));
|
||||||
|
|
||||||
|
// Status is a JSON column (not translatable to SQL) — count in memory; the demo
|
||||||
|
// holds a handful of briefs at most.
|
||||||
|
private static int CountUnsent(AppDbContext db, string subOrgId) =>
|
||||||
|
db.Briefs.AsEnumerable().Count(b => b.SubOrgId == subOrgId && b.Status.Tag != "sent");
|
||||||
|
|
||||||
|
private static AppDbContext Seeded()
|
||||||
|
{
|
||||||
|
var db = Db.Create();
|
||||||
|
if (!db.OrgTemplates.Any())
|
||||||
|
{
|
||||||
|
db.OrgTemplates.AddRange(OrgTemplateSeed.All());
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two seeded sub-organizations so the admin editor can demonstrate isolation
|
||||||
|
/// (editing one never touches the other). Values come from the fictitious sample
|
||||||
|
/// artifact `voorbeeldbrief-inschrijving.pdf`; each starts with draft == published v1.
|
||||||
|
/// </summary>
|
||||||
|
public static class OrgTemplateSeed
|
||||||
|
{
|
||||||
|
public const string Registers = "cibg-registers";
|
||||||
|
public const string Vakbekwaamheid = "cibg-vakbekwaamheid";
|
||||||
|
|
||||||
|
// Deterministic seed timestamp (stable golden files, stable demos).
|
||||||
|
private const string SeededAt = "2026-07-01T00:00:00.0000000+00:00";
|
||||||
|
|
||||||
|
public static IEnumerable<OrgTemplateEntity> All() => new[]
|
||||||
|
{
|
||||||
|
Entity(new OrgTemplateDto(
|
||||||
|
Registers, "BIG-register",
|
||||||
|
"Retouradres: Postbus 00000, 2500 AA Den Haag",
|
||||||
|
LogoDocumentId: null,
|
||||||
|
"BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example",
|
||||||
|
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
|
||||||
|
"A. de Vries", "Hoofd Registratie, BIG-register", "Met vriendelijke groet,",
|
||||||
|
new MarginsDto(25, 20, 20, 25))),
|
||||||
|
Entity(new OrgTemplateDto(
|
||||||
|
Vakbekwaamheid, "CIBG Vakbekwaamheid",
|
||||||
|
"Retouradres: Postbus 11111, 2500 BB Den Haag",
|
||||||
|
LogoDocumentId: null,
|
||||||
|
"CIBG Vakbekwaamheid · Postbus 11111, 2500 BB Den Haag · 070 111 11 11 · vakbekwaamheid@voorbeeld.example",
|
||||||
|
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
|
||||||
|
"M. Bakker", "Hoofd Vakbekwaamheid, CIBG", "Met vriendelijke groet,",
|
||||||
|
new MarginsDto(25, 20, 20, 25))),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static OrgTemplateEntity Entity(OrgTemplateDto v1) => new()
|
||||||
|
{
|
||||||
|
SubOrgId = v1.SubOrgId,
|
||||||
|
Draft = v1 with { Version = 0 },
|
||||||
|
PublishedVersion = 1,
|
||||||
|
History = new() { new OrgTemplateVersionDto(1, SeededAt, v1 with { Version = 1 }) },
|
||||||
|
};
|
||||||
|
}
|
||||||
75
backend/src/BigRegister.Api/Domain/Authorization/Authz.cs
Normal file
75
backend/src/BigRegister.Api/Domain/Authorization/Authz.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
|
||||||
|
namespace BigRegister.Domain.Authorization;
|
||||||
|
|
||||||
|
public enum PrincipalRole { Drafter, Approver, Admin }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
|
||||||
|
/// from the client-asserted X-Role header (mirrors the FE's `?role=` toggle). A real
|
||||||
|
/// system builds this from verified AD/OIDC claims (PRD-0002 §3, §7); everything else
|
||||||
|
/// in this file — the capability model, the single Authz.Can check both emitting and
|
||||||
|
/// enforcing — carries over unchanged once that swap happens.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Principal(PrincipalRole Role);
|
||||||
|
|
||||||
|
public enum BriefAction { Approve, Reject, Send }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for brief authorization (PRD-0002 phase P1). The SAME
|
||||||
|
/// check both computes the decision flags shipped on the screen DTO (emit) and
|
||||||
|
/// gates the mutation endpoints (enforce) — so the two can never drift, closing the
|
||||||
|
/// classic broken-object-level-authorization gap (PRD-0002 §7).
|
||||||
|
/// </summary>
|
||||||
|
public static class Authz
|
||||||
|
{
|
||||||
|
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch
|
||||||
|
{
|
||||||
|
"approver" => PrincipalRole.Approver,
|
||||||
|
"admin" => PrincipalRole.Admin,
|
||||||
|
_ => PrincipalRole.Drafter,
|
||||||
|
});
|
||||||
|
|
||||||
|
public static string ActingId(Principal principal) => principal.Role switch
|
||||||
|
{
|
||||||
|
PrincipalRole.Approver => BriefStore.ApproverId,
|
||||||
|
PrincipalRole.Admin => BriefStore.AdminId,
|
||||||
|
_ => BriefStore.DrafterId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
|
||||||
|
/// tied to any specific brief's live status; contrast Decisions below).
|
||||||
|
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
||||||
|
{
|
||||||
|
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
||||||
|
PrincipalRole.Admin => new[] { "orgtemplate:edit" },
|
||||||
|
_ => Array.Empty<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Role + four-eyes (SoD) check only — no status. This is the exact check
|
||||||
|
/// BriefStore.Review enforces before its status guard; kept separate from
|
||||||
|
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
|
||||||
|
/// today's behavior exactly. The explicit Approver condition keeps the new Admin
|
||||||
|
/// role out of the review flow (WP-23) — SoD alone would have let it through.
|
||||||
|
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
|
||||||
|
{
|
||||||
|
BriefAction.Approve or BriefAction.Reject =>
|
||||||
|
principal.Role == PrincipalRole.Approver && ActingId(principal) != drafterId,
|
||||||
|
BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Org-template management (WP-23): admin-only, resource-independent — templates
|
||||||
|
/// have no per-resource state to weigh, so role IS the whole decision here.
|
||||||
|
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||||
|
|
||||||
|
/// Resource-aware decision for the screen DTO: "would this action succeed right
|
||||||
|
/// now" — role/SoD AND the brief's current status. This is what the UI renders;
|
||||||
|
/// it never re-derives these booleans itself.
|
||||||
|
public static BriefDecisionsDto Decisions(Principal principal, string status, string drafterId) => new(
|
||||||
|
CanEdit: principal.Role == PrincipalRole.Drafter && status is "draft" or "rejected",
|
||||||
|
CanApprove: CanActOn(BriefAction.Approve, principal, drafterId) && status == "submitted",
|
||||||
|
CanReject: CanActOn(BriefAction.Reject, principal, drafterId) && status == "submitted",
|
||||||
|
CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved");
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public static class DocumentRules
|
|||||||
{
|
{
|
||||||
private static readonly string[] Pdf = { "application/pdf" };
|
private static readonly string[] Pdf = { "application/pdf" };
|
||||||
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
||||||
|
private static readonly string[] Image = { "image/jpeg", "image/png" };
|
||||||
|
|
||||||
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
||||||
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
||||||
@@ -42,6 +43,13 @@ public static class DocumentRules
|
|||||||
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
||||||
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
||||||
},
|
},
|
||||||
|
// WP-23: the admin's org-template logo rides the same upload machinery as the
|
||||||
|
// wizard documents — one category under its own "wizard" id.
|
||||||
|
"org-template" => new[]
|
||||||
|
{
|
||||||
|
new DocumentCategory("org-logo", "Organisatielogo",
|
||||||
|
"Upload het logo van de organisatie (PNG of JPG).", false, Image, 1, false, false),
|
||||||
|
},
|
||||||
_ => Array.Empty<DocumentCategory>(),
|
_ => Array.Empty<DocumentCategory>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
191
backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
Normal file
191
backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
|
||||||
|
namespace BigRegister.Domain.Letters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-rendered letter HTML (WP-25) — the archived, "what is sent" artifact.
|
||||||
|
/// Mirrors the FE letter canvas' class vocabulary exactly (<c>public/letter.css</c>,
|
||||||
|
/// the FE⇄BE contract; LetterHtmlTests' class-parity test is the fence against drift).
|
||||||
|
///
|
||||||
|
/// Unlike the canvas, placeholders resolve to real text rather than a live editor
|
||||||
|
/// widget: an auto-resolvable key pulls from seed/case data (there is no per-brief
|
||||||
|
/// resolved value stored anywhere else in the domain — see BriefEntity's own "does
|
||||||
|
/// not interpret it" posture), an unresolved manual key renders literally as
|
||||||
|
/// "[NOG IN TE VULLEN: label]" (PRD Brief v2 §8) — the preview works despite this,
|
||||||
|
/// only send blocks on it (FE-authoritative linting).
|
||||||
|
///
|
||||||
|
/// ponytail: HTML today, a headless-Chromium PDF render slots in behind this same
|
||||||
|
/// route if the POC ever needs real PDF bytes — see the preview endpoints.
|
||||||
|
/// </summary>
|
||||||
|
public static class LetterHtml
|
||||||
|
{
|
||||||
|
private static readonly string Css = File.ReadAllText(FindLetterCss());
|
||||||
|
|
||||||
|
public static string Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)
|
||||||
|
{
|
||||||
|
var defs = brief.Placeholders.ToDictionary(p => p.Key);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.Append("<!doctype html><html lang=\"nl\"><head><meta charset=\"utf-8\">");
|
||||||
|
sb.Append("<title>").Append(Enc(brief.BriefId)).Append("</title>");
|
||||||
|
sb.Append("<style>").Append(Css).Append(ExtraCss).Append("</style>");
|
||||||
|
sb.Append("</head><body>");
|
||||||
|
sb.Append("<div class=\"letter\" style=\"").Append(MarginStyle(template.Margins)).Append("\">");
|
||||||
|
|
||||||
|
// --- letterhead ---
|
||||||
|
sb.Append("<div class=\"letter__letterhead\">");
|
||||||
|
if (template.LogoDocumentId is { } logoId && DocumentStore.Get(logoId) is { } logo)
|
||||||
|
{
|
||||||
|
sb.Append("<img class=\"org-logo\" alt=\"\" src=\"data:").Append(logo.ContentType)
|
||||||
|
.Append(";base64,").Append(Convert.ToBase64String(logo.Content)).Append("\">");
|
||||||
|
}
|
||||||
|
sb.Append("<p class=\"org-wordmark\">").Append(Enc(template.OrgName)).Append("</p>");
|
||||||
|
sb.Append("<address class=\"return-address\">").Append(EncLines(template.ReturnAddress)).Append("</address>");
|
||||||
|
sb.Append("<address class=\"address-window\">").Append(EncLines(RecipientPlaceholder)).Append("</address>");
|
||||||
|
sb.Append("<dl class=\"reference\">");
|
||||||
|
sb.Append("<div><dt>Ons kenmerk</dt><dd>").Append(Enc(brief.BriefId)).Append("</dd></div>");
|
||||||
|
sb.Append("<div><dt>Datum</dt><dd>").Append(Enc(FormatDatumNl(at))).Append("</dd></div>");
|
||||||
|
sb.Append("</dl></div>");
|
||||||
|
|
||||||
|
// --- body: the case-type template's sections ---
|
||||||
|
sb.Append("<div class=\"letter__body\">");
|
||||||
|
foreach (var section in brief.Sections)
|
||||||
|
{
|
||||||
|
sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>");
|
||||||
|
foreach (var block in section.Blocks)
|
||||||
|
RenderParagraphs(sb, block.Content.Paragraphs, defs);
|
||||||
|
sb.Append("</section>");
|
||||||
|
}
|
||||||
|
sb.Append("</div>");
|
||||||
|
|
||||||
|
// --- signature ---
|
||||||
|
sb.Append("<div class=\"letter__signature\">");
|
||||||
|
sb.Append("<p>").Append(Enc(template.SignatureClosing)).Append("</p>");
|
||||||
|
sb.Append("<p class=\"signature-name\">").Append(Enc(template.SignatureName)).Append("</p>");
|
||||||
|
sb.Append("<p>").Append(Enc(template.SignatureRole)).Append("</p>");
|
||||||
|
sb.Append("</div>");
|
||||||
|
|
||||||
|
// --- footer ---
|
||||||
|
sb.Append("<div class=\"letter__footer\">");
|
||||||
|
sb.Append("<div class=\"footer-contact\">").Append(EncLines(template.FooterContact)).Append("</div>");
|
||||||
|
sb.Append("<div class=\"footer-legal\">").Append(Enc(template.FooterLegal)).Append("</div>");
|
||||||
|
sb.Append("</div>");
|
||||||
|
|
||||||
|
if (watermark) sb.Append("<div class=\"preview-watermark\" aria-hidden=\"true\">VOORBEELD</div>");
|
||||||
|
|
||||||
|
sb.Append("</div></body></html>");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exposed so LetterHtmlTests can assert every `letter`-prefixed class this
|
||||||
|
/// renderer emits also exists in the shared contract file — the fence against drift.
|
||||||
|
public static string StyleSheet => Css;
|
||||||
|
|
||||||
|
// No recipient address is tracked anywhere in this POC's brief domain (BRP lookup
|
||||||
|
// is out of scope here) — the canvas shows the same static placeholder text.
|
||||||
|
private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)";
|
||||||
|
|
||||||
|
private static void RenderParagraphs(
|
||||||
|
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
|
||||||
|
{
|
||||||
|
string? openList = null;
|
||||||
|
foreach (var para in paragraphs)
|
||||||
|
{
|
||||||
|
if (para.List != openList)
|
||||||
|
{
|
||||||
|
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
|
||||||
|
if (para.List is not null) sb.Append(para.List == "bullet" ? "<ul>" : "<ol>");
|
||||||
|
openList = para.List;
|
||||||
|
}
|
||||||
|
sb.Append(openList is null ? "<p>" : "<li>");
|
||||||
|
foreach (var node in para.Nodes) RenderNode(sb, node, defs);
|
||||||
|
sb.Append(openList is null ? "</p>" : "</li>");
|
||||||
|
}
|
||||||
|
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderNode(
|
||||||
|
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
|
||||||
|
{
|
||||||
|
switch (node.Type)
|
||||||
|
{
|
||||||
|
case "text":
|
||||||
|
sb.Append(Enc(node.Text ?? ""));
|
||||||
|
break;
|
||||||
|
case "lineBreak":
|
||||||
|
sb.Append("<br>");
|
||||||
|
break;
|
||||||
|
case "placeholder":
|
||||||
|
var key = node.Key ?? "";
|
||||||
|
var def = defs.GetValueOrDefault(key);
|
||||||
|
var label = def?.Label ?? key;
|
||||||
|
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The only place a placeholder key gets a real value: seed/case data for the
|
||||||
|
// single demo applicant (SeedData.Registration — no per-brief resolved value is
|
||||||
|
// ever stored, see the class doc above). Falls back to the label itself for any
|
||||||
|
// other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback.
|
||||||
|
private static string ResolveAuto(string key, string label) => key switch
|
||||||
|
{
|
||||||
|
"naam_zorgverlener" => SeedData.Registration.Naam,
|
||||||
|
"big_nummer" => SeedData.Registration.BigNummer,
|
||||||
|
"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")),
|
||||||
|
_ => label,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly CultureInfo Nl = CultureInfo.GetCultureInfo("nl-NL");
|
||||||
|
private static string FormatDatumNl(string at) => DateTimeOffset.Parse(at).ToString("d MMMM yyyy", Nl);
|
||||||
|
|
||||||
|
private static string MarginStyle(MarginsDto m) =>
|
||||||
|
$"--letter-margin-top:{m.TopMm}mm;--letter-margin-right:{m.RightMm}mm;" +
|
||||||
|
$"--letter-margin-bottom:{m.BottomMm}mm;--letter-margin-left:{m.LeftMm}mm;";
|
||||||
|
|
||||||
|
private static string Enc(string s) => System.Net.WebUtility.HtmlEncode(s);
|
||||||
|
private static string EncLines(string s) => Enc(s).Replace("\n", "<br>");
|
||||||
|
|
||||||
|
// Walks up from the running assembly's own directory (NOT the process cwd, which
|
||||||
|
// varies by how `dotnet run`/docker/tests invoke it — see docs/backlog/WP-25) until
|
||||||
|
// it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
|
||||||
|
// api container's `/src` for exactly this walk to resolve there too.
|
||||||
|
private static string FindLetterCss()
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "public", "letter.css");
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"public/letter.css not found by walking up from {AppContext.BaseDirectory} " +
|
||||||
|
"— check the docker bind mount or build output location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend-only concerns absent from the FE canvas (no live preview toggle for
|
||||||
|
// either): kept out of the shared contract file, not "letter"-prefixed so the
|
||||||
|
// class-parity test's scope doesn't need to widen for them.
|
||||||
|
private const string ExtraCss = """
|
||||||
|
.preview-watermark {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 72pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgb(200 30 30 / 0.18);
|
||||||
|
transform: rotate(-30deg);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.org-logo {
|
||||||
|
display: block;
|
||||||
|
max-height: 18mm;
|
||||||
|
margin-block-end: 4mm;
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
|
||||||
|
namespace BigRegister.Domain.Letters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SERVER-OWNED org-template validation (Brief v2 PRD §5): bounded margins so an
|
||||||
|
/// admin cannot break the letter geometry, and the identity fields a letter cannot
|
||||||
|
/// render without. The FE mirrors the bounds for instant feedback (config values on
|
||||||
|
/// the wire if ever needed); this is the authority.
|
||||||
|
/// </summary>
|
||||||
|
public static class OrgTemplateRules
|
||||||
|
{
|
||||||
|
public const int MarginMinMm = 10;
|
||||||
|
public const int MarginMaxMm = 50;
|
||||||
|
|
||||||
|
/// Returns a rejection reason, or null when the draft is acceptable.
|
||||||
|
public static string? RejectDraft(OrgTemplateDto draft)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(draft.OrgName)) return "Organisatienaam is verplicht.";
|
||||||
|
if (string.IsNullOrWhiteSpace(draft.SignatureName)) return "Naam van de ondertekenaar is verplicht.";
|
||||||
|
var m = draft.Margins;
|
||||||
|
var outOfBounds = new[] { m.TopMm, m.RightMm, m.BottomMm, m.LeftMm }
|
||||||
|
.Any(v => v is < MarginMinMm or > MarginMaxMm);
|
||||||
|
return outOfBounds
|
||||||
|
? $"Marges moeten tussen {MarginMinMm} en {MarginMaxMm} mm liggen."
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Authorization;
|
||||||
using BigRegister.Domain.Diplomas;
|
using BigRegister.Domain.Diplomas;
|
||||||
using BigRegister.Domain.Documents;
|
using BigRegister.Domain.Documents;
|
||||||
using BigRegister.Domain.Intake;
|
using BigRegister.Domain.Intake;
|
||||||
|
using BigRegister.Domain.Letters;
|
||||||
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);
|
||||||
|
|
||||||
@@ -19,13 +23,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);
|
||||||
@@ -239,97 +276,187 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
|||||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
|
||||||
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the
|
// tied to a specific brief's live status — see BriefDecisionsDto for that).
|
||||||
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. ---
|
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
|
||||||
|
.Produces<MeDto>();
|
||||||
|
|
||||||
api.MapGet("/brief", () =>
|
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
||||||
|
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
|
||||||
|
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
|
||||||
|
// toggle) — no real identities in this POC. ---
|
||||||
|
|
||||||
|
api.MapGet("/brief", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
return ToView(ctx, e);
|
||||||
})
|
})
|
||||||
.Produces<BriefViewDto>();
|
.Produces<BriefViewDto>();
|
||||||
|
|
||||||
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (_, isDrafter) = BriefRole(ctx);
|
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||||
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
return BriefResult(ctx, BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (_, isDrafter) = BriefRole(ctx);
|
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||||
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
||||||
LogBrief("submit", r);
|
LogBrief("submit", r);
|
||||||
return BriefResult(r, "Alleen de opsteller mag indienen.");
|
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
|
||||||
})
|
})
|
||||||
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (acting, _) = BriefRole(ctx);
|
var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
|
||||||
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
|
|
||||||
LogBrief("approve", r);
|
LogBrief("approve", r);
|
||||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
var (acting, _) = BriefRole(ctx);
|
var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now());
|
||||||
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
|
|
||||||
LogBrief("reject", r);
|
LogBrief("reject", r);
|
||||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
api.MapPost("/brief/send", () =>
|
api.MapPost("/brief/send", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
||||||
// port); the backend only guards the approved→sent transition.
|
// port); the backend only guards the approved→sent transition (not role-gated
|
||||||
|
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
|
||||||
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
||||||
LogBrief("send", r);
|
LogBrief("send", r);
|
||||||
return BriefResult(r, "Versturen kan niet in deze status.");
|
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
|
||||||
})
|
})
|
||||||
.Produces<BriefDto>()
|
.Produces<BriefViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
api.MapPost("/brief/reset", () =>
|
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
|
||||||
|
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
|
||||||
|
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
|
||||||
|
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||||
|
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||||
|
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
|
||||||
|
return Results.Content(archived, "text/html");
|
||||||
|
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
|
||||||
|
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
|
||||||
|
})
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
|
// Proefbrief: the admin's unpublished draft template rendered over a fixture
|
||||||
|
// brief, so the appearance can be checked before publishing touches real letters.
|
||||||
|
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
|
{
|
||||||
|
var view = OrgTemplateStore.AdminView(subOrgId);
|
||||||
|
if (view is null) return Results.NotFound();
|
||||||
|
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||||
|
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||||
|
}))
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
|
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||||
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
||||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
return ToView(ctx, e);
|
||||||
})
|
})
|
||||||
.WithName("briefReset")
|
.WithName("briefReset")
|
||||||
.Produces<BriefViewDto>();
|
.Produces<BriefViewDto>();
|
||||||
|
|
||||||
|
// --- Organization templates (WP-23): the second template axis — appearance and
|
||||||
|
// identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam
|
||||||
|
// as drafter/approver); the same Authz check gates every endpoint and feeds the
|
||||||
|
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
|
||||||
|
|
||||||
|
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
|
Results.Ok(OrgTemplateStore.List())))
|
||||||
|
.WithName("orgTemplates")
|
||||||
|
.Produces<List<SubOrgSummaryDto>>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
|
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||||
|
.WithName("orgTemplateGET")
|
||||||
|
.Produces<OrgTemplateAdminViewDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
|
{
|
||||||
|
var reject = OrgTemplateRules.RejectDraft(req.Draft);
|
||||||
|
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
|
||||||
|
}))
|
||||||
|
.WithName("orgTemplatePUT")
|
||||||
|
.Produces<OrgTemplateAdminViewDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
|
{
|
||||||
|
var r = OrgTemplateStore.Publish(subOrgId, Now());
|
||||||
|
if (r is not null)
|
||||||
|
app.Logger.LogInformation("orgtemplate publish subOrg={SubOrg} version={Version} affected={Affected}",
|
||||||
|
subOrgId, r.Version, r.AffectedUnsentBriefs);
|
||||||
|
return r is not null ? Results.Ok(r) : Results.NotFound();
|
||||||
|
}))
|
||||||
|
.WithName("orgTemplatePublish")
|
||||||
|
.Produces<PublishOrgTemplateResponse>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
|
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||||
|
.WithName("orgTemplateRollback")
|
||||||
|
.Produces<OrgTemplateAdminViewDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||||
|
|
||||||
|
// One gate for every org-template endpoint — the enforce twin of the
|
||||||
|
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
|
||||||
|
static IResult OrgAdmin(HttpContext ctx, Func<IResult> action) =>
|
||||||
|
Authz.CanManageOrgTemplates(Authz.ResolvePrincipal(ctx))
|
||||||
|
? action()
|
||||||
|
: Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||||
|
|
||||||
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
|
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||||
// else (default) acts as the drafter.
|
e.ToDto(),
|
||||||
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
|
BriefSeed.PassagesFor(e.Beroep),
|
||||||
{
|
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
|
||||||
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
|
// Sent letters render with the version pinned at send; everything else follows
|
||||||
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
|
// the sub-org's current published template (WP-23 immutability invariant).
|
||||||
}
|
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null));
|
||||||
|
|
||||||
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
|
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
||||||
|
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
||||||
|
IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
|
||||||
{
|
{
|
||||||
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
|
BriefStore.Outcome.Ok => Results.Ok(ToView(ctx, r.entity!)),
|
||||||
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
|
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
|
||||||
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
|
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
|
||||||
};
|
};
|
||||||
@@ -340,33 +467,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.
|
||||||
|
|||||||
@@ -641,6 +641,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/me": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MeDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/brief": {
|
"/api/v1/brief": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -679,7 +698,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/BriefDto"
|
"$ref": "#/components/schemas/BriefViewDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,7 +738,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/BriefDto"
|
"$ref": "#/components/schemas/BriefViewDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,7 +777,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/BriefDto"
|
"$ref": "#/components/schemas/BriefViewDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,7 +826,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/BriefDto"
|
"$ref": "#/components/schemas/BriefViewDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -846,7 +865,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/BriefDto"
|
"$ref": "#/components/schemas/BriefViewDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,6 +902,238 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/org-templates": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"operationId": "orgTemplates",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SubOrgSummaryDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/org-template/{subOrgId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"operationId": "orgTemplateGET",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subOrgId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"operationId": "orgTemplatePUT",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subOrgId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SaveOrgTemplateRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/org-template/{subOrgId}/publish": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"operationId": "orgTemplatePublish",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subOrgId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PublishOrgTemplateResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/org-template/{subOrgId}/rollback/{version}": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"operationId": "orgTemplateRollback",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subOrgId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "version",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -1030,6 +1281,24 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"BriefDecisionsDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"canEdit": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"canApprove": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"canReject": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"canSend": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"BriefDto": {
|
"BriefDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1123,6 +1392,12 @@
|
|||||||
"$ref": "#/components/schemas/LibraryPassageDto"
|
"$ref": "#/components/schemas/LibraryPassageDto"
|
||||||
},
|
},
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"$ref": "#/components/schemas/BriefDecisionsDto"
|
||||||
|
},
|
||||||
|
"orgTemplate": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1469,6 +1744,131 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"MarginsDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"topMm": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"rightMm": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"bottomMm": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"leftMm": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"MeDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"OrgTemplateAdminViewDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"draft": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||||
|
},
|
||||||
|
"publishedVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateVersionDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"unsentBriefs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"OrgTemplateDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"subOrgId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"orgName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"returnAddress": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"logoDocumentId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"footerContact": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"footerLegal": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"signatureName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"signatureRole": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"signatureClosing": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"margins": {
|
||||||
|
"$ref": "#/components/schemas/MarginsDto"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"OrgTemplateVersionDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"publishedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"ParagraphDto": {
|
"ParagraphDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1573,6 +1973,20 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": { }
|
"additionalProperties": { }
|
||||||
},
|
},
|
||||||
|
"PublishOrgTemplateResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"affectedUnsentBriefs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"ReferentieResponse": {
|
"ReferentieResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1716,6 +2130,33 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"SaveOrgTemplateRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"draft": {
|
||||||
|
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"SubOrgSummaryDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"subOrgId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"orgName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"publishedVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"SubmitApplicationRequest": {
|
"SubmitApplicationRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
68
backend/tests/BigRegister.Tests/AuthzTests.cs
Normal file
68
backend/tests/BigRegister.Tests/AuthzTests.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using BigRegister.Domain.Authorization;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the single authorization helper (PRD-0002 phase P1) that both
|
||||||
|
/// computes the brief screen's decision flags and gates the mutation endpoints.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthzTests
|
||||||
|
{
|
||||||
|
private static readonly Principal Drafter = new(PrincipalRole.Drafter);
|
||||||
|
private static readonly Principal Approver = new(PrincipalRole.Approver);
|
||||||
|
private const string DrafterId = "demo-drafter";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drafter_may_not_approve_or_reject_even_when_submitted()
|
||||||
|
{
|
||||||
|
Assert.False(Authz.CanActOn(BriefAction.Approve, Drafter, DrafterId));
|
||||||
|
Assert.False(Authz.CanActOn(BriefAction.Reject, Drafter, DrafterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Approver_may_approve_and_reject_a_different_drafters_letter()
|
||||||
|
{
|
||||||
|
Assert.True(Authz.CanActOn(BriefAction.Approve, Approver, DrafterId));
|
||||||
|
Assert.True(Authz.CanActOn(BriefAction.Reject, Approver, DrafterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Send_is_not_role_gated()
|
||||||
|
{
|
||||||
|
Assert.True(Authz.CanActOn(BriefAction.Send, Drafter, DrafterId));
|
||||||
|
Assert.True(Authz.CanActOn(BriefAction.Send, Approver, DrafterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("draft")]
|
||||||
|
[InlineData("rejected")]
|
||||||
|
public void Decisions_CanEdit_true_for_drafter_in_editable_statuses(string status)
|
||||||
|
{
|
||||||
|
Assert.True(Authz.Decisions(Drafter, status, DrafterId).CanEdit);
|
||||||
|
Assert.False(Authz.Decisions(Approver, status, DrafterId).CanEdit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decisions_CanApprove_requires_submitted_status_and_approver_role()
|
||||||
|
{
|
||||||
|
Assert.True(Authz.Decisions(Approver, "submitted", DrafterId).CanApprove);
|
||||||
|
Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanApprove);
|
||||||
|
Assert.False(Authz.Decisions(Drafter, "submitted", DrafterId).CanApprove);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decisions_CanSend_requires_approved_status_only()
|
||||||
|
{
|
||||||
|
Assert.True(Authz.Decisions(Drafter, "approved", DrafterId).CanSend);
|
||||||
|
Assert.True(Authz.Decisions(Approver, "approved", DrafterId).CanSend);
|
||||||
|
Assert.False(Authz.Decisions(Approver, "submitted", DrafterId).CanSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoleCapabilities_are_empty_for_drafter_and_the_three_brief_capabilities_for_approver()
|
||||||
|
{
|
||||||
|
Assert.Empty(Authz.RoleCapabilities(Drafter));
|
||||||
|
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, Authz.RoleCapabilities(Approver));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,4 +23,10 @@
|
|||||||
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
|
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="LetterHtml.golden.html">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
|||||||
|
|
||||||
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
|
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
Assert.Equal("submitted", submitted!.Status.Tag);
|
Assert.Equal("submitted", submitted!.Brief.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -106,7 +106,7 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
|||||||
|
|
||||||
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -117,13 +117,13 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
|||||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
var rejected = await (await _client.SendAsync(
|
var rejected = await (await _client.SendAsync(
|
||||||
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
|
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
Assert.Equal("rejected", rejected!.Status.Tag);
|
Assert.Equal("rejected", rejected!.Brief.Status.Tag);
|
||||||
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
|
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
|
||||||
|
|
||||||
// A drafter save on a rejected letter reopens it to draft.
|
// A drafter save on a rejected letter reopens it to draft.
|
||||||
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
|
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
Assert.Equal("draft", reopened!.Status.Tag);
|
Assert.Equal("draft", reopened!.Brief.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -139,7 +139,36 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
|||||||
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||||
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
|
||||||
|
{
|
||||||
|
var brief = await Get();
|
||||||
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||||
|
Assert.True(view!.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
|
||||||
|
Assert.False(view.Decisions.CanApprove);
|
||||||
|
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
|
var asApprover = await _client.SendAsync(
|
||||||
|
new HttpRequestMessage(HttpMethod.Get, "/api/v1/brief") { Headers = { { "X-Role", "approver" } } });
|
||||||
|
var approverView = await asApprover.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
|
Assert.True(approverView!.Decisions.CanApprove);
|
||||||
|
Assert.False(approverView.Decisions.CanEdit); // approver never edits
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Me_returns_no_capabilities_for_drafter_and_the_brief_set_for_approver()
|
||||||
|
{
|
||||||
|
var asDrafter = await _client.GetFromJsonAsync<MeDto>("/api/v1/me");
|
||||||
|
Assert.Empty(asDrafter!.Capabilities);
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/v1/me") { Headers = { { "X-Role", "approver" } } });
|
||||||
|
var asApprover = await res.Content.ReadFromJsonAsync<MeDto>();
|
||||||
|
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover!.Capabilities);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
215
backend/tests/BigRegister.Tests/LetterHtml.golden.html
Normal file
215
backend/tests/BigRegister.Tests/LetterHtml.golden.html
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<!doctype html><html lang="nl"><head><meta charset="utf-8"><title>golden-brief-1</title><style>/* letter.css — the FE⇄BE letter-rendering CONTRACT (WP-24/WP-25).
|
||||||
|
*
|
||||||
|
* One stylesheet, two consumers: the FE letter canvas loads it via <link>
|
||||||
|
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25)
|
||||||
|
* inlines this same file. Its class-parity test is the fence against drift.
|
||||||
|
*
|
||||||
|
* Class vocabulary: .letter, .letter__letterhead, .letter__body,
|
||||||
|
* .letter__signature, .letter__footer, .letter__page-break.
|
||||||
|
* Margins arrive as --letter-margin-* custom props (mm, from the OrgTemplate);
|
||||||
|
* the defaults below match the seed template.
|
||||||
|
*
|
||||||
|
* Deliberately self-contained: no --rhc- or --bs- tokens — the backend renderer
|
||||||
|
* has no token bridge. Geometry follows the sample voorbeeldbrief-inschrijving
|
||||||
|
* (A4, return address above the envelope window, reference block, footer rule).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
--letter-margin-top: 25mm;
|
||||||
|
--letter-margin-right: 25mm;
|
||||||
|
--letter-margin-bottom: 25mm;
|
||||||
|
--letter-margin-left: 25mm;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 210mm; /* A4 */
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 297mm;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: var(--letter-margin-top) var(--letter-margin-right) var(--letter-margin-bottom)
|
||||||
|
var(--letter-margin-left);
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
/* Letter typography is the letter's, not the portal UI's. Licensed RO/Rijks
|
||||||
|
fonts are not shipped; system stack mirrors the app-wide decision (ADR-0003). */
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter p {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter ul,
|
||||||
|
.letter ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-inline-start: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Letterhead: wordmark, return address above the envelope window, reference block --- */
|
||||||
|
|
||||||
|
.letter__letterhead {
|
||||||
|
margin-block-end: 12mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .org-wordmark {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .return-address {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: #555;
|
||||||
|
margin-block-end: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Envelope-window position (~C5 venstercouvert): recipient block. */
|
||||||
|
.letter__letterhead .address-window {
|
||||||
|
min-height: 22mm;
|
||||||
|
font-style: normal;
|
||||||
|
white-space: pre-line;
|
||||||
|
margin-block-end: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference {
|
||||||
|
display: flex;
|
||||||
|
gap: 10mm;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dt {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Body: the case-type template's sections --- */
|
||||||
|
|
||||||
|
.letter__body {
|
||||||
|
flex: 1;
|
||||||
|
/* Above the absolutely-positioned page-break marks: the dashed line stays visible
|
||||||
|
in the gaps but never draws THROUGH opaque content (editors, pickers). */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body h3 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body section {
|
||||||
|
margin-block-end: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Signature --- */
|
||||||
|
|
||||||
|
.letter__signature {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature .signature-name {
|
||||||
|
margin-block-start: 3em; /* room for the (not-shipped) handwritten signature */
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Footer: contact + legal, above a rule --- */
|
||||||
|
|
||||||
|
.letter__footer {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
padding-block-start: 3mm;
|
||||||
|
border-block-start: 0.5pt solid #999;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-contact {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-legal {
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Approximate page-break indicator (screen only; PRD §2b honesty rule) ---
|
||||||
|
Positioned per A4-interval by the canvas; the print preview is authoritative. */
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
border-block-start: 1px dashed #b36200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break > span {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 2mm;
|
||||||
|
inset-block-start: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
/* Above .letter__body: the honesty caption stays legible even over content. */
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #b36200;
|
||||||
|
background: #fff;
|
||||||
|
padding-inline: 1mm;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Print: real pages, no indicator chrome --- */
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.letter {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preview-watermark {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 72pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgb(200 30 30 / 0.18);
|
||||||
|
transform: rotate(-30deg);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.org-logo {
|
||||||
|
display: block;
|
||||||
|
max-height: 18mm;
|
||||||
|
margin-block-end: 4mm;
|
||||||
|
}</style></head><body><div class="letter" style="--letter-margin-top:25mm;--letter-margin-right:20mm;--letter-margin-bottom:20mm;--letter-margin-left:25mm;"><div class="letter__letterhead"><p class="org-wordmark">BIG-register</p><address class="return-address">Retouradres: Postbus 00000, 2500 AA Den Haag</address><address class="address-window">Adres van de geadresseerde<br>(wordt ingevuld bij verzending)</address><dl class="reference"><div><dt>Ons kenmerk</dt><dd>golden-brief-1</dd></div><div><dt>Datum</dt><dd>5 juli 2026</dd></div></dl></div><div class="letter__body"><section><h3>Aanhef</h3><p>Geachte heer/mevrouw Dr. A. (Anna) de Vries,</p></section><section><h3>Kern van het besluit</h3><p>Op </p><ul><li>Eerste punt: [NOG IN TE VULLEN: Reden besluit]</li><li>Tweede punt</li></ul></section><section><h3>Slot</h3><p>Met vriendelijke groet,</p></section></div><div class="letter__signature"><p>Met vriendelijke groet,</p><p class="signature-name">A. de Vries</p><p>Hoofd Registratie, BIG-register</p></div><div class="letter__footer"><div class="footer-contact">BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example</div><div class="footer-legal">Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.</div></div><div class="preview-watermark" aria-hidden="true">VOORBEELD</div></div></body></html>
|
||||||
102
backend/tests/BigRegister.Tests/LetterHtmlTests.cs
Normal file
102
backend/tests/BigRegister.Tests/LetterHtmlTests.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Letters;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP-25's fence against drift between the backend renderer and the FE letter
|
||||||
|
/// canvas: a golden-file snapshot of a fixed brief + template, and a class-parity
|
||||||
|
/// check that every `letter`-prefixed class the renderer emits exists in the
|
||||||
|
/// shared `public/letter.css` contract. Neither test launches a browser.
|
||||||
|
/// </summary>
|
||||||
|
public class LetterHtmlTests
|
||||||
|
{
|
||||||
|
private static BriefEntity FixtureBrief() => new()
|
||||||
|
{
|
||||||
|
BriefId = "golden-brief-1",
|
||||||
|
Owner = "golden",
|
||||||
|
Beroep = "arts",
|
||||||
|
TemplateId = "besluit-arts",
|
||||||
|
DrafterId = BriefStore.DrafterId,
|
||||||
|
Placeholders = new[]
|
||||||
|
{
|
||||||
|
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
|
||||||
|
new PlaceholderDefDto("datum", "Datum", true),
|
||||||
|
new PlaceholderDefDto("reden_besluit", "Reden besluit", false),
|
||||||
|
},
|
||||||
|
Sections = new()
|
||||||
|
{
|
||||||
|
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "aanhef-1", new RichTextBlockDto(new[]
|
||||||
|
{
|
||||||
|
new ParagraphDto(new[]
|
||||||
|
{
|
||||||
|
new RichTextNodeDto("text", Text: "Geachte heer/mevrouw "),
|
||||||
|
new RichTextNodeDto("placeholder", Key: "naam_zorgverlener"),
|
||||||
|
new RichTextNodeDto("text", Text: ","),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}, Locked: true),
|
||||||
|
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "kern-1", new RichTextBlockDto(new[]
|
||||||
|
{
|
||||||
|
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Op ") }),
|
||||||
|
new ParagraphDto(new RichTextNodeDto[]
|
||||||
|
{
|
||||||
|
new("text", Text: "Eerste punt: "),
|
||||||
|
new("placeholder", Key: "reden_besluit"),
|
||||||
|
}, List: "bullet"),
|
||||||
|
new ParagraphDto(new RichTextNodeDto[]
|
||||||
|
{
|
||||||
|
new("text", Text: "Tweede punt"),
|
||||||
|
}, List: "bullet"),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
new("slot", "Slot", false, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "slot-1", new RichTextBlockDto(new[]
|
||||||
|
{
|
||||||
|
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Met vriendelijke groet,") }),
|
||||||
|
})),
|
||||||
|
}, Locked: true),
|
||||||
|
},
|
||||||
|
Status = new BriefStatusDto("draft"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly OrgTemplateDto Template = new(
|
||||||
|
"cibg-registers", "BIG-register",
|
||||||
|
"Retouradres: Postbus 00000, 2500 AA Den Haag",
|
||||||
|
LogoDocumentId: null,
|
||||||
|
"BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example",
|
||||||
|
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
|
||||||
|
"A. de Vries", "Hoofd Registratie, BIG-register", "Met vriendelijke groet,",
|
||||||
|
new MarginsDto(25, 20, 20, 25), Version: 1);
|
||||||
|
|
||||||
|
private const string At = "2026-07-05T12:00:00.0000000+00:00";
|
||||||
|
|
||||||
|
private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_matches_the_golden_file()
|
||||||
|
{
|
||||||
|
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
|
||||||
|
var golden = File.ReadAllText(GoldenPath);
|
||||||
|
Assert.Equal(golden, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_letter_prefixed_class_exists_in_letter_css()
|
||||||
|
{
|
||||||
|
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
|
||||||
|
var classes = Regex.Matches(html, "class=\"([^\"]+)\"")
|
||||||
|
.SelectMany(m => m.Groups[1].Value.Split(' '))
|
||||||
|
.Where(c => c.StartsWith("letter"))
|
||||||
|
.Distinct();
|
||||||
|
Assert.NotEmpty(classes);
|
||||||
|
Assert.All(classes, c => Assert.Contains($".{c}", LetterHtml.StyleSheet));
|
||||||
|
}
|
||||||
|
}
|
||||||
180
backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
Normal file
180
backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Org templates (WP-23): admin-only endpoints, draft→publish versioning, and the
|
||||||
|
/// sent-brief immutability invariant (pin at send, republish touches unsent only).
|
||||||
|
/// Same reset discipline as BriefEndpointTests — the stores are process-global.
|
||||||
|
/// </summary>
|
||||||
|
public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
|
{
|
||||||
|
private const string Registers = OrgTemplateSeed.Registers;
|
||||||
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null, object? body = null)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(method, path);
|
||||||
|
if (role is not null) req.Headers.Add("X-Role", role);
|
||||||
|
if (body is not null) req.Content = JsonContent.Create(body);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrgTemplateAdminViewDto> AdminView(string subOrgId = Registers)
|
||||||
|
{
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{subOrgId}", role: "admin"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
return (await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResetStores()
|
||||||
|
{
|
||||||
|
OrgTemplateStore.Reset();
|
||||||
|
BriefStore.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_endpoints_are_admin_only()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
// drafter (no header) and approver both bounce off every admin endpoint.
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden,
|
||||||
|
(await _client.GetAsync("/api/v1/admin/org-templates")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden,
|
||||||
|
(await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "approver"))).StatusCode);
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/admin/org-templates", role: "admin"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var list = await res.Content.ReadFromJsonAsync<List<SubOrgSummaryDto>>();
|
||||||
|
Assert.Equal(new[] { Registers, OrgTemplateSeed.Vakbekwaamheid }, list!.Select(s => s.SubOrgId));
|
||||||
|
Assert.All(list!, s => Assert.Equal(1, s.PublishedVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_increments_version_appends_history_and_counts_unsent_briefs()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
// One unsent brief for this sub-org (GetOrCreate on first read).
|
||||||
|
await _client.GetAsync("/api/v1/brief");
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
|
||||||
|
Assert.Equal(2, published!.Version);
|
||||||
|
Assert.Equal(1, published.AffectedUnsentBriefs);
|
||||||
|
|
||||||
|
var view = await AdminView();
|
||||||
|
Assert.Equal(2, view.PublishedVersion);
|
||||||
|
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version));
|
||||||
|
Assert.Equal(1, view.UnsentBriefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_draft_validates_margins_and_round_trips()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
var draft = (await AdminView()).Draft;
|
||||||
|
|
||||||
|
var invalid = draft with { Margins = new MarginsDto(5, 20, 20, 25) };
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, (await _client.SendAsync(
|
||||||
|
Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||||
|
body: new SaveOrgTemplateRequest(invalid)))).StatusCode);
|
||||||
|
|
||||||
|
var valid = draft with { OrgName = "BIG-register (nieuw)", Margins = new MarginsDto(30, 20, 20, 25) };
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||||
|
body: new SaveOrgTemplateRequest(valid)));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
||||||
|
Assert.Equal("BIG-register (nieuw)", view!.Draft.OrgName);
|
||||||
|
Assert.Equal(30, view.Draft.Margins.TopMm);
|
||||||
|
// Saving a draft publishes nothing.
|
||||||
|
Assert.Equal(1, view.PublishedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rollback_copies_an_old_version_into_the_draft_without_rewriting_history()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
var draft = (await AdminView()).Draft;
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||||
|
body: new SaveOrgTemplateRequest(draft with { OrgName = "Versie twee" })));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/1", role: "admin"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
||||||
|
Assert.Equal("BIG-register", view!.Draft.OrgName); // v1 content back in the draft
|
||||||
|
Assert.Equal(2, view.PublishedVersion); // still live: v2 (rollback ≠ publish)
|
||||||
|
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version)); // append-only, untouched
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.SendAsync(
|
||||||
|
Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/99", role: "admin"))).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sent_brief_keeps_its_pinned_template_while_an_unsent_brief_follows_a_republish()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
// Walk one brief to sent under template v1.
|
||||||
|
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||||
|
var filled = brief.Sections
|
||||||
|
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||||
|
s.Required && s.Blocks.Count == 0
|
||||||
|
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
||||||
|
: s.Blocks))
|
||||||
|
.ToList();
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
|
||||||
|
var sent = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
|
Assert.Equal(1, sent!.OrgTemplate.Version);
|
||||||
|
|
||||||
|
// Republish with a new org name.
|
||||||
|
var draft = (await AdminView()).Draft;
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||||
|
body: new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||||
|
|
||||||
|
// The sent brief still renders v1 with the old name (immutable)...
|
||||||
|
var sentView = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||||
|
Assert.Equal(1, sentView!.OrgTemplate.Version);
|
||||||
|
Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName);
|
||||||
|
|
||||||
|
// ...while a fresh (unsent) brief follows the new published version.
|
||||||
|
var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
|
Assert.Equal(2, freshView!.OrgTemplate.Version);
|
||||||
|
Assert.Equal("Hertitelde organisatie", freshView.OrgTemplate.OrgName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Me_returns_the_orgtemplate_capability_for_admin()
|
||||||
|
{
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
||||||
|
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
||||||
|
Assert.Equal(new[] { "orgtemplate:edit" }, me!.Capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_cannot_slip_into_the_brief_review_flow()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||||
|
var filled = brief.Sections
|
||||||
|
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||||
|
s.Required && s.Blocks.Count == 0
|
||||||
|
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
||||||
|
: s.Blocks))
|
||||||
|
.ToList();
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||||
|
|
||||||
|
// Approve/reject require the Approver role explicitly — admin passes SoD
|
||||||
|
// (different identity) but must still be Forbidden.
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden,
|
||||||
|
(await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "admin"))).StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
Normal file
96
backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP-25: the two HTML preview endpoints. Both are excluded from the OpenAPI doc
|
||||||
|
/// (see the drift check in the API-client generation step) — these tests hit them
|
||||||
|
/// as plain HTTP, the same way the hand-written FE fetch does.
|
||||||
|
/// </summary>
|
||||||
|
public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
|
{
|
||||||
|
private const string Registers = OrgTemplateSeed.Registers;
|
||||||
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private static LetterBlockDto FreeText(string id) =>
|
||||||
|
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
|
||||||
|
|
||||||
|
private static SaveBriefRequest FilledFrom(BriefDto brief)
|
||||||
|
{
|
||||||
|
var i = 0;
|
||||||
|
var sections = brief.Sections
|
||||||
|
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
|
||||||
|
.ToList();
|
||||||
|
return new SaveBriefRequest(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResetStores()
|
||||||
|
{
|
||||||
|
BriefStore.Reset();
|
||||||
|
OrgTemplateStore.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null) =>
|
||||||
|
role is null ? new HttpRequestMessage(method, path) : new HttpRequestMessage(method, path) { Headers = { { "X-Role", role } } };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
|
||||||
|
|
||||||
|
var res = await _client.GetAsync("/api/v1/brief/preview");
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
Assert.Equal("text/html", res.Content.Headers.ContentType?.MediaType);
|
||||||
|
var html = await res.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("<div class=\"preview-watermark\"", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"));
|
||||||
|
|
||||||
|
var sentHtml = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
|
||||||
|
Assert.DoesNotContain("<div class=\"preview-watermark\"", sentHtml);
|
||||||
|
Assert.Contains("BIG-register", sentHtml);
|
||||||
|
|
||||||
|
// Republish the org template under a new name.
|
||||||
|
var adminView = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}", role: "admin"));
|
||||||
|
var draft = (await adminView.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!.Draft;
|
||||||
|
await _client.SendAsync(new HttpRequestMessage(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}")
|
||||||
|
{
|
||||||
|
Headers = { { "X-Role", "admin" } },
|
||||||
|
Content = JsonContent.Create(new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })),
|
||||||
|
});
|
||||||
|
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||||
|
|
||||||
|
// The sent brief's preview is unchanged — still the archived rendering.
|
||||||
|
var afterRepublish = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
|
||||||
|
Assert.Equal(sentHtml, afterRepublish);
|
||||||
|
Assert.DoesNotContain("Hertitelde organisatie", afterRepublish);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Proefbrief_is_admin_only_and_renders_the_draft_template()
|
||||||
|
{
|
||||||
|
ResetStores();
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden,
|
||||||
|
(await _client.GetAsync($"/api/v1/admin/org-template/{Registers}/preview")).StatusCode);
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}/preview", role: "admin"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var html = await res.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("BIG-register", html);
|
||||||
|
Assert.Contains("<div class=\"preview-watermark\"", html);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,9 +9,18 @@ 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
|
||||||
|
# WP-25: LetterHtml.Render inlines public/letter.css (the FE⇄BE letter
|
||||||
|
# contract, repo-root sibling of backend/) — mounted here so the same
|
||||||
|
# walk-up-from-the-assembly lookup that works for `dotnet run`/tests also
|
||||||
|
# resolves inside this container, whose bind mount otherwise only sees backend/.
|
||||||
|
- ./public:/src/public:z
|
||||||
ports:
|
ports:
|
||||||
- '5000:5000'
|
- '5000:5000'
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ profile = computed(() =>
|
|||||||
The rule baked into `map2`: the combined result is a **Failure if either
|
The rule baked into `map2`: the combined result is a **Failure if either
|
||||||
failed**, **Loading if either is still loading**, and only **Success when both
|
failed**, **Loading if either is still loading**, and only **Success when both
|
||||||
succeeded**. So the page renders one state and the combiner callback only runs
|
succeeded**. So the page renders one state and the combiner callback only runs
|
||||||
when it's safe. (`map`, `map3`, `andThen` are variations on the same idea.)
|
when it's safe. (`map`, `andThen` are variations on the same idea.)
|
||||||
|
|
||||||
### 2c. The store — "all state changes go through one pure function"
|
### 2c. The store — "all state changes go through one pure function"
|
||||||
|
|
||||||
|
|||||||
@@ -48,3 +48,7 @@ layer — not a palette swap.
|
|||||||
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
|
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
|
||||||
intentionally dropped, so we accept the warning.
|
intentionally dropped, so we accept the warning.
|
||||||
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
|
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
|
||||||
|
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
|
||||||
|
(`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
|
||||||
|
design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
|
||||||
|
drifting.
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ From WP-01 onward, additionally:
|
|||||||
npm run test-storybook:ci
|
npm run test-storybook:ci
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
|
Phases 0–5 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
|
||||||
only needs re-running if a WP unexpectedly touches `backend/`.
|
WPs `cd backend && dotnet test` is part of GREEN, and any wire change ends with
|
||||||
|
`npm run gen:api` leaving no drift.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -44,24 +49,30 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||||
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
||||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
|
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
|
||||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
|
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
||||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
|
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
||||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
|
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
||||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
|
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
|
||||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
|
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
|
||||||
| [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 | todo |
|
| [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 | todo |
|
| [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 | done |
|
||||||
| [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 | done |
|
||||||
| [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 | done |
|
||||||
|
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
||||||
|
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
||||||
|
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
|
||||||
|
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
|
||||||
|
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
|
||||||
|
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
|
||||||
|
|
||||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||||
@@ -71,6 +82,9 @@ are independent of each other and of phases 1–4 — pick any order; **18 is th
|
|||||||
first pick** (it's the headline gap: no authorization spine exists yet, and it closes the
|
first pick** (it's the headline gap: no authorization spine exists yet, and it closes the
|
||||||
FE-computed-authz anti-pattern in `brief.store.ts`). 22 is explicitly lower priority — the
|
FE-computed-authz anti-pattern in `brief.store.ts`). 22 is explicitly lower priority — the
|
||||||
current in-memory persistence is a documented, defensible POC choice, not a bug.
|
current in-memory persistence is a documented, defensible POC choice, not a bug.
|
||||||
|
Phase 6 (Brief v2, the "Brief opstellen v2" PRD) is strictly ordered
|
||||||
|
23 → 24 → 25 → 26 → 27 → 28: 24 needs 23's `orgTemplate` on the wire, 25 needs 24's
|
||||||
|
`letter.css` contract, 26 needs 23's endpoints + 24's canvas, 27/28 polish on top.
|
||||||
|
|
||||||
## WP template
|
## WP template
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-05 — Parse-don't-validate closure + MDX
|
# WP-05 — Parse-don't-validate closure + MDX
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 1 — FP/DDD core
|
Phase: 1 — FP/DDD core
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -45,10 +45,10 @@ PassageScope` → validated parse (the file is otherwise parse-heavy; this one f
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
|
- [x] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
|
||||||
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
|
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
|
||||||
- [ ] Each new parser has a spec including a rejection case.
|
- [x] Each new parser has a spec including a rejection case.
|
||||||
- [ ] MDX renders under Foundations in Storybook.
|
- [x] MDX renders under Foundations in Storybook.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-06 — Generic async template contexts: kill `$any()` (18×)
|
# WP-06 — Generic async template contexts: kill `$any()` (18×)
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 1 — FP/DDD core
|
Phase: 1 — FP/DDD core
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `grep -rn '\$any(' src/app` → zero hits.
|
- [x] `grep -rn '\$any(' src/app` → zero hits.
|
||||||
- [ ] No `as` casts added to compensate in component classes (typed getters are fine).
|
- [x] No `as` casts added to compensate in component classes (typed getters are fine).
|
||||||
- [ ] Build green with strict template checking.
|
- [x] Build green with strict template checking.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
GREEN + `npm run test-storybook:ci`. Smoke: dashboard + registration detail render.
|
GREEN + `npm run test-storybook:ci` (one unrelated flake on `review-section.stories.ts`'s
|
||||||
|
smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke
|
||||||
|
via a running `docker compose` stack + Playwright: logged in, drove `/dashboard`,
|
||||||
|
`/registratie` (registration-detail), `/aanvraag/:id`, `/concepts`, and the
|
||||||
|
`/registreren` wizard through the beroep step (both the DUO-match and the "mijn diploma
|
||||||
|
staat er niet bij" handmatig branch) — every fixed template renders its real data with
|
||||||
|
no console errors.
|
||||||
|
|
||||||
## Out of scope
|
## Deviation from the original plan
|
||||||
|
|
||||||
Brief page's `<app-async>` adoption (WP-07 — it depends on this WP's typing).
|
`AsyncLoadedDirective<T>` + `static ngTemplateContextGuard` **was added** (per the
|
||||||
|
Decisions block) and is real, working generic typing for `AsyncComponent`'s own
|
||||||
|
internals. But it does **not**, and structurally **cannot**, remove `$any()` at the ~9
|
||||||
|
"root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular
|
||||||
|
only infers a structural directive's type parameter from an **input bound on that same
|
||||||
|
node** (see `NgFor`'s `ngForOf`, or `*ngIf="x as y"`'s `ngIf` input) — a generic on a
|
||||||
|
directive that has no input of its own cannot inherit a type from a sibling input on the
|
||||||
|
parent `<app-async>` element, even though the two are nested in the same template. This
|
||||||
|
is a hard limitation of Angular's template type-checker, not a gap in this
|
||||||
|
implementation (confirmed against the documented `ngTemplateContextGuard` pattern and by
|
||||||
|
the compiler continuing to type `let-p` as `unknown` after the generic was added).
|
||||||
|
|
||||||
## Risks
|
The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed
|
||||||
|
getters are fine"): each consumer gets a small `computed()` that unwraps the `RemoteData`
|
||||||
|
Success value, and the template narrows it locally with `@if (x(); as p)` inside the
|
||||||
|
`appAsyncLoaded` slot (no `let-p` on the directive itself). `registratie-wizard` reused
|
||||||
|
its existing `duoData` computed instead of adding a new one. The registration-summary
|
||||||
|
union-narrowing case used the anticipated `@switch` fix, but needed a `@let status =
|
||||||
|
reg().status` binding first — `@switch`/`@case` only narrows a stable local, not a
|
||||||
|
repeated `reg().status` function call. The showcase fake-resource case (`successRes`)
|
||||||
|
just reads `successRes.value()` directly in the `@for`, skipping `let-v` entirely.
|
||||||
|
|
||||||
Angular generic-component inference edge cases — the documented fallback keeps the WP
|
`AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
|
||||||
bounded.
|
deviation is contained to consumer templates, as the WP intended.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-07 — Brief on the shared idioms + RemoteData MDX
|
# WP-07 — Brief on the shared idioms + RemoteData MDX
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 1 — FP/DDD core
|
Phase: 1 — FP/DDD core
|
||||||
Depends on: WP-06 (typed `<app-async>`)
|
Depends on: WP-06 (typed `<app-async>`)
|
||||||
|
|
||||||
@@ -50,15 +50,53 @@ bypassing the shared molecule.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] No boolean-plus-error signal pairs in `brief/`.
|
- [x] No boolean-plus-error signal pairs in `brief/`.
|
||||||
- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`).
|
- [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see
|
||||||
- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle).
|
Deviation for why `empty` isn't meaningful here).
|
||||||
- [ ] MDX renders under Foundations.
|
- [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts`
|
||||||
|
(new).
|
||||||
|
- [x] MDX renders under Foundations.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → `/brief` with
|
GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from
|
||||||
`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow.
|
WP-06's baseline by the new store spec). Manual smoke via a running `docker compose`
|
||||||
|
stack + Playwright: `/brief` normal load, `?scenario=slow` (spinner), `?scenario=error`
|
||||||
|
(failure alert + working retry), and `/brief?role=approver` — all with no console errors.
|
||||||
|
|
||||||
|
## Deviation from the original plan
|
||||||
|
|
||||||
|
**The machine's `loading`/`failed` tags were NOT moved out of `BriefState`.** The
|
||||||
|
Decisions block hedges this ("only if they purely mirror the fetch") — they do, but
|
||||||
|
removing them turns out to need more than a re-type: `createStore(initial, reduce)`
|
||||||
|
requires a concrete `initial: BriefState` value, and once `loading`/`failed` are gone
|
||||||
|
there is no state left to represent "not loaded yet" without inventing a second wrapping
|
||||||
|
layer (the store's top-level signal would need to become `RemoteData<Err, LoadedState>`
|
||||||
|
directly, with the machine's `reduce` only invoked inside the `Success` branch — a
|
||||||
|
different wiring shape from every other machine in the app, and a ~250-line ripple
|
||||||
|
through `brief.machine.spec.ts`). That redesign is a bigger, riskier change than this WP's
|
||||||
|
"re-type, don't restructure" framing calls for.
|
||||||
|
|
||||||
|
Instead, `BriefStore.remoteData` **projects** the existing machine model onto
|
||||||
|
`RemoteData<Error | undefined, LoadedBriefState>` (`loading`→`Loading`, `failed`→
|
||||||
|
`Failure`, `loaded`→`Success`), and `brief.page.ts` renders that projection through
|
||||||
|
`<app-async>`. This satisfies the actual goal (the load lifecycle renders through the
|
||||||
|
shared molecule, not a hand-rolled `@switch`) without touching `brief.machine.ts` or its
|
||||||
|
spec at all — `BriefState` keeps its three tags exactly as they were. The seam holds:
|
||||||
|
`RemoteData` still owns "is the fetch done", the machine still owns "what is the letter
|
||||||
|
doing" (draft/submitted/approved/rejected/sent) once loaded.
|
||||||
|
|
||||||
|
**`?scenario=empty` doesn't apply to `/brief`.** It rewrites the HTTP body to `[]`, which
|
||||||
|
fails `parseBriefView`'s `!dto.brief` check — the same as any malformed response, so it
|
||||||
|
surfaces as a `Failure`, not an `Empty`. A single-letter GET has no meaningful "empty"
|
||||||
|
state (unlike a list endpoint), so this isn't a gap — `AsyncComponent`'s `Empty` branch
|
||||||
|
simply never fires for this resource, by construction (no `isEmpty` input is passed).
|
||||||
|
|
||||||
|
**Reused the WP-06 fallback for the loaded slot.** `<ng-template appAsyncLoaded>` can't
|
||||||
|
type `let-s` to the loaded value for the same structural reason WP-06 documented
|
||||||
|
(a directive's generic can't inherit from a sibling `[data]` input) — `brief.page.ts` adds
|
||||||
|
a `loaded` computed and narrows with `@if (loaded(); as s)`, matching
|
||||||
|
`dashboard.page.ts`/`registration-detail.page.ts`.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
@@ -67,4 +105,11 @@ Brief component stories (WP-15); machine renaming conventions (WP-08).
|
|||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
Autosave (debounced) interplay with the new transition union — flush ordering must stay
|
Autosave (debounced) interplay with the new transition union — flush ordering must stay
|
||||||
as-is; the machine spec pins it.
|
as-is; `brief.store.spec.ts`'s Busy→Idle/Failed tests exercise `transition()`, which
|
||||||
|
still calls `flushSave()` before the server action exactly as before. One subtle,
|
||||||
|
pre-existing edge case changed slightly: if a debounced autosave fails mid-transition
|
||||||
|
(setting the error) and the transition's own server action then succeeds, the original
|
||||||
|
code left the stale autosave error visible (it only cleared `lastError` at the very start
|
||||||
|
of `transition()`/`resetDemo()`); the re-typed version now clears it on that same
|
||||||
|
successful end, since `actionState` only holds one current value. Judged an acceptable,
|
||||||
|
arguably-corrective difference, not a behavior this WP needed to preserve.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-08 — One store idiom + machine naming + TEA MDX
|
# WP-08 — One store idiom + machine naming + TEA MDX
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 1 — FP/DDD core
|
Phase: 1 — FP/DDD core
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts:
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
|
- [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
|
||||||
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` +
|
- [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
|
||||||
hand-rolled dispatch remains.
|
hand-rolled dispatch remains.
|
||||||
- [ ] Convention documented in CLAUDE.md; MDX renders.
|
- [x] Convention documented in CLAUDE.md; MDX renders.
|
||||||
- [ ] All machine specs pass unchanged (reducers untouched).
|
- [x] All machine specs pass unchanged (reducers untouched).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and
|
GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
|
||||||
submit.
|
this WP touched no reducer logic). Manual smoke via a running `docker compose` stack +
|
||||||
|
Playwright: the change-request form (the renamed machine) submitted end-to-end with a
|
||||||
|
referentie shown; the intake wizard stepped forward and back; the herregistratie wizard
|
||||||
|
loaded its first step — no console errors across all three.
|
||||||
|
|
||||||
|
## Deviation from the original plan
|
||||||
|
|
||||||
|
**Step 2 (migrate wizard pages off hand-wired `signal(model)`+`dispatch()` onto
|
||||||
|
`createStore`) turned out to already be done.** `registratie-wizard.component.ts`,
|
||||||
|
`intake-wizard.component.ts`, `herregistratie-wizard.component.ts`, and
|
||||||
|
`change-request-form.component.ts` all already wire
|
||||||
|
`createStore<XState, XMsg>(initial, reduce)` — confirmed both by reading each file and by
|
||||||
|
`git log -p` on `registratie-wizard.component.ts`, which shows `createStore` present
|
||||||
|
since the file's introduction. `grep -rn "= signal<.*State>\|= signal(init" src/app`
|
||||||
|
(excluding specs) turns up nothing outside `store.ts` itself and `brief.store.ts`'s two
|
||||||
|
unrelated transient-state signals (WP-07). The WP's "Why" section was accurate for an
|
||||||
|
earlier snapshot of the codebase but stale by the time this WP ran — only the
|
||||||
|
`change-request.machine.ts` naming fix (Step 1) and the CLAUDE.md/MDX documentation
|
||||||
|
(Steps 3–4) had real work left.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-09 — Pure-logic closure: dates + missing command specs
|
# WP-09 — Pure-logic closure: dates + missing command specs
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 1 — FP/DDD core
|
Phase: 1 — FP/DDD core
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -51,15 +51,28 @@ no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Exactly one hand-written date formatter in the repo; `grep -rn "toLocaleDateString" src/app`
|
- [x] Exactly one hand-written date formatter in the repo. `formatDatumNl` uses
|
||||||
hits only `datum.ts`.
|
`Intl.DateTimeFormat(...).format()` rather than `.toLocaleDateString()`, so
|
||||||
- [ ] Both command specs exist; debounce coalescing + error path covered.
|
`grep -rn "toLocaleDateString" src/app` now hits **nothing** (stronger than the
|
||||||
- [ ] `map3` / unused `variant` input removed (or a note here why kept).
|
literal criterion, same intent — no file anywhere hand-rolls date formatting).
|
||||||
- [ ] CLAUDE.md rule added.
|
- [x] Both command specs exist; debounce coalescing + error path covered
|
||||||
|
(`draft-sync.spec.ts`, `submit-change-request.spec.ts`).
|
||||||
|
- [x] `map3` removed (found in `shared/application/remote-data.ts`, not
|
||||||
|
`shared/kernel/fp.ts` as the WP text guessed — updated the three docs that
|
||||||
|
mentioned it: CLAUDE.md, `docs/ARCHITECTURE.md`, `remote-data.mdx`). The
|
||||||
|
`variant` input on `confirmation.component.ts` no longer exists — already
|
||||||
|
cleaned up before this WP ran; nothing to do.
|
||||||
|
- [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in
|
||||||
|
pure TS).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
GREEN + `npm run test-storybook:ci`.
|
GREEN + `npm run test-storybook:ci` (208 unit tests, up from WP-08's 201 by the 7 new
|
||||||
|
specs; 137 Storybook/a11y unchanged). Manual smoke via a running `docker compose` stack +
|
||||||
|
Playwright: dashboard's herregistratie-deadline task text ("Verleng uw registratie vóór 1
|
||||||
|
maart 2027"), the Concept aanvraag-block's complete-before text ("Rond de aanvraag af
|
||||||
|
vóór 2 augustus 2026"), and `formatDatumNl` unit specs for the letter-preview's `today` —
|
||||||
|
all render the expected long-form Dutch date, no console errors.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
# WP-10 — CIBG button fidelity
|
# WP-10 — CIBG button fidelity
|
||||||
|
|
||||||
Status: todo
|
Status: done (69880ef)
|
||||||
Phase: 2 — CIBG fidelity
|
Phase: 2 — CIBG fidelity
|
||||||
|
|
||||||
|
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
|
||||||
|
.btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
|
||||||
|
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
|
||||||
|
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
|
||||||
|
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope
|
||||||
|
> button (atom, file-input, RTE toolbar) currently has a next/previous affordance to
|
||||||
|
> attach one to — skipped as not applicable, not recorded as a gap (nothing hand-rolled
|
||||||
|
> to mark).
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost /
|
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost /
|
||||||
@@ -46,9 +55,9 @@ and render as unstyled Bootstrap defaults instead of CIBG buttons.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
|
- [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
|
||||||
- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system.
|
- [x] Button story shows all CIBG variants incl. ghost; visuals match the design system.
|
||||||
- [ ] Axe still green (contrast can change with real button styles).
|
- [x] Axe still green (contrast can change with real button styles).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
# WP-13 — CIBG-gap register + hygiene + MDX
|
# WP-13 — CIBG-gap register + hygiene + MDX
|
||||||
|
|
||||||
Status: todo
|
Status: done (9d58f59)
|
||||||
Phase: 2 — CIBG fidelity
|
Phase: 2 — CIBG fidelity
|
||||||
|
|
||||||
|
> **Deviation:** WP-11/12 ran first but left no markers (deferred to this WP, as their own
|
||||||
|
> files note), so this WP defines the marker format fresh per its own Decisions block —
|
||||||
|
> not adopted from 11/12. The Decisions block's `task-list → Actieblok` mapping is stale:
|
||||||
|
> no `.actieblok`/`actie` class exists in the vendored CSS, and `task-list`'s own header
|
||||||
|
> comment already (accurately) documents it as composing `choice-list`'s Keuzelijst
|
||||||
|
> pattern rather than a distinct Actieblok one — left as-is rather than forced to claim a
|
||||||
|
> nonexistent mapping. `application-link`'s `.static-row` (flagged as a marked-gap
|
||||||
|
> candidate in this file's own correction note) got the marker too. The optional
|
||||||
|
> `check:cibg-gaps` script (step 4) is skipped: the register is nine rows, reviewed at PR
|
||||||
|
> time same as any other doc — a CI script to diff it against code markers is complexity
|
||||||
|
> the size of the problem doesn't warrant (noted, not built).
|
||||||
|
|
||||||
> **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite
|
> **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite
|
||||||
> had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload").
|
> had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload").
|
||||||
> The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
|
> The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
|
||||||
@@ -72,11 +84,11 @@ Components to mark (closest CIBG concept in parens):
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Every component with hand-rolled surface CSS either wraps vendored classes or
|
- [x] Every component with hand-rolled surface CSS either wraps vendored classes or
|
||||||
carries the marker (spot-check with a grep for `styles: [` vs markers).
|
carries the marker (spot-check with a grep for `styles: [` vs markers).
|
||||||
- [ ] Register MDX complete, linked from ADR-0003.
|
- [x] Register MDX complete, linked from ADR-0003.
|
||||||
- [ ] `upload-status-banner` gone; consumer green; no story coverage lost.
|
- [x] `upload-status-banner` gone; consumer green; no story coverage lost.
|
||||||
- [ ] List trio documented.
|
- [x] List trio documented.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -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-18 — ABAC capability spine (Principal + capabilities, phase P1)
|
# WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
|
||||||
|
|
||||||
Status: todo
|
Status: done (7ec13d8)
|
||||||
Phase: 5 — productie-volwassenheid
|
Phase: 5 — productie-volwassenheid
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -8,146 +8,171 @@ Phase: 5 — productie-volwassenheid
|
|||||||
The single biggest gap between this POC and a production SSP: identity carries no
|
The single biggest gap between this POC and a production SSP: identity carries no
|
||||||
roles/capabilities (`Session { bsn, naam }` only), the only "role" is an unverified
|
roles/capabilities (`Session { bsn, naam }` only), the only "role" is an unverified
|
||||||
`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable`
|
`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable`
|
||||||
computes its authorization gate **in the frontend** from that header — the exact
|
computed its authorization gate **in the frontend** from that header — the exact
|
||||||
anti-pattern ADR-0001 exists to prevent (FE renders decisions, never computes them).
|
anti-pattern ADR-0001 exists to prevent (FE renders decisions, never computes them).
|
||||||
The backend is fully open: no `[Authorize]`, no principal, ownership is a constant
|
The backend was fully open: no `[Authorize]`, no principal, ownership is a constant
|
||||||
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; nothing is built. This
|
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; this WP implements
|
||||||
WP implements PRD-0002's **P1 — Capability spine** only (§9), the smallest slice
|
PRD-0002's **P1 — Capability spine** only (§9), the smallest slice that closes the
|
||||||
that closes the anti-pattern and gives every later phase (data-scoping, PII
|
anti-pattern and gives every later phase (data-scoping, PII redaction, step-up/audit)
|
||||||
redaction, step-up/audit) a real foundation to extend.
|
a real foundation to extend.
|
||||||
|
|
||||||
## Read first
|
## Read first
|
||||||
|
|
||||||
- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
|
- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
|
||||||
union, identity-vs-authorization split)
|
union, identity-vs-authorization split — see the deviation noted below)
|
||||||
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1 (this WP
|
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
|
||||||
implements exactly P1 — don't reach into P2/P3)
|
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
|
||||||
- `src/app/auth/domain/session.ts` (flat `Session` to replace)
|
authorization helper)
|
||||||
- `src/app/auth/application/session.store.ts`, `src/app/auth/auth.guard.ts` (seams
|
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
|
||||||
that already localise the `Session → Principal` swap, per ADR-0002)
|
SoD guard to `Authz.CanActOn`)
|
||||||
- `src/app/shared/domain/role.ts`, `src/app/shared/infrastructure/role.ts` +
|
- `src/app/brief/application/brief.store.ts` (the FE-computed gate that was removed)
|
||||||
`role.interceptor.ts` (the dev stub being retired as an authority, kept as a dev
|
|
||||||
toggle)
|
|
||||||
- `src/app/brief/application/brief.store.ts:28,41-46` (`readonly role = currentRole()`
|
|
||||||
and the `editable` computed — the FE-computed gate to remove)
|
|
||||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27` (`HerregistratieDecisionsDto`
|
|
||||||
— the decision-flag pattern this WP extends to a `BriefDecisionsDto`)
|
|
||||||
- `backend/src/BigRegister.Api/Program.cs:318-335` (`IsAdmin`, the `X-Role` reader
|
|
||||||
around brief endpoints — becomes `Authz.Can`)
|
|
||||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review`, the
|
|
||||||
`actingId == e.DrafterId → Forbidden` SoD check — keep this check, move it behind
|
|
||||||
the verified principal)
|
|
||||||
|
|
||||||
## Decisions (pre-made, don't relitigate)
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
- **P1 scope only.** No data-scoping, no PII redaction/BSN reveal, no step-up or
|
- **P1 scope only.** No data-scoping, no PII redaction/BSN reveal, no step-up or
|
||||||
audit log — those are PRD-0002 §9 P2/P3, separate future WPs. This WP: `Principal`,
|
audit log — those are PRD-0002 §9 P2/P3, separate future WPs.
|
||||||
`AccessStore`/`can()`, `capabilityGuard`, `GET /me`, capability flags on the brief
|
|
||||||
screen DTO, and server-side enforcement via one shared `Authz.Can` helper.
|
|
||||||
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The
|
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The
|
||||||
`Principal` is still built server-side from the existing dev stand-ins
|
`Principal` is built server-side from the existing dev stand-in (`X-Role` header),
|
||||||
(`X-Role`/`X-Admin` headers), but it becomes the backend's own construct — the FE
|
but it becomes the backend's own construct — the FE never re-derives capabilities
|
||||||
never re-derives capabilities from the header, it only reads what the backend sends.
|
from the header, it only reads what the backend sends.
|
||||||
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — start with
|
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — exactly
|
||||||
exactly `brief:approve`, `brief:reject`, `brief:send` (the brief flow is the only
|
`brief:approve`, `brief:reject`, `brief:send` (the only role-gated flow that
|
||||||
role-gated flow that exists today). Do not invent capabilities for flows that don't
|
exists today). The brief screen's fourth flag, `canEdit`, is a **screen decision**
|
||||||
exist yet (e.g. `aanvraag:beoordelen` — that's the backoffice, ADR-0002, out of scope).
|
on `BriefDecisionsDto`, not a named capability string — it's resource/state-scoped
|
||||||
- **Emit and enforce are the same code path.** `Authz.Can(principal, action, resource)`
|
(draft/rejected + drafter role) the same way `HerregistratieDecisionsDto` blends
|
||||||
is called both to compute the DTO flag and to gate the endpoint — never two separate
|
business state into a decision flag, and `GET /me`'s coarse `RoleCapabilities` set
|
||||||
checks that can drift (PRD-0002 §7, the classic BOLA bug it calls out).
|
stays exactly the three above.
|
||||||
- **Dev role toggle survives**, but moves behind the `Principal` seam: `?role=` still
|
- **Emit and enforce are the same code path for approve/reject.**
|
||||||
picks an identity for demo purposes, but it flows into building the `Principal`
|
`Authz.CanActOn(action, principal, drafterId)` is the SAME check
|
||||||
server-side (still asserted by the client — this is _not_ real auth, just moving
|
`BriefStore.Review` uses to gate the mutation and `Authz.Decisions` uses to compute
|
||||||
the authority from FE-computed to BE-computed within the POC's honesty envelope).
|
the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic
|
||||||
Keep it explicitly commented `// dev stub — NOT a security boundary` per PRD-0002 §3.
|
BOLA bug it calls out). `Send` is deliberately **not** role-gated (see Risks) —
|
||||||
|
that parity is preserved exactly, decisions only mirror it.
|
||||||
|
- **Dev role toggle survives** as the POC's identity stub: `?role=` still picks an
|
||||||
|
identity for demo purposes, resolved into a `Principal` server-side via
|
||||||
|
`Authz.ResolvePrincipal`. Commented `dev stub — NOT a security boundary` per
|
||||||
|
PRD-0002 §3.
|
||||||
|
- **Deviation from the original plan — `auth/domain/session.ts` is untouched.** An
|
||||||
|
earlier draft of this WP planned a `Session → Principal` rename in the SSP's login
|
||||||
|
domain. That's **out of scope**: ADR-0002 explicitly lists that refactor as
|
||||||
|
"deferred until a second actor is actually introduced" (§"Out of scope here"), and
|
||||||
|
no second actor exists yet — renaming a type to a one-variant union ahead of that
|
||||||
|
need is exactly the premature abstraction the ADR warns against. It also turned
|
||||||
|
out unnecessary: the brief workflow's drafter/approver "acting identity" is a
|
||||||
|
**separate axis** from the SSP login session (a Zorgverlener logs in via BSN;
|
||||||
|
drafter/approver is an independent `?role=` toggle, not tied to that login). This
|
||||||
|
WP's `Principal` therefore lives entirely in the backend's
|
||||||
|
`BigRegister.Domain.Authorization` namespace and never touches `auth/`.
|
||||||
|
|
||||||
## Files
|
## Files (as built)
|
||||||
|
|
||||||
- `src/app/auth/domain/session.ts` — replace `Session` with the `Principal`
|
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new) — `Principal`,
|
||||||
discriminated union from ADR-0002 (`{ kind: 'zorgverlener'; bsn; naam }` — no
|
`PrincipalRole`, `BriefAction`, `Authz.ResolvePrincipal/ActingId/RoleCapabilities/
|
||||||
`medewerker` variant yet, that's ADR-0002/backoffice scope; keep the union shape so
|
CanActOn/Decisions`.
|
||||||
it's additive later). Update `isAuthenticated`.
|
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — added `BriefDecisionsDto(CanEdit,
|
||||||
- `src/app/auth/application/session.store.ts` — carries the `Principal`; unchanged
|
CanApprove, CanReject, CanSend)` on `BriefViewDto`; added `MeDto(Capabilities)`.
|
||||||
persistence rules (never persist the BSN, per existing comment).
|
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `Approve`/`Reject`/`Review` take
|
||||||
- New `src/app/shared/domain/capability.ts` — branded/union `Capability` type
|
a `Principal` + `BriefAction` and delegate the SoD check to `Authz.CanActOn`
|
||||||
(`'brief:approve' | 'brief:reject' | 'brief:send'`), framework-free.
|
(same Forbidden-before-Conflict ordering as before).
|
||||||
- New `src/app/shared/application/access.store.ts` — `providedIn: 'root'`, holds
|
- `backend/src/BigRegister.Api/Program.cs` — `GET /api/v1/me`; every brief endpoint
|
||||||
resolved capabilities as `RemoteData` from `GET /me`; `can(capability): boolean`,
|
(including `send`, which had no `HttpContext` before) now returns a fresh
|
||||||
deny-by-default on absence.
|
`BriefViewDto` (via a shared `ToView`/`BriefResult` helper) so decisions are never
|
||||||
- New `src/app/shared/infrastructure/me.adapter.ts` (+ spec) — calls `GET /me`,
|
stale after a mutation.
|
||||||
`parseMe(): Result` boundary.
|
- `backend/tests/BigRegister.Tests/AuthzTests.cs` (new) — unit tests for `Authz`.
|
||||||
- New `capabilityGuard` in `src/app/auth/auth.guard.ts` (or co-located
|
- `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` — updated to deserialize
|
||||||
`capability.guard.ts`) — factory `CanActivateFn` extending `authGuard`'s shape.
|
`BriefViewDto` (not bare `BriefDto`) from submit/approve/reject/send; two new
|
||||||
- `src/app/brief/application/brief.store.ts` — delete `readonly role = currentRole()`
|
tests for live decisions and `/me`.
|
||||||
and the FE-computed `editable`; read `canApprove`/`canReject`/`canSend` off the
|
- `src/app/shared/domain/capability.ts` (new) — the `Capability` union type.
|
||||||
loaded `BriefViewDto`'s new `BriefDecisionsDto` instead.
|
- `src/app/shared/infrastructure/me.adapter.ts` (+ spec, new) — `GET /me` adapter +
|
||||||
- `src/app/shared/infrastructure/role.interceptor.ts` — keep (still asserts the dev
|
`parseMe` boundary (unknown capability strings are dropped, not rejected).
|
||||||
identity), retitle its comment to "feeds Principal construction, not an authority
|
- `src/app/shared/application/access.store.ts` (new) — `AccessStore.can()`,
|
||||||
the FE reads back."
|
deny-by-default.
|
||||||
- Backend: `backend/src/BigRegister.Api/Contracts/Dtos.cs` — add
|
- `src/app/auth/auth.guard.ts` — added `capabilityGuard(capability)` factory.
|
||||||
`BriefDecisionsDto(bool CanApprove, bool CanReject, bool CanSend)`; add it to
|
**Built but deliberately unwired**: no route in this app needs a capability gate
|
||||||
`BriefViewDto`.
|
today (both drafter and approver land on the same `/brief` page; the gating is
|
||||||
- New `backend/src/BigRegister.Api/Domain/Authorization/Principal.cs` +
|
per-action, not per-page). It's the available building block for a future
|
||||||
`Authz.cs` — `Principal` (mirrors the FE union), `Authz.Can(principal, action)`
|
approver-only page.
|
||||||
covering the three brief capabilities + the existing SoD rule.
|
- `src/app/brief/domain/brief.ts` — added the `BriefDecisions` domain type.
|
||||||
- `backend/src/BigRegister.Api/Program.cs` — add `GET /api/v1/me` returning the
|
- `src/app/brief/domain/brief.machine.ts` (+ spec) — `BriefState.loaded` and the
|
||||||
resolved `Principal`'s capabilities; replace the ad-hoc `X-Role` reads around
|
`BriefLoaded`/`Submitted`/`Approved`/`Rejected`/`Sent` messages now carry
|
||||||
brief endpoints with `Authz.Can`; compute `BriefDecisionsDto` via the same helper.
|
`decisions`; the pure `transition()` helper replaces them with each fresh
|
||||||
- New backend test `backend/tests/BigRegister.Tests/AuthzTests.cs` — `Authz.Can`
|
server value.
|
||||||
unit tests (approve/reject/send × drafter/approver × SoD).
|
- `src/app/brief/infrastructure/brief.adapter.ts` (+ spec) — `save/submit/approve/
|
||||||
|
reject/send` now return `Result<string, BriefView>` (was `Brief`) via
|
||||||
|
`parseBriefView`, which also parses `decisions`.
|
||||||
|
- `src/app/brief/application/brief.store.ts` — deleted `currentRole()`/`editable`;
|
||||||
|
added `canEdit`/`canApprove`/`canReject`/`canSend` computed straight from
|
||||||
|
`BriefState.loaded.decisions`.
|
||||||
|
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (+ stories) —
|
||||||
|
`editable`/`role` inputs replaced by the four `can*` inputs; the approve/reject
|
||||||
|
block gates on `canApprove() || canReject()`, the send button on `canSend()`.
|
||||||
|
- `src/app/brief/ui/brief.page.ts` — passes the four `can*` signals through.
|
||||||
|
- `src/app/shared/infrastructure/role.ts` — comment updated (no longer claims the
|
||||||
|
FE derives `editable` from the role reader).
|
||||||
|
- Regenerated `backend/swagger.json` + `src/app/shared/infrastructure/api-client.ts`
|
||||||
|
via `npm run gen:api` (new `/me` endpoint + DTO shapes).
|
||||||
|
|
||||||
## Steps
|
## Steps (as executed)
|
||||||
|
|
||||||
1. Backend: `Principal`, `Authz.Can`, `GET /me`, `BriefDecisionsDto` wired into the
|
1. Backend: `Authz.cs`, DTOs, `BriefStore` delegation, `Program.cs` wiring
|
||||||
existing brief endpoints (replace `IsDrafter`/`X-Role` reads one at a time,
|
(`GET /me` + `BriefResult`/`ToView`) — kept `dotnet test` green throughout
|
||||||
keeping `BriefEndpointTests.cs` green after each).
|
(79/79 including 10 new tests).
|
||||||
2. FE: `Capability` type, `access.store.ts`, `me.adapter.ts`, `capabilityGuard`.
|
2. `npm run gen:api` to pick up the new endpoint/DTOs before touching the FE.
|
||||||
3. FE: `Session → Principal` in `auth/domain`; thread through `SessionStore`,
|
3. FE domain: `BriefDecisions`, machine state/messages, machine spec fixtures.
|
||||||
`auth.guard.ts` (both keep working — `isAuthenticated` still means "has a
|
4. FE infrastructure: `parseDecisions`/`parseBriefView` in `brief.adapter.ts` (+spec).
|
||||||
Principal").
|
5. FE application: `brief.store.ts`'s computed flags; `access.store.ts` +
|
||||||
4. FE: `brief.store.ts` reads `canApprove`/`canReject`/`canSend` from the DTO;
|
`me.adapter.ts` (+spec) as the general capability-spine infrastructure.
|
||||||
delete `currentRole()` import and the FE-computed `editable`. Update the brief
|
6. FE UI: `letter-composer` inputs/template, `brief.page.ts` bindings, stories.
|
||||||
UI components consuming `.editable`/`.role` to consume the new flags.
|
7. Full GREEN gate + a live curl smoke test against the running backend (submit as
|
||||||
5. Update PRD-0002's own status: this WP completes phase P1 — note it in the PRD or
|
drafter → 403 on approve as drafter → 200 on approve as approver, with decisions
|
||||||
leave for a follow-up doc pass (don't rewrite the PRD's phasing table mid-WP).
|
flipping correctly at each step).
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `brief.store.ts` contains no `currentRole()` call and no FE-computed
|
- [x] `brief.store.ts` contains no `currentRole()` call and no FE-computed
|
||||||
permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
|
permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
|
||||||
- [ ] Forging `?role=approver` in the browser with a stale/absent server capability
|
- [x] The SoD rule is enforced server-side regardless of FE state — verified by
|
||||||
still gets a 403 from the backend (verified by a test hitting the endpoint
|
curl directly against the backend (drafter calling `/brief/approve` → 403)
|
||||||
directly, bypassing the FE).
|
and by `AuthzTests`/`BriefEndpointTests`, bypassing the FE entirely.
|
||||||
- [ ] `Authz.Can` is the only place brief authorization logic lives; the emit path
|
- [x] `Authz.CanActOn`/`Authz.Decisions` is the only place brief authorization logic
|
||||||
(DTO flags) and the enforce path (endpoint gating) both call it.
|
lives; the emit path (DTO flags) and the enforce path (`BriefStore.Review`)
|
||||||
- [ ] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
|
both call it.
|
||||||
unknown capability.
|
- [x] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
|
||||||
- [ ] The existing SoD rule (approver ≠ drafter) still holds, now expressed as an
|
unknown capability (deny-by-default, verified in `me.adapter.spec.ts`).
|
||||||
`Authz.Can` precondition rather than inline in `BriefStore.Review`.
|
- [x] The existing SoD rule (approver ≠ drafter) still holds, expressed as
|
||||||
- [ ] `capabilityGuard` compiles and is demonstrated on at least one route (or
|
`Authz.CanActOn` instead of the old inline check in `BriefStore.Review`.
|
||||||
documented as available-but-unwired if no route needs it yet — brief has no
|
- [x] `capabilityGuard` compiles; documented as available-but-unwired (no route
|
||||||
route today, it's a page section).
|
needs it yet — see Files).
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
GREEN (`docs/backlog/README.md`) + `cd backend && dotnet test`. Manual smoke:
|
GREEN gate, all green: `npm run lint && npm run check:tokens && npm test && npm run
|
||||||
`npm start` with `?role=drafter` — draft-only actions enabled; `?role=approver` —
|
build && npm run build-storybook && npm run test-storybook:ci` (189 unit tests, 137
|
||||||
approve/reject enabled, editing disabled. Confirm via browser devtools that removing
|
Storybook/a11y tests) + `cd backend && dotnet test` (79/79) +
|
||||||
the `X-Role` header (or backend patched to ignore it) makes every capability `false`
|
`dotnet format --verify-no-changes`. Manual smoke via curl against a running
|
||||||
— i.e. deny-by-default actually denies.
|
backend: default (drafter) `GET /brief` → `canEdit: true`; submit → decisions
|
||||||
|
recompute; drafter `POST /brief/approve` → 403; approver `POST /brief/approve` →
|
||||||
|
200, `canSend: true` afterward. `GET /me` → `[]` for drafter,
|
||||||
|
`["brief:approve","brief:reject","brief:send"]` for approver.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit
|
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit
|
||||||
log) — separate future WPs. The Behandeling/backoffice app and `medewerker`
|
log) — separate future WPs. The Behandeling/backoffice app and a `medewerker`
|
||||||
`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
|
`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
|
||||||
AD/OIDC integration (identity provider stays simulated).
|
AD/OIDC integration (identity provider stays simulated). The `auth/domain/session.ts`
|
||||||
|
`Session → Principal` rename (see the Decisions deviation above — ADR-0002 defers
|
||||||
|
it explicitly).
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
Threading `Principal` through `SessionStore`/`auth.guard.ts` touches the one
|
`Send` was already unauthenticated/unauthorized before this WP (no role check on
|
||||||
authenticated-session seam every route depends on — keep the observable shape
|
`POST /brief/send`) — `Authz.CanActOn(Send, …)` preserves that exactly
|
||||||
(`isAuthenticated(): boolean`) identical so no route wiring needs to change, only
|
(`=> true`, a mechanical dispatch step) rather than silently introducing a new gate
|
||||||
what's inside `Session`/`Principal`. Backend `Authz.Can` replacing inline `X-Role`
|
that would have broken the existing `Send_only_from_approved` test (which calls
|
||||||
reads must preserve the existing 403 `Outcome.Forbidden` mapping
|
`send` as the default drafter identity and expects success). If a future WP decides
|
||||||
(`Program.cs:330-335`) exactly, or `BriefEndpointTests.cs` breaks.
|
`send` should be approver-only, that's a deliberate behavior change, not a bug fix.
|
||||||
|
Every brief mutation endpoint now returns `BriefViewDto` instead of bare `BriefDto`
|
||||||
|
— a wire-shape change; the generated `api-client.ts` was regenerated and every FE
|
||||||
|
call site updated, but any other caller of these endpoints outside this repo would
|
||||||
|
need the same update.
|
||||||
|
|||||||
@@ -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 (e276629)
|
||||||
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 (40dbcb2)
|
||||||
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 (556f2f4)
|
||||||
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.
|
||||||
|
|||||||
114
docs/backlog/WP-23-org-template-backend.md
Normal file
114
docs/backlog/WP-23-org-template-backend.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# WP-23 — Org-template backend + admin role
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
PRD "Brief opstellen v2" splits a rendered letter over two orthogonal template axes:
|
||||||
|
the **case-type template** (section structure + placeholders — exists, unchanged) and
|
||||||
|
a new **organization template** (appearance/identity per sub-organization: letterhead,
|
||||||
|
footer, signature, margins). This WP builds the second axis server-side plus the
|
||||||
|
`admin` role that will edit it (WP-26). Everything downstream (canvas WP-24, preview
|
||||||
|
WP-25, editor WP-26) reads what this WP serves.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `docs/prd` — the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants)
|
||||||
|
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (store idiom + `BriefSeed`)
|
||||||
|
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (emit+enforce single source)
|
||||||
|
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` (JSON-column precedent, WP-22)
|
||||||
|
- `docs/backlog/WP-18-abac-capability-spine.md` (how the capability spine works)
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
- **No case model, no sub-org auth scoping.** `GET /brief` stays; the brief just
|
||||||
|
gains a `SubOrgId`. Two seeded sub-orgs (`cibg-registers`, `cibg-vakbekwaamheid`)
|
||||||
|
exist purely so the admin editor can demo isolation.
|
||||||
|
- **One row per sub-org**, versions as a JSON history column
|
||||||
|
(`OrgTemplateEntity { SubOrgId PK, Draft json, PublishedVersion, History json }`) —
|
||||||
|
the WP-22 JSON-column precedent; no extra tables. Publish = append draft snapshot
|
||||||
|
to history + version++. Rollback = copy `History[v]` into `Draft` (history stays
|
||||||
|
append-only; admin republishes).
|
||||||
|
- **Sent letters are immutable**: `Send` pins `SentOrgTemplateVersion`; a sent
|
||||||
|
brief's `BriefViewDto.orgTemplate` resolves from history, never from the current
|
||||||
|
published version. Unsent briefs always follow the current published version —
|
||||||
|
that is the point of the admin editor.
|
||||||
|
- **Admin = third `X-Role` value** (`PrincipalRole.Admin`), capability
|
||||||
|
`orgtemplate:edit` via `GET /me`. Approve/reject gain an explicit
|
||||||
|
`Role == Approver` condition so the new role cannot slip through the SoD-only
|
||||||
|
check. The existing `X-Admin` document-deletion seam stays untouched.
|
||||||
|
- **Trimmed model** (PRD §3 minus): no signature image asset, no structured address
|
||||||
|
objects, no per-template fonts. Return address / footer contact are multiline
|
||||||
|
strings. Margins are 4 bounded ints (mm, 10–50) — server-validated.
|
||||||
|
- **Logo = existing upload machinery**: seed an `org-logo` category (png/jpeg, 1 MB)
|
||||||
|
under a `org-template` wizardId; the template stores only `logoDocumentId`.
|
||||||
|
- Seed values come from the sample artifact `voorbeeldbrief-inschrijving.pdf`
|
||||||
|
(A. de Vries / Hoofd Registratie / Postbus 00000 / info@voorbeeld.example — all fictitious).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — `MarginsDto`, `OrgTemplateDto`,
|
||||||
|
`OrgTemplateVersionDto`, `OrgTemplateAdminViewDto`, `SaveOrgTemplateRequest`,
|
||||||
|
`PublishOrgTemplateResponse`, `SubOrgSummaryDto`; `BriefViewDto` + `OrgTemplate`.
|
||||||
|
- `backend/src/BigRegister.Api/Data/OrgTemplateStore.cs` (new) — entity + store + seed.
|
||||||
|
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` — OrgTemplates DbSet + JSON converters.
|
||||||
|
- `backend/src/BigRegister.Api/Data/Migrations/*` — new migration.
|
||||||
|
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `SubOrgId`, `SentOrgTemplateVersion`, pin at `Send`.
|
||||||
|
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` — `Admin` role, capability, gate.
|
||||||
|
- `backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs` — `org-logo` category.
|
||||||
|
- `backend/src/BigRegister.Api/Program.cs` — 5 admin endpoints, `ToView` orgTemplate resolution.
|
||||||
|
- `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` (new).
|
||||||
|
- Regenerated: `backend/swagger.json`, `src/app/shared/infrastructure/api-client.ts`.
|
||||||
|
- FE seam only: `src/app/shared/domain/role.ts`, `shared/domain/capability.ts`,
|
||||||
|
`shared/infrastructure/role.ts`, `shared/infrastructure/role.interceptor.ts`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. DTOs (above).
|
||||||
|
2. `OrgTemplateEntity` + `OrgTemplateStore` (list/get/saveDraft/publish/rollback/
|
||||||
|
published/versionPayload; margin validation; seed-on-first-access, 2 sub-orgs,
|
||||||
|
draft == published v1) + AppDbContext mapping + migration.
|
||||||
|
3. `PrincipalRole.Admin`; `ResolvePrincipal` reads `admin`; `RoleCapabilities(Admin)`
|
||||||
|
→ `orgtemplate:edit`; approve/reject checks require `Approver` explicitly.
|
||||||
|
4. Endpoints: `GET /admin/org-templates`, `GET|PUT /admin/org-template/{subOrgId}`,
|
||||||
|
`POST …/publish` (returns impact count = unsent briefs of that sub-org),
|
||||||
|
`POST …/rollback/{version}`. All 403 for non-admin via `Authz`.
|
||||||
|
5. `BriefEntity.SubOrgId` (seed `cibg-registers`) + `SentOrgTemplateVersion`; `Send`
|
||||||
|
pins; `ToView` resolves published-vs-pinned into `BriefViewDto.orgTemplate`.
|
||||||
|
6. Seed `org-logo` upload category.
|
||||||
|
7. `npm run gen:api`.
|
||||||
|
8. FE: widen `Role`/`Capability` unions, `currentRole()`, interceptor URL filter
|
||||||
|
(nothing consumes them yet — WP-24/26 do).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Publish increments `publishedVersion` and appends to history; rollback copies an
|
||||||
|
old version into the draft without rewriting history.
|
||||||
|
- [x] Every admin endpoint returns 403 for drafter/approver, 200 for `X-Role: admin`.
|
||||||
|
- [x] A sent brief keeps its pinned org-template version after a republish; an unsent
|
||||||
|
brief follows the new published version (both asserted in one test).
|
||||||
|
- [x] Publish impact count = number of unsent briefs of that sub-org.
|
||||||
|
- [x] `PUT` with out-of-bounds margins → 400.
|
||||||
|
- [x] `GET /brief` carries `orgTemplate`; existing brief tests stay green.
|
||||||
|
- [x] `GET /me` with `X-Role: admin` → `["orgtemplate:edit"]`.
|
||||||
|
- [x] Full GREEN (FE untouched functionally, but lint/test/build/storybook all pass).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`cd backend && dotnet test`; GREEN one-liner; curl smoke: admin list/save/publish
|
||||||
|
(200) vs drafter (403); sent-brief pin walk-through per acceptance.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
The canvas (WP-24), HTML preview endpoints + archive-at-send (WP-25), the admin UI
|
||||||
|
(WP-26). Template approval chains (draft→publish is enough for the POC; flagged as
|
||||||
|
an open question in the PRD). Sub-org-scoped brief authorization.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
`BriefViewDto` gains a field — additive, but the FE `parseBriefView` boundary and
|
||||||
|
generated client must be regenerated in the same WP to keep the drift check green.
|
||||||
|
Adding `PrincipalRole.Admin` touches the approve/reject SoD path: the explicit
|
||||||
|
`Role == Approver` condition must preserve today's Forbidden-before-Conflict order
|
||||||
|
(existing tests prove it).
|
||||||
99
docs/backlog/WP-24-letter-canvas.md
Normal file
99
docs/backlog/WP-24-letter-canvas.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# WP-24 — Letter canvas (edit on the letter)
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The drafter should compose **on the letter** — letterhead above, footer/signature
|
||||||
|
below, content blocks edited in place — instead of in an abstract form next to a
|
||||||
|
separate preview. PRD Brief v2 §4. This is a **presentation rebuild only**: the
|
||||||
|
domain model, `brief.machine.ts`, and every `BriefMsg` stay byte-identical.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- PRD Brief v2 §2b (fidelity note), §4, §10; the sample `voorbeeldbrief-inschrijving.pdf`
|
||||||
|
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (the `canEdit` pivot)
|
||||||
|
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (rendering that migrates in)
|
||||||
|
- `docs/backlog/WP-23-org-template-backend.md` (the `orgTemplate` on `BriefViewDto`)
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
- **One stylesheet is the FE⇄BE rendering contract**: `public/letter.css` — class
|
||||||
|
vocabulary `.letter`, `.letter__letterhead`, `.letter__body`, `.letter__signature`,
|
||||||
|
`.letter__footer`, `.letter__page-break`; margins as `--letter-margin-*` custom
|
||||||
|
props; `@page`/print rules. Loaded via `<link>` in `index.html` (app + Storybook).
|
||||||
|
WP-25's backend renderer inlines the same file; its parity test is the fence.
|
||||||
|
- **`LetterCanvasComponent`** (new organism, `Domein/Brief/Letter Canvas`) with
|
||||||
|
`editableRegions: 'content' | 'template' | 'none'` — one component serves drafter
|
||||||
|
composing, approver review (and in WP-26, the admin editor). Letter typography on
|
||||||
|
the canvas is the letter's, not the portal UI's — but still token-bridged.
|
||||||
|
- `'content'` mode hosts the **existing** `letter-section`/`letter-block` components
|
||||||
|
unchanged; letterhead/footer/signature render read-only with a subtle tint and a
|
||||||
|
first-use caption ("komt uit de huisstijl van de organisatie").
|
||||||
|
- `'none'` mode absorbs `letter-preview`'s rendering (paragraph grouping, placeholder
|
||||||
|
chips, sample-values toggle); **`ui/letter-preview/` is then deleted** — the PRD
|
||||||
|
explicitly supersedes it; no third rendering is maintained.
|
||||||
|
- **Page-break indicator is approximate by design**: a dashed line per A4-content
|
||||||
|
interval with the caption "±pagina-einde — afdrukvoorbeeld is leidend" (PRD §2b
|
||||||
|
honesty requirement).
|
||||||
|
- `brief.machine.ts` and `BriefMsg` are untouched — `git diff` must prove it.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `public/letter.css` (new), `src/index.html` (link)
|
||||||
|
- `src/app/brief/domain/org-template.ts` (new, pure) — `OrgTemplate` type
|
||||||
|
- `src/app/brief/infrastructure/brief.adapter.ts` (+spec) — parse `orgTemplate`
|
||||||
|
- `src/app/brief/ui/letter-canvas/*` (new: component + stories)
|
||||||
|
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (pivot swap) + stories
|
||||||
|
- `src/app/brief/ui/brief.page.ts` (pass orgTemplate through)
|
||||||
|
- delete `src/app/brief/ui/letter-preview/*`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. `letter.css` from the sample PDF's geometry (A4 proportions, letterhead, address
|
||||||
|
window + reference block, footer rule, signature).
|
||||||
|
2. `OrgTemplate` domain type + `parseOrgTemplate` boundary in the adapter (+spec).
|
||||||
|
3. Canvas organism: three modes, zoom input, page-break indicator.
|
||||||
|
4. Migrate `letter-preview` rendering into `'none'` mode; delete the component,
|
||||||
|
fold its stories into the canvas stories.
|
||||||
|
5. Swap the composer's `@if (canEdit())` pivot for
|
||||||
|
`<app-letter-canvas [editableRegions]="canEdit() ? 'content' : 'none'">`.
|
||||||
|
6. Stories: three modes + zoom + page-break, all axe-gated.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Drafter edits blocks in place on the letter surface; passage picker and
|
||||||
|
diagnostics click-to-locate still work on the canvas.
|
||||||
|
- [x] Approver sees the identical surface read-only with the action bar.
|
||||||
|
- [x] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
|
||||||
|
- [x] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
|
||||||
|
- [x] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
|
||||||
|
- [x] Full GREEN + e2e smoke.
|
||||||
|
|
||||||
|
Field notes (landed alongside): the CIBG huisstijl styles bare `<header>`/`<footer>`
|
||||||
|
elements (robijn background), so the canvas regions are `<div>`s — the letter surface
|
||||||
|
must stay letter.css-only. The passage picker's checkboxes now get unique
|
||||||
|
`checkboxId`s (all previously resolved to `id="undefined"`, so labels toggled only
|
||||||
|
the first box — multi-select was broken) and an opaque background; `.letter__body`
|
||||||
|
stacks above the page-break marks so the dashed line never draws through content,
|
||||||
|
while the "±pagina-einde" caption floats above everything for legibility.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
GREEN one-liner; `npm run e2e` (brief flow); manual: `?role=drafter` compose on
|
||||||
|
canvas, `?role=approver` review; `?scenario=slow|error` still degrade gracefully
|
||||||
|
through `<app-async>`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Server-rendered preview + `[NOG IN TE VULLEN]` resolution (WP-25); admin `'template'`
|
||||||
|
mode wiring beyond the input existing (WP-26 gives it a consumer); zoom controls
|
||||||
|
polish + standaardbrief + diff badges (WP-27).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
The canvas duplicates the letter markup that WP-25's backend renderer will emit —
|
||||||
|
acceptable *only* because `letter.css` is shared and WP-25 adds the class-parity
|
||||||
|
test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible.
|
||||||
|
Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete.
|
||||||
90
docs/backlog/WP-25-letter-preview-html.md
Normal file
90
docs/backlog/WP-25-letter-preview-html.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
"What you compose is what is sent" needs a server-side rendering of the letter —
|
||||||
|
placeholders resolved, org template applied — from the same CSS contract the canvas
|
||||||
|
uses (PRD §2b: one rendering, used twice). The preview is the artifact: at send, the
|
||||||
|
same composition is archived with the brief, making sent letters immutable.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- PRD Brief v2 §2b, §8; `docs/backlog/WP-24-letter-canvas.md` (the `letter.css` contract)
|
||||||
|
- `backend/src/BigRegister.Api/Program.cs` — upload `content` endpoint (binary house
|
||||||
|
pattern: `.ExcludeFromDescription()` + hand-written FE fetch)
|
||||||
|
- `src/app/shared/upload/upload.adapter.ts` (hand-written transport precedent)
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
- **HTML, not PDF** (user decision at plan review): no Microsoft.Playwright/Chromium
|
||||||
|
dependency in the POC. `GET /api/v1/brief/preview` returns `text/html` — the fully
|
||||||
|
composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual
|
||||||
|
affordance). The endpoint is the seam where a headless-Chromium PDF render slots
|
||||||
|
in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if
|
||||||
|
the POC ever needs real PDF bytes`.
|
||||||
|
- **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the
|
||||||
|
canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the
|
||||||
|
logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from
|
||||||
|
seed/case data; unresolved manual keys render as `[NOG IN TE VULLEN: label]`
|
||||||
|
(PRD §8) — preview is allowed with errors, only send blocks on them.
|
||||||
|
- **Parity is tested, not hoped for**: a golden-file test snapshots the composed
|
||||||
|
HTML; a second test asserts every `letter`-prefixed class in the golden HTML
|
||||||
|
exists in `letter.css`. `dotnet test` never launches a browser.
|
||||||
|
- **Archive at send**: `Send` stores the composed HTML in `BriefEntity.ArchivedHtml`
|
||||||
|
(SQLite text column) alongside the WP-23 version pin; the preview endpoint serves
|
||||||
|
the archive when status is `sent`, so a republish never changes a sent letter.
|
||||||
|
- **Two endpoints, both excluded from OpenAPI** (JSON-only generated client stays
|
||||||
|
clean): `GET /brief/preview` and `GET /admin/org-template/{subOrgId}/preview`
|
||||||
|
(proefbrief: draft template + a fixture brief). FE consumes them via a small
|
||||||
|
hand-written fetch (needs the `X-Role` header) → blob → object URL in a new tab.
|
||||||
|
- Watermark: previews of unsent letters carry a `VOORBEELD` watermark (CSS), the
|
||||||
|
archived/sent rendering never does — the PRD's open question resolved the simple way.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` (new)
|
||||||
|
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `ArchivedHtml` + archive at send (+migration)
|
||||||
|
- `backend/src/BigRegister.Api/Program.cs` — 2 preview endpoints
|
||||||
|
- `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` (new) + `LetterHtml.golden.html`
|
||||||
|
- `src/app/brief/infrastructure/letter-preview.adapter.ts` (new, fetch → `Result<string, Blob>`)
|
||||||
|
- `src/app/brief/application/brief.store.ts` — `previewLetter()` command
|
||||||
|
- `src/app/brief/ui/letter-composer/*` — "Voorbeeld" button
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. `LetterHtml.Render` + placeholder resolution + data-URI logo + watermark flag.
|
||||||
|
2. Golden-file + class-parity tests.
|
||||||
|
3. Endpoints (serve archive when sent; proefbrief renders the draft template).
|
||||||
|
4. Archive-at-send in `BriefStore.Send` (+ migration for `ArchivedHtml`).
|
||||||
|
5. FE adapter + store command + button (explicit action — no live re-render; PRD §8).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Preview opens the composed print-ready letter in a new tab; browser print
|
||||||
|
shows correct margins via `@page`.
|
||||||
|
- [x] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
|
||||||
|
despite lint errors (only send blocks).
|
||||||
|
- [x] A sent brief serves its archived HTML unchanged after an org-template republish.
|
||||||
|
- [x] Golden + parity tests green without any browser installed.
|
||||||
|
- [x] `swagger.json` unchanged by the two endpoints (drift check green).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`cd backend && dotnet test`; GREEN one-liner; manual: compose → preview → print
|
||||||
|
dialog; send → republish template → preview still the archived rendering.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Real PDF bytes / headless Chromium (the deliberate deferral — the endpoint is the
|
||||||
|
seam). Pixel-parity testing (the shared CSS + class-parity test is the fence).
|
||||||
|
Pagination fidelity beyond the browser's own print engine.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
`LetterHtml` reads `public/letter.css` from disk — path must resolve for `dotnet run`,
|
||||||
|
tests, and docker (bind mount/copy); fail loudly with a clear error if missing.
|
||||||
|
The golden file will churn whenever the letter structure changes — that is its job;
|
||||||
|
update it deliberately, never blindly.
|
||||||
118
docs/backlog/WP-26-org-template-editor.md
Normal file
118
docs/backlog/WP-26-org-template-editor.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# WP-26 — Admin org-template editor
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The org template (WP-23) needs its editor: an admin edits, per sub-organization, the
|
||||||
|
letter's appearance **in place on the same canvas** the drafter composes on — the
|
||||||
|
mirror image (`editableRegions='template'`: letterhead/footer/signature editable,
|
||||||
|
content a read-only sample). PRD Brief v2 §5, §7h.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- PRD Brief v2 §5, §7h; `docs/backlog/WP-23/24/25` (endpoints, canvas, proefbrief)
|
||||||
|
- `src/app/shared/application/access.store.ts` (`can('orgtemplate:edit')`)
|
||||||
|
- `.claude/skills/form-machine` — the house form idiom this editor follows
|
||||||
|
- `src/app/shared/ui/upload/single-upload` (logo upload reuse)
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
- **Lives in the `brief` context** (route `/brief/huisstijl`, lazy) — same bounded
|
||||||
|
capability, no new context. Gated by `AccessStore.can('orgtemplate:edit')` with a
|
||||||
|
denial alert (deny-by-default); no new route guard.
|
||||||
|
- **House form-machine idiom**: `org-template.machine.ts`
|
||||||
|
(`OrgTemplateState`/`OrgTemplateMsg`, pure `reduce` + spec) — draft fields are form
|
||||||
|
state, not brief state. Store (`org-template.store.ts`, root singleton) does
|
||||||
|
debounced draft save (mirror `BriefStore.scheduleSave`), publish, rollback.
|
||||||
|
- **Publish shows impact first**: confirmation displays the WP-23 impact count
|
||||||
|
("Dit raakt N nog niet verzonden brieven") before the POST.
|
||||||
|
- **Version history is a list, rollback copies into draft** (WP-23 semantics) —
|
||||||
|
no side-by-side rendered diff (deferred; field-level history list is enough here).
|
||||||
|
- **Logo upload reuses `single-upload`** against the `org-logo` category; the canvas
|
||||||
|
shows `<img src="/api/v1/uploads/{id}/content">`.
|
||||||
|
- **Proefbrief** = the WP-25 admin preview endpoint; just a button.
|
||||||
|
- Margins are bounded number inputs (server re-validates, WP-23).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `src/app/brief/domain/org-template.machine.ts` (+spec)
|
||||||
|
- `src/app/brief/application/org-template.store.ts`
|
||||||
|
- `src/app/brief/infrastructure/org-template.adapter.ts` (+spec, `parseOrgTemplateAdminView`)
|
||||||
|
- `src/app/brief/ui/org-template-editor/*` (organism + stories)
|
||||||
|
- `src/app/brief/ui/org-template.page.ts`
|
||||||
|
- `src/app/app.routes.ts` (route `brief/huisstijl`)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Machine (fields, `FieldEdited`/`MarginEdited`/`LogoSet`/`DraftLoaded`/save-publish
|
||||||
|
outcome Msgs) + spec.
|
||||||
|
2. Adapter (generated client CRUD + parse boundary) + spec.
|
||||||
|
3. Store: load (sub-org list + selected), debounced save, publish (impact confirm),
|
||||||
|
rollback.
|
||||||
|
4. Editor UI: sub-org switcher, canvas in `'template'` mode with inline-editable
|
||||||
|
regions, margins inputs, logo upload, version history + rollback, proefbrief
|
||||||
|
button, publish bar showing live version + published-at.
|
||||||
|
5. Route + capability gate + stories (axe).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `?role=admin` can switch sub-orgs, edit all template fields in place on the
|
||||||
|
canvas, and see the canvas update live.
|
||||||
|
- [x] Draft saves are debounced; publish asks for confirmation showing the impact
|
||||||
|
count; after publish the drafter's canvas (WP-24) reflects it on reload.
|
||||||
|
- [x] Version history lists published versions (who is faked, when is real);
|
||||||
|
rollback copies an old version into the draft.
|
||||||
|
- [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
|
||||||
|
- [x] Logo upload validates type/size client-side (existing `rejectReason`) and
|
||||||
|
renders on the canvas after upload.
|
||||||
|
- [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
|
||||||
|
- [x] Full GREEN.
|
||||||
|
|
||||||
|
## Deviations / notes (as built)
|
||||||
|
|
||||||
|
- **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*,
|
||||||
|
#hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here
|
||||||
|
to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every
|
||||||
|
one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing
|
||||||
|
`passage-picker` `var(--rhc-color-wit, #fff)` hit.
|
||||||
|
- **Canvas edit-in-place**: `editableRegions='template'` now renders the seven
|
||||||
|
org-identity text fields as inline `<input>`/`<textarea>` controls (aria-labelled)
|
||||||
|
and shows the logo `<img>`; the letter body stays a read-only sample
|
||||||
|
(`SAMPLE_LETTER_BRIEF`). `logoUrl` input added to the canvas and wired through the
|
||||||
|
composer too, so a published logo shows to the drafter (AC2).
|
||||||
|
- **Load-effect loop (caught in the live walk)**: the page's initial-load effect must
|
||||||
|
NOT read the store model — `load()` dispatches `Loading` (a fresh object), which
|
||||||
|
would retrigger a model-reading effect into a runaway loop that saturated the page.
|
||||||
|
Gated on `canEdit()` + a plain `loadRequested` flag instead.
|
||||||
|
- **`AccessStore.ready`** added (tiny): a page-level capability gate needs to tell
|
||||||
|
"still loading `/me`" from "denied" so an admin doesn't flash the denial alert.
|
||||||
|
- **`uploadContentUrl`** pure helper extracted from `UploadAdapter.contentUrl` so
|
||||||
|
`BriefStore` can build a logo `src` without pulling `ApiClient` into its DI graph
|
||||||
|
(kept its spec green).
|
||||||
|
- **Role caching caveat**: `/me` loads once. Open the app with `?role=admin` from the
|
||||||
|
first navigation that touches the editor (the dev role stub, like `?scenario=`);
|
||||||
|
switching role mid-session won't refetch capabilities (out of scope, POC).
|
||||||
|
- **Dirty race**: `DraftSaved` carries the saved draft and clears `dirty` only if it's
|
||||||
|
reference-equal to the current draft, so an edit landing during a save round-trip
|
||||||
|
keeps its pending save.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
GREEN one-liner; manual walk: admin edits footer + margin → canvas live-updates →
|
||||||
|
proefbrief shows draft → publish (impact count) → `?role=drafter` reload shows new
|
||||||
|
appearance; second sub-org unaffected.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Template approval chain (four-eyes on templates — PRD open question, out for POC).
|
||||||
|
Rendered side-by-side version diff. Soft locks. New shared overlay/modal component —
|
||||||
|
the publish confirmation uses the existing inline confirmation pattern, not a dialog.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
The editor is the first consumer of `editableRegions='template'` — WP-24 built the
|
||||||
|
input but nothing exercised it; budget for canvas fixes here. Debounced draft save +
|
||||||
|
publish can race — flush the draft save before publishing (same
|
||||||
|
`clearTimeout`+`flushSave` discipline as `BriefStore.transition`).
|
||||||
93
docs/backlog/WP-27-brief-ux-layer.md
Normal file
93
docs/backlog/WP-27-brief-ux-layer.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges)
|
||||||
|
|
||||||
|
Status: todo
|
||||||
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
PRD Brief v2 §7: the working-day features that make the composer pleasant daily.
|
||||||
|
Several are nearly free **because** state is one immutable value — that's the
|
||||||
|
teaching payload: undo/redo is a shell-side snapshot list, the rejection diff is a
|
||||||
|
pure function over two values. Say so in code comments and stories.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- PRD Brief v2 §7 (and the plan-review trim recorded below)
|
||||||
|
- `src/app/brief/application/brief.store.ts` (autosave + `SaveState` already exist)
|
||||||
|
- `src/app/brief/domain/brief.machine.ts` — the `Seed` Msg (undo/redo's restore path)
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
- **Trim agreed at plan review.** IN: undo/redo, autosave retry affordance,
|
||||||
|
standaardbrief, passage search, canvas zoom controls, Ctrl+Z/Ctrl+Shift+Z,
|
||||||
|
block-level rejection-diff badges. OUT (deferred, one line each in Out of scope):
|
||||||
|
soft lock/takeover, case-context panel, 401 autosave grace, per-user usage counts,
|
||||||
|
shortcut-overlay dialog, inline character-level text diff.
|
||||||
|
- **Undo/redo is shell state, not machine state**: `past`/`future: Brief[]` in
|
||||||
|
`BriefStore` (cap 50; push on `edit()`; clear `future` on a new edit); restore
|
||||||
|
dispatches the **existing `Seed` Msg** — zero machine changes — then `scheduleSave()`.
|
||||||
|
- **Standaardbrief**: backend seeds `IsDefault` on 2–3 kern passages
|
||||||
|
(`LibraryPassageDto` gains the flag); one button, visible only while the kern
|
||||||
|
section is empty, dispatches the existing `PassagesInserted` with the default set —
|
||||||
|
one Msg, one undo step.
|
||||||
|
- **Passage search is a client-side filter** in the picker (label + content match) —
|
||||||
|
the library is small; no server search, no usage tracking.
|
||||||
|
- **Rejection diff**: pure `diffBlocks(before, after): BlockDiff[]` in
|
||||||
|
`domain/brief-diff.ts` (added/removed/changed by `blockId`); the "before" snapshot
|
||||||
|
is captured shell-side when the `Rejected` dispatch happens (POC limit: lost on
|
||||||
|
reload — comment it). Rendered as "gewijzigd sinds afwijzing" badges on the canvas;
|
||||||
|
the approver gets a "Toon wijzigingen" toggle on resubmission.
|
||||||
|
- **Autosave retry**: `SaveState.Error` already exists; add the "Opnieuw proberen"
|
||||||
|
button that calls the existing flush path. No new state.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `src/app/brief/application/brief.store.ts` (+spec: history bounds, clear-on-edit,
|
||||||
|
redo, rejection snapshot)
|
||||||
|
- `src/app/brief/domain/brief-diff.ts` (new, +spec)
|
||||||
|
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`IsDefault` seed) +
|
||||||
|
`Contracts/Dtos.cs` (`LibraryPassageDto`) + gen:api + adapter parse
|
||||||
|
- `src/app/brief/ui/passage-picker/*` (search input)
|
||||||
|
- `src/app/brief/ui/letter-canvas/*` (diff badges, standaardbrief button, zoom controls)
|
||||||
|
- `src/app/brief/ui/brief.page.ts` (undo/redo buttons + keydown listener, retry button)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. `diffBlocks` + spec (added/removed/changed/unchanged; changed = same blockId,
|
||||||
|
different content).
|
||||||
|
2. Store: history + undo/redo + rejection snapshot (+spec).
|
||||||
|
3. Backend `IsDefault` + gen:api + parse.
|
||||||
|
4. UI: standaardbrief button, search, zoom, badges, keyboard, retry.
|
||||||
|
5. Stories for the new states (axe).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Remove a block → Ctrl+Z restores it → Ctrl+Shift+Z re-removes; buttons mirror;
|
||||||
|
history capped at 50; a new edit clears redo; restore re-triggers autosave.
|
||||||
|
- [ ] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
|
||||||
|
step; button gone once kern is non-empty.
|
||||||
|
- [ ] Search filters passages by label and content.
|
||||||
|
- [ ] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
|
||||||
|
removed blocks are badged (block granularity).
|
||||||
|
- [ ] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
|
||||||
|
content never lost locally.
|
||||||
|
- [ ] Full GREEN.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
GREEN one-liner; store + diff specs; manual reject→edit→diff walk with two roles.
|
||||||
|
|
||||||
|
## Out of scope (deferred, per plan review)
|
||||||
|
|
||||||
|
Soft lock/heartbeat/takeover (real session infra, no FP teaching value here).
|
||||||
|
Case-context panel (no case data exists). 401 autosave grace (auth is faked).
|
||||||
|
Per-user passage usage counts (bookkeeping, demos nothing). Shortcut overlay dialog
|
||||||
|
(no modal component exists; not worth building one). Inline character-level diff
|
||||||
|
(block granularity carries the teaching point).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
Undo history holds `Brief` snapshots — deep-frozen immutable values, so sharing is
|
||||||
|
safe, but never push non-content dispatches (status transitions, `Seed` itself) into
|
||||||
|
history or undo will replay workflow state. The rejection snapshot lives in memory
|
||||||
|
only — document it where it's captured.
|
||||||
66
docs/backlog/WP-28-brief-v2-demo-polish.md
Normal file
66
docs/backlog/WP-28-brief-v2-demo-polish.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
|
||||||
|
|
||||||
|
Status: todo
|
||||||
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Phase 6 ships across five WPs; this one makes it demonstrable and closes the loop:
|
||||||
|
a demo script that maps every kept PRD §12 scenario to a URL + click path, an e2e
|
||||||
|
spec covering the new flows end-to-end, story gap-fill, and the docs/README updates
|
||||||
|
that keep CLAUDE.md and the backlog truthful.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- PRD Brief v2 §6 (demo choreography), §12 (scenario list); WP-23..27 as built
|
||||||
|
- `e2e/` (WP-19 conventions); `src/app/shared/infrastructure/scenario.ts`
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
- **No preset registry.** The PRD's 18 scenarios collapse onto the existing toggles:
|
||||||
|
`?role=drafter|approver|admin`, `?scenario=slow|loading|error` (the interceptor
|
||||||
|
already covers all `/api/` calls, the new endpoints included), and
|
||||||
|
`POST /brief/reset`. The demo script documents the mapping; no new interceptor
|
||||||
|
cases, no scenario code.
|
||||||
|
- Demo script lives at `docs/prd/0003-brief-v2-demo-script.md` and follows the §6
|
||||||
|
choreography (compose → preview → switch sub-org seed → "two axes, one render").
|
||||||
|
- One e2e spec, not a suite: drafter composes on canvas → submit → approve → send
|
||||||
|
pins the org-template version; admin publishes → drafter canvas reflects it.
|
||||||
|
Preview assertion is content-type-level (text/html), not pixel.
|
||||||
|
- CLAUDE.md gets the new role value + route only — keep it rules, not narrative.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `docs/prd/0003-brief-v2-demo-script.md` (new)
|
||||||
|
- `e2e/brief-v2.spec.ts` (new)
|
||||||
|
- story gap-fill where WP-24..27 left holes
|
||||||
|
- `docs/backlog/README.md` (statuses), `CLAUDE.md` (roles/routes touch-up)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Demo script: table scenario → URL + clicks, covering every kept §12 entry.
|
||||||
|
2. e2e spec (backend + FE running, WP-19 pattern).
|
||||||
|
3. Story sweep for the new components/states.
|
||||||
|
4. Docs updates.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Every kept PRD §12 scenario has a working URL + click path in the script
|
||||||
|
(walked manually once).
|
||||||
|
- [ ] `npm run e2e` green, including the new spec.
|
||||||
|
- [ ] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
|
||||||
|
`?role=admin` and `/brief/huisstijl`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
|
||||||
|
`npm run e2e`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
New scenario interceptor cases; a scenario-switcher UI; screenshots/video.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
The demo script rots when flows change — it lists URLs + clicks only (no prose
|
||||||
|
walkthroughs), so churn stays cheap.
|
||||||
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.
|
||||||
14775
documentation.json
14775
documentation.json
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
197
public/letter.css
Normal file
197
public/letter.css
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/* letter.css — the FE⇄BE letter-rendering CONTRACT (WP-24/WP-25).
|
||||||
|
*
|
||||||
|
* One stylesheet, two consumers: the FE letter canvas loads it via <link>
|
||||||
|
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25)
|
||||||
|
* inlines this same file. Its class-parity test is the fence against drift.
|
||||||
|
*
|
||||||
|
* Class vocabulary: .letter, .letter__letterhead, .letter__body,
|
||||||
|
* .letter__signature, .letter__footer, .letter__page-break.
|
||||||
|
* Margins arrive as --letter-margin-* custom props (mm, from the OrgTemplate);
|
||||||
|
* the defaults below match the seed template.
|
||||||
|
*
|
||||||
|
* Deliberately self-contained: no --rhc- or --bs- tokens — the backend renderer
|
||||||
|
* has no token bridge. Geometry follows the sample voorbeeldbrief-inschrijving
|
||||||
|
* (A4, return address above the envelope window, reference block, footer rule).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
--letter-margin-top: 25mm;
|
||||||
|
--letter-margin-right: 25mm;
|
||||||
|
--letter-margin-bottom: 25mm;
|
||||||
|
--letter-margin-left: 25mm;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 210mm; /* A4 */
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 297mm;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: var(--letter-margin-top) var(--letter-margin-right) var(--letter-margin-bottom)
|
||||||
|
var(--letter-margin-left);
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
/* Letter typography is the letter's, not the portal UI's. Licensed RO/Rijks
|
||||||
|
fonts are not shipped; system stack mirrors the app-wide decision (ADR-0003). */
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter p {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter ul,
|
||||||
|
.letter ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-inline-start: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Letterhead: wordmark, return address above the envelope window, reference block --- */
|
||||||
|
|
||||||
|
.letter__letterhead {
|
||||||
|
margin-block-end: 12mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .org-wordmark {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .return-address {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: #555;
|
||||||
|
margin-block-end: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Envelope-window position (~C5 venstercouvert): recipient block. */
|
||||||
|
.letter__letterhead .address-window {
|
||||||
|
min-height: 22mm;
|
||||||
|
font-style: normal;
|
||||||
|
white-space: pre-line;
|
||||||
|
margin-block-end: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference {
|
||||||
|
display: flex;
|
||||||
|
gap: 10mm;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dt {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__letterhead .reference dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Body: the case-type template's sections --- */
|
||||||
|
|
||||||
|
.letter__body {
|
||||||
|
flex: 1;
|
||||||
|
/* Above the absolutely-positioned page-break marks: the dashed line stays visible
|
||||||
|
in the gaps but never draws THROUGH opaque content (editors, pickers). */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body h3 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__body section {
|
||||||
|
margin-block-end: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Signature --- */
|
||||||
|
|
||||||
|
.letter__signature {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__signature .signature-name {
|
||||||
|
margin-block-start: 3em; /* room for the (not-shipped) handwritten signature */
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Footer: contact + legal, above a rule --- */
|
||||||
|
|
||||||
|
.letter__footer {
|
||||||
|
margin-block-start: 10mm;
|
||||||
|
padding-block-start: 3mm;
|
||||||
|
border-block-start: 0.5pt solid #999;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-contact {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__footer .footer-legal {
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Approximate page-break indicator (screen only; PRD §2b honesty rule) ---
|
||||||
|
Positioned per A4-interval by the canvas; the print preview is authoritative. */
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
border-block-start: 1px dashed #b36200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break > span {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 2mm;
|
||||||
|
inset-block-start: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
/* Above .letter__body: the honesty caption stays legible even over content. */
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #b36200;
|
||||||
|
background: #fff;
|
||||||
|
padding-inline: 1mm;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Print: real pages, no indicator chrome --- */
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.letter {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter__page-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'brief/huisstijl',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'concepts',
|
path: 'concepts',
|
||||||
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
|
import { Capability } from '@shared/domain/capability';
|
||||||
import { SessionStore } from './application/session.store';
|
import { SessionStore } from './application/session.store';
|
||||||
|
|
||||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||||
@@ -8,3 +10,22 @@ export const authGuard: CanActivateFn = () => {
|
|||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||||
|
* redirect. No route in this app currently needs a capability gate — brief's
|
||||||
|
* canApprove/canReject/canSend are per-action, not per-page (both actors land on
|
||||||
|
* the same `/brief` page and see different actions) — so this exists as the
|
||||||
|
* available building block for the day a route-level gate is needed, e.g. a future
|
||||||
|
* approver-only page.
|
||||||
|
*/
|
||||||
|
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||||
|
return () => {
|
||||||
|
const session = inject(SessionStore);
|
||||||
|
const access = inject(AccessStore);
|
||||||
|
const router = inject(Router);
|
||||||
|
return session.isAuthenticated() && access.can(capability)
|
||||||
|
? true
|
||||||
|
: router.createUrlTree(['/login']);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
146
src/app/brief/application/brief.store.spec.ts
Normal file
146
src/app/brief/application/brief.store.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { Result } from '@shared/kernel/fp';
|
||||||
|
import { Brief, BriefDecisions } from '@brief/domain/brief';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
|
import { BriefStore } from './brief.store';
|
||||||
|
|
||||||
|
const decisions: BriefDecisions = {
|
||||||
|
canEdit: true,
|
||||||
|
canApprove: true,
|
||||||
|
canReject: true,
|
||||||
|
canSend: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const brief: Brief = {
|
||||||
|
briefId: 'b1',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 't1',
|
||||||
|
placeholders: [],
|
||||||
|
sections: [],
|
||||||
|
status: { tag: 'draft' },
|
||||||
|
drafterId: 'u1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgTemplate: OrgTemplate = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG — Registers',
|
||||||
|
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
||||||
|
footerContact: 'info@voorbeeld.example',
|
||||||
|
footerLegal: 'KvK 00000000',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate };
|
||||||
|
|
||||||
|
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||||
|
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||||
|
return TestBed.inject(BriefStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||||
|
it('is Busy synchronously once a transition starts, then Idle on success', async () => {
|
||||||
|
const approved: BriefView = {
|
||||||
|
...view,
|
||||||
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||||
|
};
|
||||||
|
const store = setup({
|
||||||
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
approve: (): Promise<Result<string, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: true, value: approved }),
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
const pending = store.approve();
|
||||||
|
expect(store.busy()).toBe(true); // set synchronously, before any await resolves
|
||||||
|
|
||||||
|
await pending;
|
||||||
|
expect(store.busy()).toBe(false);
|
||||||
|
expect(store.lastError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
||||||
|
const store = setup({
|
||||||
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
approve: (): Promise<Result<string, BriefView>> =>
|
||||||
|
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
await store.approve();
|
||||||
|
expect(store.busy()).toBe(false);
|
||||||
|
expect(store.lastError()).toBe('niet toegestaan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a subsequent successful transition clears a prior Failed state', async () => {
|
||||||
|
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
||||||
|
const store = setup({
|
||||||
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
await store.approve();
|
||||||
|
expect(store.lastError()).toBe('eerste poging mislukt');
|
||||||
|
|
||||||
|
approveResult = {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
...view,
|
||||||
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await store.approve();
|
||||||
|
expect(store.busy()).toBe(false);
|
||||||
|
expect(store.lastError()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BriefStore.previewLetter', () => {
|
||||||
|
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
||||||
|
// the property — window.open/URL.createObjectURL must be restored between tests.
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('opens the composed letter in a new tab on success', async () => {
|
||||||
|
const store = setup({
|
||||||
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
value: blob,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.previewLetter();
|
||||||
|
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
||||||
|
expect(store.lastError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the error without opening a tab on failure', async () => {
|
||||||
|
const store = setup({
|
||||||
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: 'De voorvertoning kon niet worden geopend.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.previewLetter();
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { Role } from '@shared/domain/role';
|
|
||||||
import { currentRole } from '@shared/infrastructure/role';
|
|
||||||
import {
|
import {
|
||||||
Brief,
|
Brief,
|
||||||
allDiagnostics,
|
allDiagnostics,
|
||||||
@@ -11,38 +10,87 @@ import {
|
|||||||
unresolvedPlaceholders,
|
unresolvedPlaceholders,
|
||||||
} from '@brief/domain/brief';
|
} from '@brief/domain/brief';
|
||||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||||
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
|
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
||||||
|
|
||||||
|
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
||||||
|
instead of a busy boolean + a nullable error sitting side by side. */
|
||||||
|
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||||
|
|
||||||
|
/** Debounced-autosave indicator, shown in a small status line near the toolbar —
|
||||||
|
a separate concern from ActionState (a stale autosave error doesn't block
|
||||||
|
submit/approve/reject), but tag-aligned with it for one consistent idiom. */
|
||||||
|
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
||||||
|
|
||||||
|
type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||||
* read-model, and the commands (effects) that call the adapter and dispatch the
|
* read-model, and the commands (effects) that call the adapter and dispatch the
|
||||||
* outcome. Mirrors `BigProfileStore`. All of `editable`, `diagnostics`,
|
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
||||||
* `unresolved`, `canSubmit` are DERIVED here — never stored.
|
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
||||||
|
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
||||||
|
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BriefStore {
|
export class BriefStore {
|
||||||
private adapter = inject(BriefAdapter);
|
private adapter = inject(BriefAdapter);
|
||||||
|
private previewAdapter = inject(LetterPreviewAdapter);
|
||||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||||
|
|
||||||
readonly model = this.store.model;
|
readonly model = this.store.model;
|
||||||
readonly role: Role = currentRole();
|
|
||||||
readonly busy = signal(false);
|
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||||
readonly lastError = signal<string | null>(null);
|
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||||
|
readonly lastError = computed(() => {
|
||||||
|
const s = this.actionState();
|
||||||
|
return s.tag === 'Failed' ? s.error : null;
|
||||||
|
});
|
||||||
|
|
||||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||||
readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
|
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
||||||
|
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
||||||
|
stays untouched by design). Set from every server view that carries it. */
|
||||||
|
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||||
|
|
||||||
|
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
||||||
|
readonly logoUrl = computed<string | null>(() => {
|
||||||
|
const id = this.orgTemplate()?.logoDocumentId;
|
||||||
|
return id ? uploadContentUrl(id) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
||||||
|
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
||||||
|
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||||
|
readonly remoteData = computed<RemoteData<Error | undefined, LoadedBriefState>>(() => {
|
||||||
|
const s = this.model();
|
||||||
|
switch (s.tag) {
|
||||||
|
case 'loading':
|
||||||
|
return { tag: 'Loading' };
|
||||||
|
case 'failed':
|
||||||
|
return { tag: 'Failure', error: new Error(s.reason) };
|
||||||
|
case 'loaded':
|
||||||
|
return { tag: 'Success', value: s };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
private brief = computed<Brief | null>(() => {
|
private brief = computed<Brief | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s.brief : null;
|
return s.tag === 'loaded' ? s.brief : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The single source of truth for edit permission: drafter, and a status the reducer
|
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
||||||
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
|
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
|
||||||
readonly editable = computed(() => {
|
readonly canReject = computed(() => this.decisions()?.canReject ?? false);
|
||||||
const b = this.brief();
|
readonly canSend = computed(() => this.decisions()?.canSend ?? false);
|
||||||
return (
|
|
||||||
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'
|
private decisions = computed(() => {
|
||||||
);
|
const s = this.model();
|
||||||
|
return s.tag === 'loaded' ? s.decisions : null;
|
||||||
});
|
});
|
||||||
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||||
@@ -54,13 +102,12 @@ export class BriefStore {
|
|||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
const r = await this.adapter.load();
|
const r = await this.adapter.load();
|
||||||
if (r.ok)
|
if (r.ok) {
|
||||||
this.store.dispatch({
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
tag: 'BriefLoaded',
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
brief: r.value.brief,
|
} else {
|
||||||
availablePassages: r.value.availablePassages,
|
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||||
});
|
}
|
||||||
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
||||||
@@ -71,7 +118,7 @@ export class BriefStore {
|
|||||||
|
|
||||||
private saveTimer?: ReturnType<typeof setTimeout>;
|
private saveTimer?: ReturnType<typeof setTimeout>;
|
||||||
private scheduleSave() {
|
private scheduleSave() {
|
||||||
if (!this.editable()) return;
|
if (!this.canEdit()) return;
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
||||||
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
||||||
@@ -79,31 +126,29 @@ export class BriefStore {
|
|||||||
private async flushSave() {
|
private async flushSave() {
|
||||||
const b = this.brief();
|
const b = this.brief();
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
this.saveState.set('saving');
|
this.saveState.set({ tag: 'Saving' });
|
||||||
const r = await this.adapter.save(b.sections);
|
const r = await this.adapter.save(b.sections);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.saveState.set('saved');
|
this.saveState.set({ tag: 'Saved' });
|
||||||
} else {
|
} else {
|
||||||
this.lastError.set(r.error);
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
this.saveState.set('error');
|
this.saveState.set({ tag: 'Error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||||
async resetDemo() {
|
async resetDemo() {
|
||||||
this.busy.set(true);
|
this.actionState.set({ tag: 'Busy' });
|
||||||
this.lastError.set(null);
|
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
const r = await this.adapter.reset();
|
const r = await this.adapter.reset();
|
||||||
this.busy.set(false);
|
this.saveState.set({ tag: 'Idle' });
|
||||||
this.saveState.set('idle');
|
if (r.ok) {
|
||||||
if (r.ok)
|
this.actionState.set({ tag: 'Idle' });
|
||||||
this.store.dispatch({
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
tag: 'BriefLoaded',
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
brief: r.value.brief,
|
} else {
|
||||||
availablePassages: r.value.availablePassages,
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
});
|
}
|
||||||
else this.lastError.set(r.error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submit = () => this.transition(() => this.adapter.submit());
|
submit = () => this.transition(() => this.adapter.submit());
|
||||||
@@ -111,30 +156,46 @@ export class BriefStore {
|
|||||||
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
||||||
send = () => this.transition(() => this.adapter.send());
|
send = () => this.transition(() => this.adapter.send());
|
||||||
|
|
||||||
|
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||||
|
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
||||||
|
the tab outlives this call; not worth a teardown hook for a POC. */
|
||||||
|
async previewLetter() {
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
const r = await this.previewAdapter.preview();
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
window.open(URL.createObjectURL(r.value), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||||
// the returned status through the pure reducer's guarded transition.
|
// the returned status through the pure reducer's guarded transition.
|
||||||
private async transition(action: () => Promise<Result<string, Brief>>) {
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||||
this.busy.set(true);
|
this.actionState.set({ tag: 'Busy' });
|
||||||
this.lastError.set(null);
|
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
await this.flushSave();
|
await this.flushSave();
|
||||||
const r = await action();
|
const r = await action();
|
||||||
this.busy.set(false);
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.lastError.set(r.error);
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
this.applyServerStatus(r.value);
|
this.applyServerStatus(r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyServerStatus(brief: Brief) {
|
private applyServerStatus(view: BriefView) {
|
||||||
|
// `send` pins the org-template version server-side — mirror whatever came back.
|
||||||
|
this.orgTemplate.set(view.orgTemplate);
|
||||||
|
const { brief, decisions } = view;
|
||||||
const s = brief.status;
|
const s = brief.status;
|
||||||
switch (s.tag) {
|
switch (s.tag) {
|
||||||
case 'submitted':
|
case 'submitted':
|
||||||
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt });
|
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
|
||||||
break;
|
break;
|
||||||
case 'approved':
|
case 'approved':
|
||||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
|
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
||||||
break;
|
break;
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
this.store.dispatch({
|
this.store.dispatch({
|
||||||
@@ -142,10 +203,11 @@ export class BriefStore {
|
|||||||
by: s.rejectedBy,
|
by: s.rejectedBy,
|
||||||
at: s.rejectedAt,
|
at: s.rejectedAt,
|
||||||
comments: s.comments,
|
comments: s.comments,
|
||||||
|
decisions,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'sent':
|
case 'sent':
|
||||||
this.store.dispatch({ tag: 'Sent', at: s.sentAt });
|
this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
|
||||||
break;
|
break;
|
||||||
case 'draft':
|
case 'draft':
|
||||||
// reopened by a save on a rejected letter — reducer already handled it locally.
|
// reopened by a save on a rejected letter — reducer already handled it locally.
|
||||||
|
|||||||
271
src/app/brief/application/org-template.store.ts
Normal file
271
src/app/brief/application/org-template.store.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||||
|
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
||||||
|
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
||||||
|
import {
|
||||||
|
MARGIN_MAX_MM,
|
||||||
|
MARGIN_MIN_MM,
|
||||||
|
OrgTemplate,
|
||||||
|
SubOrgSummary,
|
||||||
|
} from '@brief/domain/org-template';
|
||||||
|
import {
|
||||||
|
OrgTemplateMsg,
|
||||||
|
OrgTemplateState,
|
||||||
|
initial,
|
||||||
|
reduce,
|
||||||
|
} from '@brief/domain/org-template.machine';
|
||||||
|
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||||
|
|
||||||
|
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
|
||||||
|
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||||
|
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
||||||
|
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||||
|
|
||||||
|
const LOGO_CATEGORY = 'org-logo';
|
||||||
|
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
|
||||||
|
* editable draft; commands here do the debounced save, publish (impact-confirm),
|
||||||
|
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
|
||||||
|
* logo upload reuses the shared upload transport; its completion mutates the draft
|
||||||
|
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class OrgTemplateStore {
|
||||||
|
private adapter = inject(OrgTemplateAdapter);
|
||||||
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
|
private shell = inject(UploadShellService);
|
||||||
|
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
||||||
|
|
||||||
|
readonly model = this.store.model;
|
||||||
|
|
||||||
|
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
||||||
|
readonly selectedSubOrgId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||||
|
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||||
|
readonly lastError = computed(() => {
|
||||||
|
const s = this.actionState();
|
||||||
|
return s.tag === 'Failed' ? s.error : null;
|
||||||
|
});
|
||||||
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
|
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
||||||
|
readonly pendingPublish = signal(false);
|
||||||
|
|
||||||
|
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
|
||||||
|
const s = this.model();
|
||||||
|
switch (s.tag) {
|
||||||
|
case 'loading':
|
||||||
|
return { tag: 'Loading' };
|
||||||
|
case 'failed':
|
||||||
|
return { tag: 'Failure', error: new Error(s.reason) };
|
||||||
|
case 'loaded':
|
||||||
|
return { tag: 'Success', value: s };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private loaded = computed<LoadedState | null>(() => {
|
||||||
|
const s = this.model();
|
||||||
|
return s.tag === 'loaded' ? s : null;
|
||||||
|
});
|
||||||
|
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
||||||
|
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
||||||
|
readonly history = computed(() => this.loaded()?.history ?? []);
|
||||||
|
readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);
|
||||||
|
readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);
|
||||||
|
readonly logoUrl = computed<string | null>(() => {
|
||||||
|
const id = this.draft()?.logoDocumentId;
|
||||||
|
return id ? this.uploadAdapter.contentUrl(id) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
|
||||||
|
the server re-validates and stays the authority — publish is gated on this. */
|
||||||
|
readonly draftValid = computed(() => {
|
||||||
|
const d = this.draft();
|
||||||
|
if (!d) return false;
|
||||||
|
const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(
|
||||||
|
(v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,
|
||||||
|
);
|
||||||
|
return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).
|
||||||
|
private files = new Map<string, File>();
|
||||||
|
private categoriesRes = this.uploadAdapter.categoriesResource('org-template');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Feed the logo category into the machine's upload sub-state once loaded. Tracks
|
||||||
|
// `model()` so it re-fires after a sub-org switch reseeds an empty upload state;
|
||||||
|
// the length guard makes it idempotent (no dispatch loop).
|
||||||
|
effect(() => {
|
||||||
|
const s = this.model();
|
||||||
|
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
|
||||||
|
const status = this.categoriesRes.status();
|
||||||
|
if (status === 'resolved' || status === 'local')
|
||||||
|
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.store.dispatch({ tag: 'Loading' });
|
||||||
|
const list = await this.adapter.list();
|
||||||
|
if (!list.ok) {
|
||||||
|
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.subOrgs.set(list.value);
|
||||||
|
const first = list.value[0];
|
||||||
|
if (!first) {
|
||||||
|
this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.selectSubOrg(first.subOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectSubOrg(subOrgId: string) {
|
||||||
|
this.selectedSubOrgId.set(subOrgId);
|
||||||
|
this.saveState.set({ tag: 'Idle' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
this.store.dispatch({ tag: 'Loading' });
|
||||||
|
const r = await this.adapter.load(subOrgId);
|
||||||
|
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
|
||||||
|
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An in-place canvas or margin edit: apply optimistically, then debounce-save. */
|
||||||
|
edit(msg: OrgTemplateMsg) {
|
||||||
|
this.store.dispatch(msg);
|
||||||
|
this.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private scheduleSave() {
|
||||||
|
if (this.loaded() === null) return;
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
|
||||||
|
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
||||||
|
}
|
||||||
|
private async flushSave() {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s || !s.dirty) return;
|
||||||
|
const { subOrgId, draft } = s;
|
||||||
|
this.saveState.set({ tag: 'Saving' });
|
||||||
|
const r = await this.adapter.save(subOrgId, draft);
|
||||||
|
if (r.ok) {
|
||||||
|
this.saveState.set({ tag: 'Saved' });
|
||||||
|
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
||||||
|
} else {
|
||||||
|
this.saveState.set({ tag: 'Error' });
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- publish (impact-confirm) / rollback / proefbrief ---
|
||||||
|
|
||||||
|
requestPublish() {
|
||||||
|
this.pendingPublish.set(true);
|
||||||
|
}
|
||||||
|
cancelPublish() {
|
||||||
|
this.pendingPublish.set(false);
|
||||||
|
}
|
||||||
|
async confirmPublish() {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s) return;
|
||||||
|
this.pendingPublish.set(false);
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||||
|
const r = await this.adapter.publish(s.subOrgId);
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollback(version: number) {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s) return;
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
||||||
|
}
|
||||||
|
|
||||||
|
async proefbrief() {
|
||||||
|
const s = this.loaded();
|
||||||
|
if (!s) return;
|
||||||
|
this.actionState.set({ tag: 'Busy' });
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
await this.flushSave(); // the proefbrief renders the server's draft
|
||||||
|
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||||
|
if (!r.ok) {
|
||||||
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.actionState.set({ tag: 'Idle' });
|
||||||
|
window.open(URL.createObjectURL(r.value), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
||||||
|
|
||||||
|
onLogoSelected(files: File[]) {
|
||||||
|
const s = this.loaded();
|
||||||
|
const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);
|
||||||
|
const file = files[0];
|
||||||
|
if (!s || !cat || !file) return;
|
||||||
|
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
|
||||||
|
if (reason) {
|
||||||
|
this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const localId = crypto.randomUUID();
|
||||||
|
this.files.set(localId, file);
|
||||||
|
this.dispatchUpload({
|
||||||
|
type: 'FileSelected',
|
||||||
|
categoryId: cat.categoryId,
|
||||||
|
localId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSizeMb: file.size / 1e6,
|
||||||
|
});
|
||||||
|
this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>
|
||||||
|
this.onUploadMsg(m),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogoRemoved(localId: string) {
|
||||||
|
this.shell.cancel([localId]);
|
||||||
|
this.files.delete(localId);
|
||||||
|
this.onUploadMsg({ type: 'UploadRemoved', localId });
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogoRetry(localId: string) {
|
||||||
|
const file = this.files.get(localId);
|
||||||
|
const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);
|
||||||
|
if (!file || !up) return;
|
||||||
|
this.dispatchUpload({ type: 'UploadRetried', localId });
|
||||||
|
this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>
|
||||||
|
this.onUploadMsg(m),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchUpload(msg: UploadMsg) {
|
||||||
|
this.store.dispatch({ tag: 'Upload', msg });
|
||||||
|
}
|
||||||
|
/** Upload effects arriving from the transport: a finished/removed logo edits the
|
||||||
|
draft (in the reducer) and needs persisting. */
|
||||||
|
private onUploadMsg(msg: UploadMsg) {
|
||||||
|
this.dispatchUpload(msg);
|
||||||
|
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { Brief, BriefStatus, LibraryPassage } from './brief';
|
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
||||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
import { PlaceholderDef } from './placeholders';
|
import { PlaceholderDef } from './placeholders';
|
||||||
import { BriefState, BriefMsg, reduce } from './brief.machine';
|
import { BriefState, BriefMsg, reduce } from './brief.machine';
|
||||||
@@ -37,6 +37,15 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A machine test cares about status transitions, not who may act — a fixed,
|
||||||
|
// unrestrictive fixture keeps every existing assertion focused on that.
|
||||||
|
const decisions: BriefDecisions = {
|
||||||
|
canEdit: true,
|
||||||
|
canApprove: true,
|
||||||
|
canReject: true,
|
||||||
|
canSend: true,
|
||||||
|
};
|
||||||
|
|
||||||
const loaded = (
|
const loaded = (
|
||||||
status: BriefStatus = { tag: 'draft' },
|
status: BriefStatus = { tag: 'draft' },
|
||||||
sections?: Brief['sections'],
|
sections?: Brief['sections'],
|
||||||
@@ -44,6 +53,7 @@ const loaded = (
|
|||||||
tag: 'loaded',
|
tag: 'loaded',
|
||||||
brief: briefWith(status, sections),
|
brief: briefWith(status, sections),
|
||||||
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||||
|
decisions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionBlocks = (s: BriefState, key: string) =>
|
const sectionBlocks = (s: BriefState, key: string) =>
|
||||||
@@ -56,6 +66,7 @@ describe('brief.machine reduce', () => {
|
|||||||
tag: 'BriefLoaded',
|
tag: 'BriefLoaded',
|
||||||
brief: briefWith({ tag: 'draft' }),
|
brief: briefWith({ tag: 'draft' }),
|
||||||
availablePassages: [],
|
availablePassages: [],
|
||||||
|
decisions,
|
||||||
}).tag,
|
}).tag,
|
||||||
).toBe('loaded');
|
).toBe('loaded');
|
||||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||||
@@ -175,10 +186,10 @@ describe('brief.machine reduce', () => {
|
|||||||
|
|
||||||
it('Submitted fires only from draft and only when required sections are filled', () => {
|
it('Submitted fires only from draft and only when required sections are filled', () => {
|
||||||
// required 'aanhef' empty → no-op
|
// required 'aanhef' empty → no-op
|
||||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't' })).toEqual(loaded());
|
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
|
||||||
// fill the required section, then submit
|
// fill the required section, then submit
|
||||||
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
||||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
|
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||||
tag: 'submitted',
|
tag: 'submitted',
|
||||||
submittedBy: 'u1',
|
submittedBy: 'u1',
|
||||||
@@ -189,15 +200,21 @@ describe('brief.machine reduce', () => {
|
|||||||
it('approve/reject fire only from submitted; send only from approved', () => {
|
it('approve/reject fire only from submitted; send only from approved', () => {
|
||||||
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||||
// approve from draft is a no-op
|
// approve from draft is a no-op
|
||||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
|
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
|
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||||
tag: 'approved',
|
tag: 'approved',
|
||||||
approvedBy: 'u2',
|
approvedBy: 'u2',
|
||||||
approvedAt: 't2',
|
approvedAt: 't2',
|
||||||
});
|
});
|
||||||
// reject carries comments
|
// reject carries comments
|
||||||
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
|
const rejected = reduce(submitted, {
|
||||||
|
tag: 'Rejected',
|
||||||
|
by: 'u2',
|
||||||
|
at: 't2',
|
||||||
|
comments: 'nee',
|
||||||
|
decisions,
|
||||||
|
});
|
||||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||||
tag: 'rejected',
|
tag: 'rejected',
|
||||||
rejectedBy: 'u2',
|
rejectedBy: 'u2',
|
||||||
@@ -205,10 +222,27 @@ describe('brief.machine reduce', () => {
|
|||||||
comments: 'nee',
|
comments: 'nee',
|
||||||
});
|
});
|
||||||
// send only from approved
|
// send only from approved
|
||||||
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
|
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||||
const sent = reduce(approved, { tag: 'Sent', at: 't3' });
|
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
||||||
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a status transition replaces decisions with the fresh server value', () => {
|
||||||
|
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||||
|
const staleApprover: BriefDecisions = {
|
||||||
|
canEdit: false,
|
||||||
|
canApprove: false,
|
||||||
|
canReject: false,
|
||||||
|
canSend: false,
|
||||||
|
};
|
||||||
|
const approved = reduce(submitted, {
|
||||||
|
tag: 'Approved',
|
||||||
|
by: 'u2',
|
||||||
|
at: 't2',
|
||||||
|
decisions: staleApprover,
|
||||||
|
});
|
||||||
|
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function initialLoading(): BriefState {
|
function initialLoading(): BriefState {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { assertNever } from '@shared/kernel/fp';
|
import { assertNever } from '@shared/kernel/fp';
|
||||||
import {
|
import {
|
||||||
Brief,
|
Brief,
|
||||||
|
BriefDecisions,
|
||||||
BriefStatus,
|
BriefStatus,
|
||||||
LetterBlock,
|
LetterBlock,
|
||||||
LetterSection,
|
LetterSection,
|
||||||
@@ -21,9 +22,11 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
|
|||||||
* it back to `draft`. Sections can never be added, removed, or reordered — there
|
* it back to `draft`. Sections can never be added, removed, or reordered — there
|
||||||
* is no Msg for it, so it is unrepresentable.
|
* is no Msg for it, so it is unrepresentable.
|
||||||
*
|
*
|
||||||
* Role (drafter vs approver) is NOT a reducer concern: the UI derives `editable` from
|
* Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
|
||||||
* role+status and simply doesn't dispatch edits when the actor may not edit. The
|
* canSend) arrives from the server on every load and every status transition (PRD-0002
|
||||||
* reducer guards the status invariant; the UI guards the role invariant.
|
* phase P1) and is carried through unchanged by the reducer — never recomputed here.
|
||||||
|
* The reducer guards the status invariant; the server is the sole authority on who may
|
||||||
|
* act on it.
|
||||||
*
|
*
|
||||||
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
|
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
|
||||||
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
|
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
|
||||||
@@ -33,23 +36,33 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
|
|||||||
|
|
||||||
export type BriefState =
|
export type BriefState =
|
||||||
| { tag: 'loading' }
|
| { tag: 'loading' }
|
||||||
| { tag: 'loaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
|
| {
|
||||||
|
tag: 'loaded';
|
||||||
|
brief: Brief;
|
||||||
|
availablePassages: readonly LibraryPassage[];
|
||||||
|
decisions: BriefDecisions;
|
||||||
|
}
|
||||||
| { tag: 'failed'; reason: string };
|
| { tag: 'failed'; reason: string };
|
||||||
|
|
||||||
export const initial: BriefState = { tag: 'loading' };
|
export const initial: BriefState = { tag: 'loading' };
|
||||||
|
|
||||||
export type BriefMsg =
|
export type BriefMsg =
|
||||||
| { tag: 'BriefLoaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
|
| {
|
||||||
|
tag: 'BriefLoaded';
|
||||||
|
brief: Brief;
|
||||||
|
availablePassages: readonly LibraryPassage[];
|
||||||
|
decisions: BriefDecisions;
|
||||||
|
}
|
||||||
| { tag: 'BriefLoadFailed'; reason: string }
|
| { tag: 'BriefLoadFailed'; reason: string }
|
||||||
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
|
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
|
||||||
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
||||||
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
||||||
| { tag: 'BlockRemoved'; blockId: string }
|
| { tag: 'BlockRemoved'; blockId: string }
|
||||||
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
|
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
|
||||||
| { tag: 'Submitted'; by: string; at: string } // draft → submitted
|
| { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
|
||||||
| { tag: 'Approved'; by: string; at: string } // submitted → approved
|
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
||||||
| { tag: 'Rejected'; by: string; at: string; comments: string } // submitted → rejected
|
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
||||||
| { tag: 'Sent'; at: string } // approved → sent
|
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
||||||
| { tag: 'Seed'; state: BriefState };
|
| { tag: 'Seed'; state: BriefState };
|
||||||
|
|
||||||
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
||||||
@@ -158,7 +171,12 @@ function moveWithinSection(
|
|||||||
export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||||
switch (m.tag) {
|
switch (m.tag) {
|
||||||
case 'BriefLoaded':
|
case 'BriefLoaded':
|
||||||
return { tag: 'loaded', brief: m.brief, availablePassages: m.availablePassages };
|
return {
|
||||||
|
tag: 'loaded',
|
||||||
|
brief: m.brief,
|
||||||
|
availablePassages: m.availablePassages,
|
||||||
|
decisions: m.decisions,
|
||||||
|
};
|
||||||
case 'BriefLoadFailed':
|
case 'BriefLoadFailed':
|
||||||
return { tag: 'failed', reason: m.reason };
|
return { tag: 'failed', reason: m.reason };
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
@@ -203,36 +221,41 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
s,
|
s,
|
||||||
'draft',
|
'draft',
|
||||||
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
|
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
|
||||||
|
m.decisions,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
);
|
);
|
||||||
case 'Approved':
|
case 'Approved':
|
||||||
return transition(s, 'submitted', () => ({
|
return transition(
|
||||||
tag: 'approved',
|
s,
|
||||||
approvedBy: m.by,
|
'submitted',
|
||||||
approvedAt: m.at,
|
() => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
|
||||||
}));
|
m.decisions,
|
||||||
|
);
|
||||||
case 'Rejected':
|
case 'Rejected':
|
||||||
return transition(s, 'submitted', () => ({
|
return transition(
|
||||||
tag: 'rejected',
|
s,
|
||||||
rejectedBy: m.by,
|
'submitted',
|
||||||
rejectedAt: m.at,
|
() => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
|
||||||
comments: m.comments,
|
m.decisions,
|
||||||
}));
|
);
|
||||||
case 'Sent':
|
case 'Sent':
|
||||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));
|
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return assertNever(m);
|
return assertNever(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A guarded status transition: only fires from `from`, and only if `guard` passes. */
|
/** A guarded status transition: only fires from `from`, and only if `guard` passes.
|
||||||
|
`decisions` replaces the prior server-computed flags — always fresh from the
|
||||||
|
same response that carried the new status. */
|
||||||
function transition(
|
function transition(
|
||||||
s: BriefState,
|
s: BriefState,
|
||||||
from: BriefStatus['tag'],
|
from: BriefStatus['tag'],
|
||||||
next: () => BriefStatus,
|
next: () => BriefStatus,
|
||||||
|
decisions: BriefDecisions,
|
||||||
guard: (b: Brief) => boolean = () => true,
|
guard: (b: Brief) => boolean = () => true,
|
||||||
): BriefState {
|
): BriefState {
|
||||||
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||||
return { ...s, brief: { ...s.brief, status: next() } };
|
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,3 +107,12 @@ export function unresolvedPlaceholders(brief: Brief): string[] {
|
|||||||
export function canSubmit(brief: Brief): boolean {
|
export function canSubmit(brief: Brief): boolean {
|
||||||
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server-computed decision flags for the acting principal + this brief's live
|
||||||
|
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
||||||
|
export interface BriefDecisions {
|
||||||
|
readonly canEdit: boolean;
|
||||||
|
readonly canApprove: boolean;
|
||||||
|
readonly canReject: boolean;
|
||||||
|
readonly canSend: boolean;
|
||||||
|
}
|
||||||
|
|||||||
135
src/app/brief/domain/org-template.machine.spec.ts
Normal file
135
src/app/brief/domain/org-template.machine.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
||||||
|
import { OrgTemplateState, reduce } from './org-template.machine';
|
||||||
|
import { DocumentCategory } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
|
const template: OrgTemplate = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG',
|
||||||
|
returnAddress: 'Postbus 1\n2500 AA Den Haag',
|
||||||
|
footerContact: 'info@cibg.nl',
|
||||||
|
footerLegal: 'CIBG is onderdeel van VWS',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
|
||||||
|
draft: template,
|
||||||
|
publishedVersion: 3,
|
||||||
|
history: [],
|
||||||
|
unsentBriefs: 2,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||||
|
|
||||||
|
const logoCategory: DocumentCategory = {
|
||||||
|
categoryId: 'org-logo',
|
||||||
|
label: 'Logo',
|
||||||
|
description: '',
|
||||||
|
required: false,
|
||||||
|
acceptedTypes: ['image/png'],
|
||||||
|
maxSizeMb: 2,
|
||||||
|
multiple: false,
|
||||||
|
allowPostDelivery: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('org-template.machine', () => {
|
||||||
|
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
||||||
|
const s = loaded();
|
||||||
|
expect(s.tag).toBe('loaded');
|
||||||
|
if (s.tag !== 'loaded') return;
|
||||||
|
expect(s.draft.orgName).toBe('CIBG');
|
||||||
|
expect(s.subOrgId).toBe('cibg-registers');
|
||||||
|
expect(s.unsentBriefs).toBe(2);
|
||||||
|
expect(s.dirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LoadFailed carries the reason', () => {
|
||||||
|
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||||
|
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FieldEdited edits the draft and marks dirty', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
|
||||||
|
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MarginEdited edits one edge and marks dirty', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
|
||||||
|
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
|
||||||
|
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||||
|
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||||
|
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
|
||||||
|
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(false);
|
||||||
|
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||||
|
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||||
|
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
|
||||||
|
// a further edit changes the draft reference before the save resolves
|
||||||
|
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
||||||
|
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
|
||||||
|
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits are no-ops in non-loaded states', () => {
|
||||||
|
expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({
|
||||||
|
tag: 'loading',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a completed logo upload sets logoDocumentId + dirty', () => {
|
||||||
|
const withCat = reduce(loaded(), {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||||
|
});
|
||||||
|
const selected = reduce(withCat, {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 },
|
||||||
|
});
|
||||||
|
const done = reduce(selected, {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||||
|
});
|
||||||
|
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
|
||||||
|
expect(done.tag === 'loaded' && done.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing the logo clears logoDocumentId + dirty', () => {
|
||||||
|
const withLogo = reduce(loaded(), {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||||
|
});
|
||||||
|
const removed = reduce(withLogo, {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||||
|
});
|
||||||
|
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
|
||||||
|
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
|
||||||
|
const withCat = reduce(loaded(), {
|
||||||
|
tag: 'Upload',
|
||||||
|
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||||
|
});
|
||||||
|
const switched = reduce(withCat, {
|
||||||
|
tag: 'DraftLoaded',
|
||||||
|
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||||
|
});
|
||||||
|
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
|
||||||
|
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
|
||||||
|
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
||||||
|
});
|
||||||
|
});
|
||||||
100
src/app/brief/domain/org-template.machine.ts
Normal file
100
src/app/brief/domain/org-template.machine.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { assertNever } from '@shared/kernel/fp';
|
||||||
|
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
||||||
|
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
|
||||||
|
* the same idiom as the wizards. The DRAFT org template is form state (edited in
|
||||||
|
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
|
||||||
|
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
||||||
|
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||||
|
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The org-identity text fields editable directly on the letter canvas. */
|
||||||
|
export type OrgTemplateTextField =
|
||||||
|
| 'orgName'
|
||||||
|
| 'returnAddress'
|
||||||
|
| 'footerContact'
|
||||||
|
| 'footerLegal'
|
||||||
|
| 'signatureName'
|
||||||
|
| 'signatureRole'
|
||||||
|
| 'signatureClosing';
|
||||||
|
|
||||||
|
export type OrgTemplateState =
|
||||||
|
| { tag: 'loading' }
|
||||||
|
| { tag: 'failed'; reason: string }
|
||||||
|
| {
|
||||||
|
tag: 'loaded';
|
||||||
|
subOrgId: string;
|
||||||
|
draft: OrgTemplate;
|
||||||
|
publishedVersion: number;
|
||||||
|
history: readonly OrgTemplateVersion[];
|
||||||
|
unsentBriefs: number;
|
||||||
|
dirty: boolean;
|
||||||
|
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||||
|
upload: UploadState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initial: OrgTemplateState = { tag: 'loading' };
|
||||||
|
|
||||||
|
export type OrgTemplateMsg =
|
||||||
|
| { tag: 'Loading' }
|
||||||
|
| { tag: 'DraftLoaded'; view: OrgTemplateAdminView }
|
||||||
|
| { tag: 'LoadFailed'; reason: string }
|
||||||
|
| { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string }
|
||||||
|
| { tag: 'MarginEdited'; edge: keyof Margins; value: number }
|
||||||
|
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
||||||
|
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
||||||
|
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
||||||
|
| { tag: 'Upload'; msg: UploadMsg };
|
||||||
|
|
||||||
|
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
||||||
|
function editDraft(
|
||||||
|
s: OrgTemplateState,
|
||||||
|
f: (draft: OrgTemplate) => OrgTemplate,
|
||||||
|
): OrgTemplateState {
|
||||||
|
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||||
|
switch (m.tag) {
|
||||||
|
case 'Loading':
|
||||||
|
return { tag: 'loading' };
|
||||||
|
case 'LoadFailed':
|
||||||
|
return { tag: 'failed', reason: m.reason };
|
||||||
|
case 'DraftLoaded':
|
||||||
|
return {
|
||||||
|
tag: 'loaded',
|
||||||
|
subOrgId: m.view.draft.subOrgId,
|
||||||
|
draft: m.view.draft,
|
||||||
|
publishedVersion: m.view.publishedVersion,
|
||||||
|
history: m.view.history,
|
||||||
|
unsentBriefs: m.view.unsentBriefs,
|
||||||
|
dirty: false,
|
||||||
|
// Keep the loaded logo category across sub-org switches (it's the same
|
||||||
|
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
||||||
|
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||||
|
};
|
||||||
|
case 'FieldEdited':
|
||||||
|
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||||
|
case 'MarginEdited':
|
||||||
|
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||||
|
case 'DraftSaved':
|
||||||
|
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||||
|
case 'Upload': {
|
||||||
|
if (s.tag !== 'loaded') return s;
|
||||||
|
const upload = reduceUpload(s.upload, m.msg);
|
||||||
|
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||||
|
if (m.msg.type === 'UploadComplete')
|
||||||
|
return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true };
|
||||||
|
if (m.msg.type === 'UploadRemoved') {
|
||||||
|
const { logoDocumentId: _dropped, ...rest } = s.draft;
|
||||||
|
return { ...s, upload, draft: rest, dirty: true };
|
||||||
|
}
|
||||||
|
return { ...s, upload };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return assertNever(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/brief/domain/org-template.ts
Normal file
67
src/app/brief/domain/org-template.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
|
||||||
|
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
|
||||||
|
* Orthogonal to the case-type template (sections + placeholders); the two only meet
|
||||||
|
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
|
||||||
|
* never edits it here (the admin editor is WP-26).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Margins {
|
||||||
|
readonly topMm: number;
|
||||||
|
readonly rightMm: number;
|
||||||
|
readonly bottomMm: number;
|
||||||
|
readonly leftMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgTemplate {
|
||||||
|
readonly subOrgId: string;
|
||||||
|
readonly orgName: string;
|
||||||
|
/** Multiline; rendered above the envelope window. */
|
||||||
|
readonly returnAddress: string;
|
||||||
|
readonly logoDocumentId?: string;
|
||||||
|
/** Multiline contact block in the footer. */
|
||||||
|
readonly footerContact: string;
|
||||||
|
readonly footerLegal: string;
|
||||||
|
readonly signatureName: string;
|
||||||
|
readonly signatureRole: string;
|
||||||
|
readonly signatureClosing: string;
|
||||||
|
readonly margins: Margins;
|
||||||
|
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
|
||||||
|
readonly version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin editor (WP-26) ---
|
||||||
|
|
||||||
|
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
|
||||||
|
export interface OrgTemplateVersion {
|
||||||
|
readonly version: number;
|
||||||
|
readonly publishedAt: string;
|
||||||
|
readonly template: OrgTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */
|
||||||
|
export interface OrgTemplateAdminView {
|
||||||
|
readonly draft: OrgTemplate;
|
||||||
|
readonly publishedVersion: number;
|
||||||
|
readonly history: readonly OrgTemplateVersion[];
|
||||||
|
/** How many not-yet-sent letters a publish would re-render (the impact count). */
|
||||||
|
readonly unsentBriefs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in the sub-org switcher. */
|
||||||
|
export interface SubOrgSummary {
|
||||||
|
readonly subOrgId: string;
|
||||||
|
readonly orgName: string;
|
||||||
|
readonly publishedVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish outcome: the new version and how many unsent letters it touched. */
|
||||||
|
export interface PublishResult {
|
||||||
|
readonly version: number;
|
||||||
|
readonly affectedUnsentBriefs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant
|
||||||
|
feedback via `<input min max>`; the server re-validates and stays the authority. */
|
||||||
|
export const MARGIN_MIN_MM = 10;
|
||||||
|
export const MARGIN_MAX_MM = 50;
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
||||||
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter';
|
import {
|
||||||
|
parseBrief,
|
||||||
|
parseBriefView,
|
||||||
|
parseNode,
|
||||||
|
parseOrgTemplate,
|
||||||
|
parseStatus,
|
||||||
|
} from './brief.adapter';
|
||||||
|
|
||||||
const view: BriefViewDto = {
|
const view: BriefViewDto = {
|
||||||
brief: {
|
brief: {
|
||||||
@@ -46,6 +52,19 @@ const view: BriefViewDto = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
||||||
|
orgTemplate: {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG — Registers',
|
||||||
|
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||||
|
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||||
|
footerLegal: 'KvK 00000000',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('brief.adapter parse boundary', () => {
|
describe('brief.adapter parse boundary', () => {
|
||||||
@@ -67,6 +86,39 @@ describe('brief.adapter parse boundary', () => {
|
|||||||
autoResolvable: true,
|
autoResolvable: true,
|
||||||
fillable: false,
|
fillable: false,
|
||||||
});
|
});
|
||||||
|
expect(r.value.decisions).toEqual({
|
||||||
|
canEdit: false,
|
||||||
|
canApprove: true,
|
||||||
|
canReject: true,
|
||||||
|
canSend: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the org template and drops a null logoDocumentId', () => {
|
||||||
|
const r = parseOrgTemplate(view.orgTemplate);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.value.orgName).toBe('CIBG — Registers');
|
||||||
|
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
|
||||||
|
expect('logoDocumentId' in r.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a view whose org template is missing or malformed', () => {
|
||||||
|
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
|
||||||
|
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseOrgTemplate({
|
||||||
|
...view.orgTemplate,
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
|
||||||
|
}).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a view whose decisions are missing or malformed', () => {
|
||||||
|
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('narrows node variants and rejects unknown ones', () => {
|
it('narrows node variants and rejects unknown ones', () => {
|
||||||
@@ -127,6 +179,14 @@ describe('brief.adapter parse boundary', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a library passage with an unknown scope', () => {
|
||||||
|
const r = parseBriefView({
|
||||||
|
...view,
|
||||||
|
availablePassages: [{ ...view.availablePassages![0], scope: 'bogus' as never }],
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a passage block missing provenance', () => {
|
it('rejects a passage block missing provenance', () => {
|
||||||
const r = parseBrief({
|
const r = parseBrief({
|
||||||
...view.brief,
|
...view.brief,
|
||||||
|
|||||||
@@ -3,24 +3,27 @@ import { Result, ok, err } from '@shared/kernel/fp';
|
|||||||
import { runSubmit } from '@shared/application/submit';
|
import { runSubmit } from '@shared/application/submit';
|
||||||
import {
|
import {
|
||||||
ApiClient,
|
ApiClient,
|
||||||
|
BriefDecisionsDto,
|
||||||
BriefDto,
|
BriefDto,
|
||||||
BriefStatusDto,
|
BriefStatusDto,
|
||||||
BriefViewDto,
|
BriefViewDto,
|
||||||
LetterBlockDto,
|
LetterBlockDto,
|
||||||
LetterSectionDto,
|
LetterSectionDto,
|
||||||
LibraryPassageDto,
|
LibraryPassageDto,
|
||||||
|
OrgTemplateDto,
|
||||||
PlaceholderDefDto,
|
PlaceholderDefDto,
|
||||||
RichTextBlockDto,
|
RichTextBlockDto,
|
||||||
RichTextNodeDto,
|
RichTextNodeDto,
|
||||||
} from '@shared/infrastructure/api-client';
|
} from '@shared/infrastructure/api-client';
|
||||||
import {
|
import {
|
||||||
Brief,
|
Brief,
|
||||||
|
BriefDecisions,
|
||||||
BriefStatus,
|
BriefStatus,
|
||||||
LetterBlock,
|
LetterBlock,
|
||||||
LetterSection,
|
LetterSection,
|
||||||
LibraryPassage,
|
LibraryPassage,
|
||||||
PassageScope,
|
|
||||||
} from '@brief/domain/brief';
|
} from '@brief/domain/brief';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { PlaceholderDef } from '@brief/domain/placeholders';
|
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
@@ -35,6 +38,8 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
|
|||||||
export interface BriefView {
|
export interface BriefView {
|
||||||
readonly brief: Brief;
|
readonly brief: Brief;
|
||||||
readonly availablePassages: LibraryPassage[];
|
readonly availablePassages: LibraryPassage[];
|
||||||
|
readonly decisions: BriefDecisions;
|
||||||
|
readonly orgTemplate: OrgTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||||
@@ -49,32 +54,32 @@ export class BriefAdapter {
|
|||||||
return r.ok ? parseBriefView(r.value) : r;
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
|
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
|
||||||
const r = await runSubmit(
|
const r = await runSubmit(
|
||||||
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
|
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
|
||||||
BRIEF_ACTION_FAILED,
|
BRIEF_ACTION_FAILED,
|
||||||
);
|
);
|
||||||
return r.ok ? parseBrief(r.value) : r;
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit(): Promise<Result<string, Brief>> {
|
async submit(): Promise<Result<string, BriefView>> {
|
||||||
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
|
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
|
||||||
return r.ok ? parseBrief(r.value) : r;
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approve(): Promise<Result<string, Brief>> {
|
async approve(): Promise<Result<string, BriefView>> {
|
||||||
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
|
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
|
||||||
return r.ok ? parseBrief(r.value) : r;
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reject(comments: string): Promise<Result<string, Brief>> {
|
async reject(comments: string): Promise<Result<string, BriefView>> {
|
||||||
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
|
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
|
||||||
return r.ok ? parseBrief(r.value) : r;
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(): Promise<Result<string, Brief>> {
|
async send(): Promise<Result<string, BriefView>> {
|
||||||
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
||||||
return r.ok ? parseBrief(r.value) : r;
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
|
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
|
||||||
@@ -225,8 +230,9 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
||||||
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
|
if (typeof dto.passageId !== 'string') return err('passage: bad shape');
|
||||||
return err('passage: bad shape');
|
if (dto.scope !== 'global' && dto.scope !== 'beroep')
|
||||||
|
return err(`passage: unknown scope ${dto.scope}`);
|
||||||
if (
|
if (
|
||||||
typeof dto.sectionKey !== 'string' ||
|
typeof dto.sectionKey !== 'string' ||
|
||||||
typeof dto.label !== 'string' ||
|
typeof dto.label !== 'string' ||
|
||||||
@@ -237,7 +243,7 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
|||||||
if (!content.ok) return content;
|
if (!content.ok) return content;
|
||||||
return ok({
|
return ok({
|
||||||
passageId: dto.passageId,
|
passageId: dto.passageId,
|
||||||
scope: dto.scope as PassageScope,
|
scope: dto.scope,
|
||||||
sectionKey: dto.sectionKey,
|
sectionKey: dto.sectionKey,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
content: content.value,
|
content: content.value,
|
||||||
@@ -281,17 +287,81 @@ export function parseBrief(dto: BriefDto): Result<string, Brief> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, BriefDecisions> {
|
||||||
|
if (
|
||||||
|
typeof dto?.canEdit !== 'boolean' ||
|
||||||
|
typeof dto.canApprove !== 'boolean' ||
|
||||||
|
typeof dto.canReject !== 'boolean' ||
|
||||||
|
typeof dto.canSend !== 'boolean'
|
||||||
|
) {
|
||||||
|
return err('brief-view: missing/invalid decisions');
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
canEdit: dto.canEdit,
|
||||||
|
canApprove: dto.canApprove,
|
||||||
|
canReject: dto.canReject,
|
||||||
|
canSend: dto.canSend,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrgTemplate(dto: OrgTemplateDto | undefined): Result<string, OrgTemplate> {
|
||||||
|
if (
|
||||||
|
typeof dto?.subOrgId !== 'string' ||
|
||||||
|
typeof dto.orgName !== 'string' ||
|
||||||
|
typeof dto.returnAddress !== 'string' ||
|
||||||
|
typeof dto.footerContact !== 'string' ||
|
||||||
|
typeof dto.footerLegal !== 'string' ||
|
||||||
|
typeof dto.signatureName !== 'string' ||
|
||||||
|
typeof dto.signatureRole !== 'string' ||
|
||||||
|
typeof dto.signatureClosing !== 'string' ||
|
||||||
|
typeof dto.version !== 'number'
|
||||||
|
) {
|
||||||
|
return err('org-template: bad shape');
|
||||||
|
}
|
||||||
|
const m = dto.margins;
|
||||||
|
if (
|
||||||
|
typeof m?.topMm !== 'number' ||
|
||||||
|
typeof m.rightMm !== 'number' ||
|
||||||
|
typeof m.bottomMm !== 'number' ||
|
||||||
|
typeof m.leftMm !== 'number'
|
||||||
|
) {
|
||||||
|
return err('org-template: bad margins');
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
subOrgId: dto.subOrgId,
|
||||||
|
orgName: dto.orgName,
|
||||||
|
returnAddress: dto.returnAddress,
|
||||||
|
...(dto.logoDocumentId != null ? { logoDocumentId: dto.logoDocumentId } : {}),
|
||||||
|
footerContact: dto.footerContact,
|
||||||
|
footerLegal: dto.footerLegal,
|
||||||
|
signatureName: dto.signatureName,
|
||||||
|
signatureRole: dto.signatureRole,
|
||||||
|
signatureClosing: dto.signatureClosing,
|
||||||
|
margins: { topMm: m.topMm, rightMm: m.rightMm, bottomMm: m.bottomMm, leftMm: m.leftMm },
|
||||||
|
version: dto.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||||
if (!dto.brief) return err('brief-view: missing brief');
|
if (!dto.brief) return err('brief-view: missing brief');
|
||||||
const brief = parseBrief(dto.brief);
|
const brief = parseBrief(dto.brief);
|
||||||
if (!brief.ok) return brief;
|
if (!brief.ok) return brief;
|
||||||
|
const decisions = parseDecisions(dto.decisions);
|
||||||
|
if (!decisions.ok) return decisions;
|
||||||
|
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
||||||
|
if (!orgTemplate.ok) return orgTemplate;
|
||||||
const availablePassages: LibraryPassage[] = [];
|
const availablePassages: LibraryPassage[] = [];
|
||||||
for (const p of dto.availablePassages ?? []) {
|
for (const p of dto.availablePassages ?? []) {
|
||||||
const parsed = parsePassage(p);
|
const parsed = parsePassage(p);
|
||||||
if (!parsed.ok) return parsed;
|
if (!parsed.ok) return parsed;
|
||||||
availablePassages.push(parsed.value);
|
availablePassages.push(parsed.value);
|
||||||
}
|
}
|
||||||
return ok({ brief: brief.value, availablePassages });
|
return ok({
|
||||||
|
brief: brief.value,
|
||||||
|
availablePassages,
|
||||||
|
decisions: decisions.value,
|
||||||
|
orgTemplate: orgTemplate.value,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
||||||
|
|||||||
37
src/app/brief/infrastructure/letter-preview.adapter.ts
Normal file
37
src/app/brief/infrastructure/letter-preview.adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
||||||
|
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
|
||||||
|
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||||
|
* `roleInterceptor`, so `X-Role` is set here explicitly.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LetterPreviewAdapter {
|
||||||
|
async preview(): Promise<Result<string, Blob>> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||||
|
headers: { 'X-Role': currentRole() },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return err(PREVIEW_FAILED);
|
||||||
|
}
|
||||||
|
if (!res.ok) return err(await errorMessage(res));
|
||||||
|
return ok(await res.blob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function errorMessage(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||||
|
} catch {
|
||||||
|
return PREVIEW_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/brief/infrastructure/org-template.adapter.spec.ts
Normal file
54
src/app/brief/infrastructure/org-template.adapter.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
||||||
|
import { parseOrgTemplateAdminView } from './org-template.adapter';
|
||||||
|
|
||||||
|
const draft: OrgTemplateDto = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG',
|
||||||
|
returnAddress: 'Postbus 1',
|
||||||
|
footerContact: 'info@cibg.nl',
|
||||||
|
footerLegal: 'onderdeel van VWS',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const view: OrgTemplateAdminViewDto = {
|
||||||
|
draft,
|
||||||
|
publishedVersion: 3,
|
||||||
|
unsentBriefs: 2,
|
||||||
|
history: [{ version: 2, publishedAt: '2026-06-01', template: draft }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseOrgTemplateAdminView', () => {
|
||||||
|
it('parses a well-formed admin view', () => {
|
||||||
|
const r = parseOrgTemplateAdminView(view);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.value.draft.orgName).toBe('CIBG');
|
||||||
|
expect(r.value.publishedVersion).toBe(3);
|
||||||
|
expect(r.value.unsentBriefs).toBe(2);
|
||||||
|
expect(r.value.history).toHaveLength(1);
|
||||||
|
expect(r.value.history[0].version).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing draft', () => {
|
||||||
|
const r = parseOrgTemplateAdminView({ ...view, draft: undefined });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing count field', () => {
|
||||||
|
const r = parseOrgTemplateAdminView({ ...view, unsentBriefs: undefined });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed history entry', () => {
|
||||||
|
const r = parseOrgTemplateAdminView({
|
||||||
|
...view,
|
||||||
|
history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }],
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
src/app/brief/infrastructure/org-template.adapter.ts
Normal file
163
src/app/brief/infrastructure/org-template.adapter.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import { runSubmit } from '@shared/application/submit';
|
||||||
|
import { currentRole } from '@shared/infrastructure/role';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
ApiClient,
|
||||||
|
OrgTemplateAdminViewDto,
|
||||||
|
OrgTemplateDto,
|
||||||
|
OrgTemplateVersionDto,
|
||||||
|
PublishOrgTemplateResponse,
|
||||||
|
SubOrgSummaryDto,
|
||||||
|
} from '@shared/infrastructure/api-client';
|
||||||
|
import {
|
||||||
|
OrgTemplate,
|
||||||
|
OrgTemplateAdminView,
|
||||||
|
OrgTemplateVersion,
|
||||||
|
PublishResult,
|
||||||
|
SubOrgSummary,
|
||||||
|
} from '@brief/domain/org-template';
|
||||||
|
import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/
|
||||||
|
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
||||||
|
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
||||||
|
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||||
|
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class OrgTemplateAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
|
async list(): Promise<Result<string, SubOrgSummary[]>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
const out: SubOrgSummary[] = [];
|
||||||
|
for (const s of r.value ?? []) {
|
||||||
|
const parsed = parseSubOrg(s);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
out.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
|
||||||
|
return r.ok ? parseAdminView(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
|
||||||
|
const r = await runSubmit(
|
||||||
|
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
|
||||||
|
FAILED,
|
||||||
|
);
|
||||||
|
return r.ok ? parseAdminView(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
|
||||||
|
return r.ok ? parsePublish(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollback(
|
||||||
|
subOrgId: string,
|
||||||
|
version: number,
|
||||||
|
): Promise<Result<string, OrgTemplateAdminView>> {
|
||||||
|
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
|
||||||
|
return r.ok ? parseAdminView(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */
|
||||||
|
async proefbrief(subOrgId: string): Promise<Result<string, Blob>> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(
|
||||||
|
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
||||||
|
{ headers: { 'X-Role': currentRole() } },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return err(PROEFBRIEF_FAILED);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
try {
|
||||||
|
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
|
||||||
|
} catch {
|
||||||
|
return err(PROEFBRIEF_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok(await res.blob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse: wire → domain, validating at the boundary ---
|
||||||
|
|
||||||
|
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
||||||
|
if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')
|
||||||
|
return err('sub-org: bad shape');
|
||||||
|
return ok({
|
||||||
|
subOrgId: dto.subOrgId,
|
||||||
|
orgName: dto.orgName,
|
||||||
|
publishedVersion: dto.publishedVersion ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(dto: OrgTemplateVersionDto): Result<string, OrgTemplateVersion> {
|
||||||
|
if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')
|
||||||
|
return err('version: bad shape');
|
||||||
|
const template = parseOrgTemplate(dto.template);
|
||||||
|
if (!template.ok) return template;
|
||||||
|
return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrgTemplateAdminView(
|
||||||
|
dto: OrgTemplateAdminViewDto,
|
||||||
|
): Result<string, OrgTemplateAdminView> {
|
||||||
|
const draft = parseOrgTemplate(dto.draft);
|
||||||
|
if (!draft.ok) return draft;
|
||||||
|
if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')
|
||||||
|
return err('admin-view: bad shape');
|
||||||
|
const history: OrgTemplateVersion[] = [];
|
||||||
|
for (const v of dto.history ?? []) {
|
||||||
|
const parsed = parseVersion(v);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
history.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
draft: draft.value,
|
||||||
|
publishedVersion: dto.publishedVersion,
|
||||||
|
history,
|
||||||
|
unsentBriefs: dto.unsentBriefs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAdminView = parseOrgTemplateAdminView;
|
||||||
|
|
||||||
|
function parsePublish(dto: PublishOrgTemplateResponse): Result<string, PublishResult> {
|
||||||
|
if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')
|
||||||
|
return err('publish: bad shape');
|
||||||
|
return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- toDto: domain → wire (for save) ---
|
||||||
|
|
||||||
|
function toDto(t: OrgTemplate): OrgTemplateDto {
|
||||||
|
return {
|
||||||
|
subOrgId: t.subOrgId,
|
||||||
|
orgName: t.orgName,
|
||||||
|
returnAddress: t.returnAddress,
|
||||||
|
...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),
|
||||||
|
footerContact: t.footerContact,
|
||||||
|
footerLegal: t.footerLegal,
|
||||||
|
signatureName: t.signatureName,
|
||||||
|
signatureRole: t.signatureRole,
|
||||||
|
signatureClosing: t.signatureClosing,
|
||||||
|
margins: { ...t.margins },
|
||||||
|
version: t.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
import { BriefStore } from '@brief/application/brief.store';
|
import { BriefStore } from '@brief/application/brief.store';
|
||||||
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||||
|
|
||||||
@@ -11,13 +11,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
this just wires signals to the organism and events back to store commands. */
|
this just wires signals to the organism and events back to store commands. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-brief-page',
|
selector: 'app-brief-page',
|
||||||
imports: [
|
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
|
||||||
PageShellComponent,
|
|
||||||
SpinnerComponent,
|
|
||||||
AlertComponent,
|
|
||||||
ButtonComponent,
|
|
||||||
LetterComposerComponent,
|
|
||||||
],
|
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
.brief-toolbar {
|
.brief-toolbar {
|
||||||
@@ -39,37 +33,43 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
<app-alert type="error">{{ err }}</app-alert>
|
<app-alert type="error">{{ err }}</app-alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
@switch (model().tag) {
|
<app-async [data]="store.remoteData()">
|
||||||
@case ('loading') {
|
<ng-template appAsyncError>
|
||||||
<app-spinner />
|
|
||||||
}
|
|
||||||
@case ('failed') {
|
|
||||||
<app-alert type="error">{{ failedText }}</app-alert>
|
<app-alert type="error">{{ failedText }}</app-alert>
|
||||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||||
}
|
</ng-template>
|
||||||
@case ('loaded') {
|
<ng-template appAsyncLoaded>
|
||||||
<div class="brief-toolbar">
|
@if (loaded(); as s) {
|
||||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
@if (store.orgTemplate(); as orgTemplate) {
|
||||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
<div class="brief-toolbar">
|
||||||
resetLabel
|
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||||
}}</app-button>
|
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||||
</div>
|
resetLabel
|
||||||
<app-letter-composer
|
}}</app-button>
|
||||||
[brief]="brief()!"
|
</div>
|
||||||
[availablePassages]="availablePassages()"
|
<app-letter-composer
|
||||||
[diagnostics]="store.diagnostics()"
|
[brief]="s.brief"
|
||||||
[editable]="store.editable()"
|
[orgTemplate]="orgTemplate"
|
||||||
[role]="store.role"
|
[logoUrl]="store.logoUrl()"
|
||||||
[canSubmit]="store.canSubmit()"
|
[availablePassages]="s.availablePassages"
|
||||||
[busy]="store.busy()"
|
[diagnostics]="store.diagnostics()"
|
||||||
(edit)="store.edit($event)"
|
[canEdit]="store.canEdit()"
|
||||||
(submit)="store.submit()"
|
[canApprove]="store.canApprove()"
|
||||||
(approve)="store.approve()"
|
[canReject]="store.canReject()"
|
||||||
(reject)="store.reject($event)"
|
[canSend]="store.canSend()"
|
||||||
(send)="store.send()"
|
[canSubmit]="store.canSubmit()"
|
||||||
/>
|
[busy]="store.busy()"
|
||||||
}
|
(edit)="store.edit($event)"
|
||||||
}
|
(submit)="store.submit()"
|
||||||
|
(approve)="store.approve()"
|
||||||
|
(reject)="store.reject($event)"
|
||||||
|
(send)="store.send()"
|
||||||
|
(preview)="store.previewLetter()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</app-async>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -90,12 +90,12 @@ export class BriefPage {
|
|||||||
|
|
||||||
/** Debounced-save state, surfaced in a polite live region. */
|
/** Debounced-save state, surfaced in a polite live region. */
|
||||||
protected saveText = computed(() => {
|
protected saveText = computed(() => {
|
||||||
switch (this.store.saveState()) {
|
switch (this.store.saveState().tag) {
|
||||||
case 'saving':
|
case 'Saving':
|
||||||
return this.savingText;
|
return this.savingText;
|
||||||
case 'saved':
|
case 'Saved':
|
||||||
return this.savedText;
|
return this.savedText;
|
||||||
case 'error':
|
case 'Error':
|
||||||
return this.saveErrorText;
|
return this.saveErrorText;
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
@@ -110,15 +110,13 @@ export class BriefPage {
|
|||||||
void this.store.resetDemo();
|
void this.store.resetDemo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Narrow the loaded state for the template.
|
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
|
||||||
protected brief() {
|
directive's context can't inherit a generic from a sibling host input, so the
|
||||||
|
Success value is unwrapped here instead of through `let-`. */
|
||||||
|
protected readonly loaded = computed(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s.brief : null;
|
return s.tag === 'loaded' ? s : undefined;
|
||||||
}
|
});
|
||||||
protected availablePassages() {
|
|
||||||
const s = this.model();
|
|
||||||
return s.tag === 'loaded' ? s.availablePassages : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected reload() {
|
protected reload() {
|
||||||
void this.store.load();
|
void this.store.load();
|
||||||
|
|||||||
@@ -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 } };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user