Compare commits
29 Commits
546097434d
...
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 | |||
| cbb8ae548c | |||
| bf920696ac | |||
| 1137f59f7b | |||
| e82309786d |
@@ -34,6 +34,7 @@ server re-validates as authority).
|
||||
|
||||
The mapping may read as an identity copy today — the point is the **types** differ,
|
||||
so the compiler forces a real mapping the moment the wire diverges.
|
||||
|
||||
5. **Application store** exposes the resource as `RemoteData` (`fromResource`,
|
||||
combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`).
|
||||
UI never imports infrastructure (lint-enforced).
|
||||
|
||||
@@ -16,9 +16,15 @@ unrepresentable.
|
||||
```ts
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
|
||||
export interface Draft { postcode: string; uren: string } // raw strings as typed
|
||||
export interface Draft {
|
||||
postcode: string;
|
||||
uren: string;
|
||||
} // raw strings as typed
|
||||
export type StepErrors = Partial<Record<keyof Draft, string>>;
|
||||
export interface Valid { postcode: Postcode; uren: Uren } // branded, proven valid
|
||||
export interface Valid {
|
||||
postcode: Postcode;
|
||||
uren: Uren;
|
||||
} // branded, proven valid
|
||||
|
||||
export type State =
|
||||
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors }
|
||||
@@ -28,23 +34,31 @@ export type State =
|
||||
|
||||
export type Msg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Seed'; state: State }; // mount any state (stories, resume)
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Seed'; state: State }; // mount any state (stories, resume)
|
||||
|
||||
export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} };
|
||||
|
||||
export function reduce(s: State, m: Msg): State {
|
||||
switch (m.tag) {
|
||||
/* … pure transitions only … */
|
||||
default: return assertNever(m); // exhaustiveness enforced
|
||||
default:
|
||||
return assertNever(m); // exhaustiveness enforced
|
||||
}
|
||||
}
|
||||
|
||||
export function validate(draft: Draft): Result<StepErrors, Valid> { /* calls value-object parsers */ }
|
||||
export function validate(draft: Draft): Result<StepErrors, Valid> {
|
||||
/* calls value-object parsers */
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill).
|
||||
- **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field.
|
||||
- Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine).
|
||||
|
||||
@@ -17,7 +17,9 @@ machine's `Valid` type, returns the server's answer, no parse (server is authori
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChangeRequestAdapter {
|
||||
private client = inject(ApiClient);
|
||||
async changeRequest(data: Valid): Promise<string> { /* → server reference */ }
|
||||
async changeRequest(data: Valid): Promise<string> {
|
||||
/* → server reference */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ Every feature is built inward-out. Never start with the component.
|
||||
## Worked example
|
||||
|
||||
The intake wizard slice, end to end:
|
||||
|
||||
- `src/app/herregistratie/domain/intake.machine.ts` (+ spec)
|
||||
- `src/app/herregistratie/infrastructure/`
|
||||
- `src/app/herregistratie/ui/` (wizard component + page)
|
||||
|
||||
@@ -28,6 +28,7 @@ export function parsePostcode(raw: string): Result<string, Postcode> {
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else.
|
||||
- Error message is user-facing → `$localize` with a stable `@@validation.<name>` id.
|
||||
- Trim/normalise before testing; return the cleaned value.
|
||||
|
||||
17
.github/dependabot.yml
vendored
Normal file
17
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: nuget
|
||||
directory: /backend
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
71
.github/workflows/ci.yml
vendored
71
.github/workflows/ci.yml
vendored
@@ -2,11 +2,23 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
# Least privilege by default; the CodeQL job widens its own scope locally.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same ref cancels the in-flight run.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -15,13 +27,20 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run format:check
|
||||
- run: npm run check:tokens
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
# --localize builds every configured locale (nl + en, angular.json's i18n
|
||||
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails
|
||||
# this step if messages.en.xlf is missing a unit the source (WP-20) gains.
|
||||
- run: npx ng build --localize
|
||||
# The shipped bundle must stay clean; dev-only advisories are excluded.
|
||||
- run: npm audit --omit=dev
|
||||
|
||||
storybook-a11y:
|
||||
# Axe runs against every story in the static build; a violation fails the build.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -35,16 +54,66 @@ jobs:
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- run: dotnet format backend/BigRegister.slnx --verify-no-changes
|
||||
- run: dotnet test backend/BigRegister.slnx
|
||||
|
||||
e2e:
|
||||
# Smoke-level Playwright run against the REAL FE+backend (WP-19) — a fresh
|
||||
# runner checkout per run, so there's no bigregister.db (WP-22, gitignored)
|
||||
# left over from a prior run to leak state in; the backend creates + migrates
|
||||
# an empty one on this boot, same as a fresh clone always has.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: dotnet run --project backend/src/BigRegister.Api --urls http://localhost:5000 &
|
||||
- run: npx ng serve --proxy-config proxy.conf.json &
|
||||
- run: npx wait-on http://localhost:5000/swagger http://localhost:4200
|
||||
- run: npm run e2e
|
||||
|
||||
codeql:
|
||||
# Static analysis (SAST) for both sides; results appear under the Security tab.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript, csharp]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- if: matrix.language == 'csharp'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- uses: github/codeql-action/autobuild@v3
|
||||
- uses: github/codeql-action/analyze@v3
|
||||
|
||||
api-client-drift:
|
||||
# The committed typed client must match the backend OpenAPI doc.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -45,3 +45,9 @@ Thumbs.db
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
/blob-report
|
||||
/playwright/.cache
|
||||
|
||||
18
.prettierignore
Normal file
18
.prettierignore
Normal file
@@ -0,0 +1,18 @@
|
||||
# Build output & caches
|
||||
dist/
|
||||
storybook-static/
|
||||
coverage/
|
||||
.angular/
|
||||
|
||||
# Lockfile
|
||||
package-lock.json
|
||||
|
||||
# Generated — owned by their generators, not prettier
|
||||
documentation.json
|
||||
src/app/shared/infrastructure/api-client.ts
|
||||
|
||||
# Vendored design system (CIBG Huisstijl)
|
||||
public/cibg-huisstijl/
|
||||
|
||||
# Backend is formatted by `dotnet format`, not prettier
|
||||
backend/
|
||||
@@ -1,18 +1,11 @@
|
||||
import type { StorybookConfig } from '@storybook/angular';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
"stories": [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
||||
],
|
||||
"addons": [
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-onboarding"
|
||||
],
|
||||
"framework": "@storybook/angular",
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: ['@storybook/addon-a11y', '@storybook/addon-docs', '@storybook/addon-onboarding'],
|
||||
framework: '@storybook/angular',
|
||||
// Serve the vendored CIBG package so preview-head.html can <link> its CSS (and its
|
||||
// relative font/icon/image url()s resolve) — mirrors index.html for the real app.
|
||||
"staticDirs": ["../public"]
|
||||
staticDirs: ['../public'],
|
||||
};
|
||||
export default config;
|
||||
export default config;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- 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. -->
|
||||
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
|
||||
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
|
||||
<link rel="stylesheet" href="letter.css" />
|
||||
|
||||
@@ -11,9 +11,7 @@ if (typeof document !== 'undefined') document.body.classList.add('brand--cibg');
|
||||
const preview: Preview = {
|
||||
// CIBG/Bootstrap styles bare elements — no theme wrapper class needed; just pad.
|
||||
// The token bridge lives in styles.scss (loaded via the app build target).
|
||||
decorators: [
|
||||
componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`),
|
||||
],
|
||||
decorators: [componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`)],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
// Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule
|
||||
@@ -27,6 +25,18 @@ const preview: Preview = {
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
// Sidebar tells the DDD seam: reusable design system, then domain contexts.
|
||||
// See src/docs/layers.mdx.
|
||||
options: {
|
||||
storySort: {
|
||||
order: [
|
||||
'Foundations',
|
||||
'Design System',
|
||||
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
|
||||
'Domein',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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,
|
||||
view their registration, apply for re-registration). Angular 22, standalone,
|
||||
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
|
||||
Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed
|
||||
through an NSwag-generated typed client. The FE renders the backend's decisions.
|
||||
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
||||
typed client. The FE renders the backend's decisions. Reference data mimicking
|
||||
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
||||
persist to a SQLite file via EF Core (WP-22) — `docs/backlog/WP-22-durable-persistence.md`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -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`:
|
||||
|
||||
- **`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
|
||||
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
||||
is delay-gated (~250ms) so fast connections don't flash.
|
||||
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
||||
one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models
|
||||
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
|
||||
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
|
||||
`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
|
||||
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
||||
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
|
||||
Storybook stories (`*.stories.ts` co-located, titled `Layer/Name`, a11y addon on),
|
||||
not heavy component tests.
|
||||
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
|
||||
**Story titles mirror the sidebar's Design System/Domein split** (see
|
||||
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
|
||||
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
|
||||
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
|
||||
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -138,6 +151,11 @@ not heavy component tests.
|
||||
(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
|
||||
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:
|
||||
[authGuard]` on protected routes (`app.routes.ts`).
|
||||
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
||||
|
||||
82
README.md
82
README.md
@@ -8,7 +8,7 @@ and demonstrates a robust **async-state pattern** where the UI can never reach a
|
||||
inconsistent state.
|
||||
|
||||
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
|
||||
> business rules and data *are* served by a real **ASP.NET Core backend**
|
||||
> business rules and data _are_ served by a real **ASP.NET Core backend**
|
||||
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
|
||||
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
|
||||
> Rijksoverheid font and a text wordmark for the logo.
|
||||
@@ -31,6 +31,7 @@ cd backend && dotnet run --project src/BigRegister.Api # API → http://localh
|
||||
|
||||
npm run storybook # component library, organized by atomic layer
|
||||
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
|
||||
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
|
||||
```
|
||||
|
||||
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
|
||||
@@ -48,28 +49,28 @@ eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
|
||||
|
||||
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
|
||||
|
||||
| URL | What you see |
|
||||
|-----|--------------|
|
||||
| `/dashboard` | real data (fast) |
|
||||
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
|
||||
| `/dashboard?scenario=loading` | the loading state, held open |
|
||||
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
|
||||
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
|
||||
| URL | What you see |
|
||||
| ----------------------------- | -------------------------------------------- |
|
||||
| `/dashboard` | real data (fast) |
|
||||
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
|
||||
| `/dashboard?scenario=loading` | the loading state, held open |
|
||||
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
|
||||
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
|
||||
|
||||
---
|
||||
|
||||
## How atomic design works here (folder = layer)
|
||||
|
||||
Atomic design organizes UI into five layers, each built from the one below. In this repo
|
||||
the folder structure *is* the hierarchy (`src/app/`):
|
||||
the folder structure _is_ the hierarchy (`src/app/`):
|
||||
|
||||
| Layer | What it is | Examples here |
|
||||
|-------|-----------|---------------|
|
||||
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
|
||||
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
|
||||
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
|
||||
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
|
||||
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
|
||||
| Layer | What it is | Examples here |
|
||||
| -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
|
||||
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
|
||||
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
|
||||
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
|
||||
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
|
||||
|
||||
Each atom is a thin Angular standalone component that applies CIBG Huisstijl
|
||||
(Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
|
||||
@@ -81,14 +82,14 @@ does the visual work and we only own a small, typed component API.
|
||||
|
||||
**1. Reuse — the same blocks appear everywhere.**
|
||||
|
||||
| Component | Appears in |
|
||||
|-----------|-----------|
|
||||
| `button` | login, change-request, herregistratie, async retry, Storybook |
|
||||
| `form-field` + `text-input` | login form *and* change-request *and* herregistratie |
|
||||
| `status-badge` | dashboard summary, detail summary |
|
||||
| `page-shell` / `page-layout` | all four pages |
|
||||
| `site-header` / `site-footer` | every page |
|
||||
| `async` + `skeleton` | dashboard, detail |
|
||||
| Component | Appears in |
|
||||
| ----------------------------- | ------------------------------------------------------------- |
|
||||
| `button` | login, change-request, herregistratie, async retry, Storybook |
|
||||
| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
|
||||
| `status-badge` | dashboard summary, detail summary |
|
||||
| `page-shell` / `page-layout` | all four pages |
|
||||
| `site-header` / `site-footer` | every page |
|
||||
| `async` + `skeleton` | dashboard, detail |
|
||||
|
||||
Change a component once and every screen that uses it updates.
|
||||
|
||||
@@ -125,13 +126,13 @@ decisions the FE renders rather than recomputes (see ADR-0001).
|
||||
|
||||
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
|
||||
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
|
||||
mutually exclusive *by construction*. You cannot render data and an error at the same
|
||||
mutually exclusive _by construction_. You cannot render data and an error at the same
|
||||
time, or show stale content during a hard failure: those states are unrepresentable.
|
||||
|
||||
```html
|
||||
<app-async [resource]="reg" [isEmpty]="regEmpty">
|
||||
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
|
||||
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
|
||||
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
|
||||
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
|
||||
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
|
||||
</app-async>
|
||||
```
|
||||
@@ -139,7 +140,7 @@ time, or show stale content during a hard failure: those states are unrepresenta
|
||||
- **Loaded** — your content, with the value.
|
||||
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
|
||||
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
|
||||
are also delay-gated. → *handles slow vs fast connections.*
|
||||
are also delay-gated. → _handles slow vs fast connections._
|
||||
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
|
||||
`isEmpty` predicate).
|
||||
- **Error** — your template, or a default alert + a **retry** button that calls
|
||||
@@ -166,12 +167,21 @@ degrade to an instant navigation.
|
||||
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
|
||||
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
||||
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
||||
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
|
||||
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
||||
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
|
||||
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
|
||||
consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
||||
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
|
||||
**dev-only** — it is not wired into production builds.
|
||||
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
||||
Angular 22; the builder runs fine (build verified).
|
||||
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
|
||||
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
|
||||
`en` — a genuine second-locale build, not just an unexercised claim — into
|
||||
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
|
||||
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
|
||||
English, not machine-untranslated placeholders; `angular.json`'s
|
||||
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
|
||||
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
|
||||
|
||||
### Dependency security
|
||||
|
||||
@@ -184,6 +194,12 @@ Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately
|
||||
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
|
||||
|
||||
### Deliberately out of scope (POC)
|
||||
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
|
||||
NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
|
||||
itself *is* implemented.)
|
||||
|
||||
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
|
||||
Server — SQLite persists applications/documents/the brief + a real audit table,
|
||||
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
|
||||
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
|
||||
proven (see above) but
|
||||
production-quality translation, a runtime locale switcher, and RTL/pluralization
|
||||
edge cases are not — the `en` file is demo-quality, and locale is a build-time
|
||||
choice, not a switch in the running app.
|
||||
|
||||
17
angular.json
17
angular.json
@@ -17,6 +17,14 @@
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"i18n": {
|
||||
"sourceLocale": "nl",
|
||||
"locales": {
|
||||
"en": {
|
||||
"translation": "src/locale/messages.en.xlf"
|
||||
}
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
@@ -31,7 +39,8 @@
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"polyfills": ["@angular/localize/init"]
|
||||
"polyfills": ["@angular/localize/init"],
|
||||
"i18nMissingTranslation": "error"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -59,6 +68,9 @@
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"en": {
|
||||
"localize": ["en"]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
@@ -71,6 +83,9 @@
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "atomic-design-poc:build:development"
|
||||
},
|
||||
"en": {
|
||||
"buildTarget": "atomic-design-poc:build:development,en"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
|
||||
5
backend/.gitignore
vendored
5
backend/.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
|
||||
bigregister.db
|
||||
bigregister.db-shm
|
||||
bigregister.db-wal
|
||||
|
||||
@@ -4,8 +4,18 @@ The backend that hosts the **business rules** for the BIG-register portal. The
|
||||
frontend renders the decisions this service computes; it does not recompute them
|
||||
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
|
||||
|
||||
No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`),
|
||||
but the endpoints, DTOs, status codes and error envelope are production-shaped.
|
||||
No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
|
||||
notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs,
|
||||
status codes and error envelope are production-shaped.
|
||||
|
||||
**Applications, documents and the brief persist** to a SQLite file
|
||||
(`src/BigRegister.Api/bigregister.db`, EF Core-backed — `Data/AppDbContext.cs`,
|
||||
`Data/Db.cs`) created and migrated on first run; restarting the process (or
|
||||
`docker compose restart api` — the existing `./backend:/src` bind mount already
|
||||
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
|
||||
reset demo data back to empty, the same state a fresh clone starts from. This is
|
||||
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
|
||||
`docs/backlog/WP-22-durable-persistence.md`.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"swagger"
|
||||
],
|
||||
"rollForward": false
|
||||
},
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.9",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||
<!-- Pin over EF Core Sqlite's own transitive default (2.1.11): that version
|
||||
bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption
|
||||
advisory (GHSA-2m69-gcr7-jv3q). 3.0.3 bundles a patched SQLite. -->
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -147,7 +147,47 @@ public sealed record BriefDto(
|
||||
IReadOnlyList<LetterSectionDto> Sections,
|
||||
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 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);
|
||||
|
||||
@@ -9,53 +9,53 @@ namespace BigRegister.Api.Contracts;
|
||||
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
|
||||
public static class Mappers
|
||||
{
|
||||
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
|
||||
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
|
||||
|
||||
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
|
||||
Tag: s.Tag.ToString(),
|
||||
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
|
||||
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
|
||||
Reden: s.Reden,
|
||||
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
|
||||
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
|
||||
Tag: s.Tag.ToString(),
|
||||
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
|
||||
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
|
||||
Reden: s.Reden,
|
||||
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
|
||||
|
||||
public static RegistrationDto ToDto(this Registration r) => new(
|
||||
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
|
||||
public static RegistrationDto ToDto(this Registration r) => new(
|
||||
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
|
||||
|
||||
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
|
||||
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
|
||||
|
||||
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
|
||||
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
|
||||
|
||||
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
|
||||
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
|
||||
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
|
||||
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
|
||||
|
||||
public static DuoDiplomaDto ToDto(this Diploma d) => new(
|
||||
d.Id, d.Naam, d.Instelling, d.Jaar,
|
||||
DiplomaRules.ProfessionFor(d),
|
||||
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
|
||||
public static DuoDiplomaDto ToDto(this Diploma d) => new(
|
||||
d.Id, d.Naam, d.Instelling, d.Jaar,
|
||||
DiplomaRules.ProfessionFor(d),
|
||||
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
|
||||
|
||||
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
|
||||
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
|
||||
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
|
||||
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
|
||||
|
||||
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
|
||||
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
||||
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
|
||||
// by passing different `now` values without waiting for the wall clock.
|
||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
||||
{
|
||||
if (!a.Submitted)
|
||||
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
||||
if (a.Reden is not null)
|
||||
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
|
||||
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||
return new("Goedgekeurd", Referentie: a.Referentie);
|
||||
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
||||
}
|
||||
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
|
||||
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
||||
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
|
||||
// by passing different `now` values without waiting for the wall clock.
|
||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
||||
{
|
||||
if (!a.Submitted)
|
||||
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
||||
if (a.Reden is not null)
|
||||
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
|
||||
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||
return new("Goedgekeurd", Referentie: a.Referentie);
|
||||
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
||||
}
|
||||
|
||||
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||
|
||||
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
|
||||
}
|
||||
|
||||
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)!);
|
||||
}
|
||||
@@ -12,99 +12,122 @@ namespace BigRegister.Api.Data;
|
||||
/// </summary>
|
||||
public sealed class Aanvraag
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Type { get; init; } // registratie | herregistratie | intake
|
||||
public required string Owner { get; init; }
|
||||
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
|
||||
public int StepIndex { get; set; }
|
||||
public int StepCount { get; set; }
|
||||
public List<string> DocumentIds { get; set; } = new();
|
||||
public string? Referentie { get; set; } // set on submit
|
||||
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
|
||||
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
|
||||
public bool Submitted { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
public required string Id { get; init; }
|
||||
public required string Type { get; init; } // registratie | herregistratie | intake
|
||||
public required string Owner { get; init; }
|
||||
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
|
||||
public int StepIndex { get; set; }
|
||||
public int StepCount { get; set; }
|
||||
public List<string> DocumentIds { get; set; } = new();
|
||||
public string? Referentie { get; set; } // set on submit
|
||||
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
|
||||
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
|
||||
public bool Submitted { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
|
||||
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
|
||||
/// locks if it ever serves load.
|
||||
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
|
||||
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
|
||||
/// tolerates only one writer at a time anyway, and this was already a single
|
||||
/// coarse gate before the DB existed.
|
||||
/// </summary>
|
||||
public static class ApplicationStore
|
||||
{
|
||||
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
||||
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
||||
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
||||
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
||||
|
||||
private static readonly Dictionary<string, Aanvraag> _apps = new();
|
||||
private static readonly object _gate = new();
|
||||
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 a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
|
||||
lock (_gate)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
|
||||
lock (_gate) _apps[a.Id] = a;
|
||||
return a;
|
||||
using var db = Db.Create();
|
||||
db.Applications.Add(a);
|
||||
db.SaveChanges();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
public static Aanvraag? Get(string id, string owner)
|
||||
public static Aanvraag? Get(string id, string owner)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
|
||||
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)
|
||||
{
|
||||
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
|
||||
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.
|
||||
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
|
||||
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
||||
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
|
||||
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
|
||||
a.StepIndex = stepIndex;
|
||||
a.StepCount = stepCount;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null || a.Owner != owner || a.Submitted) return false;
|
||||
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
|
||||
a.StepIndex = stepIndex;
|
||||
a.StepCount = stepCount;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
|
||||
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
|
||||
public static bool Delete(string id, string owner)
|
||||
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
|
||||
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
|
||||
public static bool Delete(string id, string owner)
|
||||
{
|
||||
List<string> docs;
|
||||
lock (_gate)
|
||||
{
|
||||
List<string> docs;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
|
||||
docs = a.DocumentIds.ToList();
|
||||
_apps.Remove(id);
|
||||
}
|
||||
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
||||
return true;
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null || a.Owner != owner) return false;
|
||||
docs = a.DocumentIds.ToList();
|
||||
db.Applications.Remove(a);
|
||||
db.SaveChanges();
|
||||
}
|
||||
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
|
||||
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
|
||||
/// if the aanvraag is gone or already submitted (idempotency guard).
|
||||
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
|
||||
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
|
||||
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
|
||||
/// if the aanvraag is gone or already submitted (idempotency guard).
|
||||
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
|
||||
a.Submitted = true;
|
||||
a.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
a.UpdatedAt = a.SubmittedAt.Value;
|
||||
a.Referentie = SubmissionRules.NewReference();
|
||||
a.AutoApprovable = autoApprovable;
|
||||
a.Reden = reject;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
return a;
|
||||
}
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null || a.Owner != owner || a.Submitted) return null;
|
||||
a.Submitted = true;
|
||||
a.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
a.UpdatedAt = a.SubmittedAt.Value;
|
||||
a.Referentie = SubmissionRules.NewReference();
|
||||
a.AutoApprovable = autoApprovable;
|
||||
a.Reden = reject;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
db.SaveChanges();
|
||||
return a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,144 +1,190 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Letters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The letter (brief) — one demo brief per owner, created from a template on first
|
||||
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. The status machine and
|
||||
/// its guards live here (the server is authoritative for transitions); the FE mirrors
|
||||
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
|
||||
/// stub does not interpret it (no server-side placeholder linting in this slice).
|
||||
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
|
||||
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
|
||||
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
|
||||
/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not
|
||||
/// interpret it (no server-side placeholder linting in this slice).
|
||||
/// </summary>
|
||||
public sealed class BriefEntity
|
||||
{
|
||||
public required string BriefId { get; init; }
|
||||
public required string Owner { get; init; }
|
||||
public required string Beroep { get; init; }
|
||||
public required string TemplateId { get; init; }
|
||||
public required string DrafterId { get; init; }
|
||||
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||
public BriefStatusDto Status { get; set; } = new("draft");
|
||||
public required string BriefId { get; init; }
|
||||
public required string Owner { get; init; }
|
||||
public required string Beroep { get; init; }
|
||||
public required string TemplateId { get; init; }
|
||||
public required string DrafterId { get; init; }
|
||||
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||
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
|
||||
{
|
||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||
public const string DrafterId = "demo-drafter";
|
||||
public const string ApproverId = "demo-approver";
|
||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||
public const string DrafterId = "demo-drafter";
|
||||
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;
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
return created;
|
||||
}
|
||||
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);
|
||||
db.Briefs.Add(created);
|
||||
db.SaveChanges();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
|
||||
/// letter reopens to draft on edit (mirrors the FE reducer).
|
||||
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
|
||||
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
|
||||
/// letter reopens to draft on edit (mirrors the FE reducer).
|
||||
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||
e.Sections = sections.ToList();
|
||||
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||
e.Sections = sections.ToList();
|
||||
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
|
||||
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
|
||||
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
|
||||
public static (Outcome, BriefEntity?) Approve(string owner, Principal principal, string 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) =>
|
||||
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
|
||||
public static (Outcome, BriefEntity?) Reject(string owner, Principal principal, string comments, string at) =>
|
||||
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);
|
||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test seam: clear the demo brief between tests.
|
||||
public static void Reset()
|
||||
/// Test seam: clear the demo brief between tests.
|
||||
public static void Reset()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate) _byOwner.Clear();
|
||||
using var db = Db.Create();
|
||||
db.Briefs.ExecuteDelete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
|
||||
/// "start over" for the showcase, not a real workflow transition.
|
||||
public static BriefEntity ResetAndCreate(string owner)
|
||||
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
|
||||
/// "start over" for the showcase, not a real workflow transition.
|
||||
public static BriefEntity ResetAndCreate(string owner)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_byOwner.Remove(owner);
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
return created;
|
||||
}
|
||||
using var db = Db.Create();
|
||||
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
db.Briefs.Add(created);
|
||||
db.SaveChanges();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
// Approve/reject share the guard: must be submitted, and the approver must differ
|
||||
// from the drafter (a drafter cannot approve their own letter).
|
||||
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
|
||||
// Approve/reject share the guard: must be submitted, and the approver must differ
|
||||
// from the drafter (a drafter cannot approve their own letter). The SoD check is
|
||||
// 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);
|
||||
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||
e.Status = next(e);
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||
e.Status = next();
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
|
||||
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
|
||||
}
|
||||
|
||||
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
|
||||
public static class BriefSeed
|
||||
{
|
||||
public const string TemplateId = "besluit-arts";
|
||||
public const string Beroep = "arts";
|
||||
public const string TemplateId = "besluit-arts";
|
||||
public const string Beroep = "arts";
|
||||
|
||||
private static RichTextNodeDto T(string t) => new("text", Text: t);
|
||||
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
|
||||
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
|
||||
private static RichTextNodeDto T(string t) => new("text", Text: t);
|
||||
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
|
||||
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
|
||||
|
||||
// The template's valid placeholder fields. Two are flagged to exercise the linter:
|
||||
// a deprecated field and one not fillable for this beroep.
|
||||
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
|
||||
{
|
||||
// The template's valid placeholder fields. Two are flagged to exercise the linter:
|
||||
// a deprecated field and one not fillable for this beroep.
|
||||
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
|
||||
{
|
||||
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
|
||||
new PlaceholderDefDto("datum", "Datum", true),
|
||||
new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
|
||||
@@ -147,18 +193,18 @@ public static class BriefSeed
|
||||
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
|
||||
};
|
||||
|
||||
public static BriefEntity NewBrief(string owner) => new()
|
||||
{
|
||||
BriefId = Guid.NewGuid().ToString(),
|
||||
Owner = owner,
|
||||
Beroep = Beroep,
|
||||
TemplateId = TemplateId,
|
||||
DrafterId = BriefStore.DrafterId,
|
||||
Placeholders = Placeholders,
|
||||
// aanhef + slot are predefined, locked template text (prefilled); the drafter
|
||||
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
|
||||
// sections (FE-authoritative for this POC — the FE reducer also refuses it).
|
||||
Sections = new()
|
||||
public static BriefEntity NewBrief(string owner) => new()
|
||||
{
|
||||
BriefId = Guid.NewGuid().ToString(),
|
||||
Owner = owner,
|
||||
Beroep = Beroep,
|
||||
TemplateId = TemplateId,
|
||||
DrafterId = BriefStore.DrafterId,
|
||||
Placeholders = Placeholders,
|
||||
// aanhef + slot are predefined, locked template text (prefilled); the drafter
|
||||
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
|
||||
// sections (FE-authoritative for this POC — the FE reducer also refuses it).
|
||||
Sections = new()
|
||||
{
|
||||
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
|
||||
{
|
||||
@@ -170,13 +216,13 @@ public static class BriefSeed
|
||||
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
|
||||
}, Locked: true),
|
||||
},
|
||||
Status = new BriefStatusDto("draft"),
|
||||
};
|
||||
Status = new BriefStatusDto("draft"),
|
||||
};
|
||||
|
||||
/// Global passages plus those scoped to the given beroep.
|
||||
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
|
||||
{
|
||||
var all = new List<LibraryPassageDto>
|
||||
/// Global passages plus those scoped to the given beroep.
|
||||
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
|
||||
{
|
||||
var all = new List<LibraryPassageDto>
|
||||
{
|
||||
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
|
||||
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
|
||||
@@ -193,6 +239,6 @@ public static class BriefSeed
|
||||
new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
|
||||
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1),
|
||||
};
|
||||
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
|
||||
}
|
||||
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
|
||||
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
|
||||
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
|
||||
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a
|
||||
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
|
||||
/// serialized into a JSON response; only the dedicated content endpoint streams them.
|
||||
/// </summary>
|
||||
@@ -10,93 +10,137 @@ public sealed record StoredDocument(
|
||||
string DocumentId, string LocalId, string CategoryId, string WizardId,
|
||||
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
|
||||
{
|
||||
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>
|
||||
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
|
||||
/// for a single-process demo store; swap for per-key locks if it ever serves load.
|
||||
/// The audit log holds metadata only (never file content or other PII).
|
||||
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
|
||||
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
|
||||
/// one writer at a time anyway, and this process already serialized all access
|
||||
/// through a single gate, so it now doubles as a coarse single-writer guard for
|
||||
/// the DB file. The audit log holds metadata only (never file content or other PII).
|
||||
/// </summary>
|
||||
public static class DocumentStore
|
||||
{
|
||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||
public const string DemoOwner = "19012345601";
|
||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||
public const string DemoOwner = "19012345601";
|
||||
|
||||
private static readonly Dictionary<string, StoredDocument> _docs = new();
|
||||
private static readonly List<AuditEntry> _audit = new();
|
||||
private static readonly object _gate = new();
|
||||
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);
|
||||
lock (_gate)
|
||||
{
|
||||
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
|
||||
lock (_gate) _docs[doc.DocumentId] = doc;
|
||||
Audit("upload", doc.DocumentId, categoryId, owner);
|
||||
return doc;
|
||||
using var db = Db.Create();
|
||||
db.Documents.Add(doc);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("upload", doc.DocumentId, categoryId, owner);
|
||||
return doc;
|
||||
}
|
||||
|
||||
public static StoredDocument? Get(string documentId)
|
||||
public static StoredDocument? Get(string documentId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
|
||||
using var db = Db.Create();
|
||||
return db.Documents.Find(documentId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Status for the poll-on-return pattern: a known localId is "complete" (it
|
||||
/// arrived), an unknown one is still in flight / never started.
|
||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
||||
/// Status for the poll-on-return pattern: a known localId is "complete" (it
|
||||
/// arrived), an unknown one is still in flight / never started.
|
||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
||||
{
|
||||
var set = localIds.ToHashSet();
|
||||
lock (_gate)
|
||||
{
|
||||
var set = localIds.ToHashSet();
|
||||
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
|
||||
using var db = Db.Create();
|
||||
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark digital documents as linked to a finalised submission (blocks user delete).
|
||||
public static void Link(IEnumerable<string> documentIds)
|
||||
/// Mark digital documents as linked to a finalised submission (blocks user delete).
|
||||
public static void Link(IEnumerable<string> documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate)
|
||||
foreach (var id in documentIds)
|
||||
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
|
||||
using var db = Db.Create();
|
||||
foreach (var id in documentIds)
|
||||
{
|
||||
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 }
|
||||
|
||||
/// User delete: owner-scoped; blocked once linked to a finalised submission.
|
||||
public static DeleteResult DeleteOwned(string documentId, string owner)
|
||||
/// User delete: owner-scoped; blocked once linked to a finalised submission.
|
||||
public static DeleteResult DeleteOwned(string documentId, string owner)
|
||||
{
|
||||
string categoryId;
|
||||
lock (_gate)
|
||||
{
|
||||
string categoryId;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
|
||||
if (d.Linked) return DeleteResult.Linked;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
}
|
||||
Audit("delete-user", documentId, categoryId, owner);
|
||||
return DeleteResult.Ok;
|
||||
using var db = Db.Create();
|
||||
var d = db.Documents.Find(documentId);
|
||||
if (d is null || d.Owner != owner) return DeleteResult.NotFound;
|
||||
if (d.Linked) return DeleteResult.Linked;
|
||||
categoryId = d.CategoryId;
|
||||
db.Documents.Remove(d);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("delete-user", documentId, categoryId, owner);
|
||||
return DeleteResult.Ok;
|
||||
}
|
||||
|
||||
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
|
||||
/// review so a caseworker is notified. Returns false if the document is unknown.
|
||||
public static bool AdminDelete(string documentId, string actor)
|
||||
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
|
||||
/// review so a caseworker is notified. Returns false if the document is unknown.
|
||||
public static bool AdminDelete(string documentId, string actor)
|
||||
{
|
||||
string categoryId;
|
||||
lock (_gate)
|
||||
{
|
||||
string categoryId;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_docs.TryGetValue(documentId, out var d)) return false;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
}
|
||||
Audit("delete-admin", documentId, categoryId, actor);
|
||||
return true;
|
||||
using var db = Db.Create();
|
||||
var d = db.Documents.Find(documentId);
|
||||
if (d is null) return false;
|
||||
categoryId = d.CategoryId;
|
||||
db.Documents.Remove(d);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("delete-admin", documentId, categoryId, actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||
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
|
||||
{
|
||||
get { lock (_gate) return _audit.ToList(); }
|
||||
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 }) },
|
||||
};
|
||||
}
|
||||
@@ -10,31 +10,31 @@ namespace BigRegister.Api.Data;
|
||||
/// </summary>
|
||||
public static class SeedData
|
||||
{
|
||||
public static readonly Registration Registration = new(
|
||||
BigNummer: "19012345601",
|
||||
Naam: "Dr. A. (Anna) de Vries",
|
||||
Beroep: "Arts",
|
||||
Registratiedatum: new DateOnly(2012, 9, 1),
|
||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
||||
public static readonly Registration Registration = new(
|
||||
BigNummer: "19012345601",
|
||||
Naam: "Dr. A. (Anna) de Vries",
|
||||
Beroep: "Arts",
|
||||
Registratiedatum: new DateOnly(2012, 9, 1),
|
||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
||||
|
||||
public static readonly Person Person = new(
|
||||
Naam: "Dr. A. (Anna) de Vries",
|
||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
|
||||
public static readonly Person Person = new(
|
||||
Naam: "Dr. A. (Anna) de Vries",
|
||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
|
||||
|
||||
/// <summary>The address BRP returns for the seeded citizen.</summary>
|
||||
public static readonly Adres BrpAddress = Person.Adres;
|
||||
/// <summary>The address BRP returns for the seeded citizen.</summary>
|
||||
public static readonly Adres BrpAddress = Person.Adres;
|
||||
|
||||
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
|
||||
{
|
||||
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
|
||||
{
|
||||
new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
|
||||
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
|
||||
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
|
||||
};
|
||||
|
||||
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
|
||||
{
|
||||
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
|
||||
{
|
||||
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
|
||||
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
|
||||
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -2,8 +2,8 @@ namespace BigRegister.Domain.Diplomas;
|
||||
|
||||
public enum QuestionType
|
||||
{
|
||||
JaNee,
|
||||
Tekst,
|
||||
JaNee,
|
||||
Tekst,
|
||||
}
|
||||
|
||||
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
|
||||
|
||||
@@ -8,61 +8,61 @@ namespace BigRegister.Domain.Diplomas;
|
||||
/// </summary>
|
||||
public static class DiplomaRules
|
||||
{
|
||||
// RULE: study program → BIG profession.
|
||||
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["geneeskunde"] = "Arts",
|
||||
["verpleegkunde"] = "Verpleegkundige",
|
||||
["fysiotherapie"] = "Fysiotherapeut",
|
||||
["farmacie"] = "Apotheker",
|
||||
["tandheelkunde"] = "Tandarts",
|
||||
};
|
||||
// RULE: study program → BIG profession.
|
||||
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["geneeskunde"] = "Arts",
|
||||
["verpleegkunde"] = "Verpleegkundige",
|
||||
["fysiotherapie"] = "Fysiotherapeut",
|
||||
["farmacie"] = "Apotheker",
|
||||
["tandheelkunde"] = "Tandarts",
|
||||
};
|
||||
|
||||
public static string ProfessionFor(Diploma d) =>
|
||||
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
||||
public static string ProfessionFor(Diploma d) =>
|
||||
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
||||
|
||||
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
||||
public static IReadOnlyList<string> ManualProfessions() =>
|
||||
ProfessionByProgram.Values.Distinct().ToList();
|
||||
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
||||
public static IReadOnlyList<string> ManualProfessions() =>
|
||||
ProfessionByProgram.Values.Distinct().ToList();
|
||||
|
||||
// --- Policy questions (geldigheidsvragen) ---
|
||||
// --- Policy questions (geldigheidsvragen) ---
|
||||
|
||||
private static readonly PolicyQuestion NlTaalEngelstalig = new(
|
||||
"nl-taalvaardigheid",
|
||||
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||
QuestionType.JaNee);
|
||||
private static readonly PolicyQuestion NlTaalEngelstalig = new(
|
||||
"nl-taalvaardigheid",
|
||||
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||
QuestionType.JaNee);
|
||||
|
||||
private static readonly PolicyQuestion NlTaalManual = new(
|
||||
"nl-taalvaardigheid",
|
||||
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||
QuestionType.JaNee);
|
||||
private static readonly PolicyQuestion NlTaalManual = new(
|
||||
"nl-taalvaardigheid",
|
||||
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||
QuestionType.JaNee);
|
||||
|
||||
private static readonly PolicyQuestion DiplomaErkend = new(
|
||||
"diploma-erkend",
|
||||
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
|
||||
QuestionType.JaNee);
|
||||
private static readonly PolicyQuestion DiplomaErkend = new(
|
||||
"diploma-erkend",
|
||||
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
|
||||
QuestionType.JaNee);
|
||||
|
||||
private static readonly PolicyQuestion Toelichting = new(
|
||||
"toelichting",
|
||||
"Geef een korte toelichting op uw diploma en opleiding.",
|
||||
QuestionType.Tekst);
|
||||
private static readonly PolicyQuestion Toelichting = new(
|
||||
"toelichting",
|
||||
"Geef een korte toelichting op uw diploma en opleiding.",
|
||||
QuestionType.Tekst);
|
||||
|
||||
/// <summary>
|
||||
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
|
||||
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
|
||||
/// change, no frontend change.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
||||
{
|
||||
var questions = new List<PolicyQuestion>();
|
||||
if (d.Engelstalig)
|
||||
questions.Add(NlTaalEngelstalig);
|
||||
return questions;
|
||||
}
|
||||
/// <summary>
|
||||
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
|
||||
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
|
||||
/// change, no frontend change.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
||||
{
|
||||
var questions = new List<PolicyQuestion>();
|
||||
if (d.Engelstalig)
|
||||
questions.Add(NlTaalEngelstalig);
|
||||
return questions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
|
||||
new[] { NlTaalManual, DiplomaErkend, Toelichting };
|
||||
/// <summary>
|
||||
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
|
||||
new[] { NlTaalManual, DiplomaErkend, Toelichting };
|
||||
}
|
||||
|
||||
@@ -17,67 +17,75 @@ public sealed record DocumentCategory(
|
||||
|
||||
public static class DocumentRules
|
||||
{
|
||||
private static readonly string[] Pdf = { "application/pdf" };
|
||||
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
||||
private static readonly string[] Pdf = { "application/pdf" };
|
||||
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",
|
||||
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
||||
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
|
||||
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
|
||||
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
|
||||
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
|
||||
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
||||
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
||||
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
|
||||
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
|
||||
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
|
||||
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
|
||||
|
||||
/// <summary>
|
||||
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
|
||||
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
|
||||
/// decides which subset is actually presented for a given set of answers.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
|
||||
/// <summary>
|
||||
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
|
||||
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
|
||||
/// decides which subset is actually presented for a given set of answers.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
|
||||
{
|
||||
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
|
||||
"herregistratie" => new[]
|
||||
{
|
||||
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
|
||||
"herregistratie" => new[]
|
||||
{
|
||||
new DocumentCategory("werkervaring", "Bewijs van werkervaring",
|
||||
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
|
||||
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
||||
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
||||
},
|
||||
_ => Array.Empty<DocumentCategory>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The categories to PRESENT for a wizard, given the answers that affect required
|
||||
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
|
||||
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
|
||||
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
|
||||
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
|
||||
/// wizards get their full set.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DocumentCategory> CategoriesFor(
|
||||
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
|
||||
// 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[]
|
||||
{
|
||||
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
|
||||
var result = new List<DocumentCategory>();
|
||||
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
|
||||
result.Add(Identiteit);
|
||||
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
|
||||
return result;
|
||||
}
|
||||
new DocumentCategory("org-logo", "Organisatielogo",
|
||||
"Upload het logo van de organisatie (PNG of JPG).", false, Image, 1, false, false),
|
||||
},
|
||||
_ => Array.Empty<DocumentCategory>(),
|
||||
};
|
||||
|
||||
public static DocumentCategory? Find(string wizardId, string categoryId) =>
|
||||
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
|
||||
/// <summary>
|
||||
/// The categories to PRESENT for a wizard, given the answers that affect required
|
||||
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
|
||||
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
|
||||
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
|
||||
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
|
||||
/// wizards get their full set.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DocumentCategory> CategoriesFor(
|
||||
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
|
||||
{
|
||||
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
|
||||
var result = new List<DocumentCategory>();
|
||||
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
|
||||
result.Add(Identiteit);
|
||||
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative upload validation (the client check is UX-only). Returns a
|
||||
/// rejection reason, or null when the file is acceptable.
|
||||
/// </summary>
|
||||
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
|
||||
{
|
||||
if (category is null) return "Onbekende documentcategorie.";
|
||||
if (!category.AcceptedTypes.Contains(contentType))
|
||||
return $"Bestandstype niet toegestaan voor {category.Label}.";
|
||||
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
|
||||
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
|
||||
return null;
|
||||
}
|
||||
public static DocumentCategory? Find(string wizardId, string categoryId) =>
|
||||
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative upload validation (the client check is UX-only). Returns a
|
||||
/// rejection reason, or null when the file is acceptable.
|
||||
/// </summary>
|
||||
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
|
||||
{
|
||||
if (category is null) return "Onbekende documentcategorie.";
|
||||
if (!category.AcceptedTypes.Contains(contentType))
|
||||
return $"Bestandstype niet toegestaan voor {category.Label}.";
|
||||
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
|
||||
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ namespace BigRegister.Domain.Intake;
|
||||
/// </summary>
|
||||
public static class IntakePolicy
|
||||
{
|
||||
public const int ScholingThreshold = 1000;
|
||||
public const int ScholingThreshold = 1000;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,25 +8,25 @@ namespace BigRegister.Domain.Registrations;
|
||||
/// </summary>
|
||||
public static class HerregistratieRule
|
||||
{
|
||||
public const int WindowMonths = 12;
|
||||
public const int WindowMonths = 12;
|
||||
|
||||
public static DateOnly? Deadline(Registration reg) =>
|
||||
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
|
||||
public static DateOnly? Deadline(Registration reg) =>
|
||||
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
|
||||
|
||||
public static (bool Eligible, string? Reason) Evaluate(
|
||||
Registration reg, DateOnly today, int windowMonths = WindowMonths)
|
||||
{
|
||||
var deadline = Deadline(reg);
|
||||
if (deadline is null)
|
||||
return (false, "Geen actieve registratie.");
|
||||
public static (bool Eligible, string? Reason) Evaluate(
|
||||
Registration reg, DateOnly today, int windowMonths = WindowMonths)
|
||||
{
|
||||
var deadline = Deadline(reg);
|
||||
if (deadline is null)
|
||||
return (false, "Geen actieve registratie.");
|
||||
|
||||
var windowStart = deadline.Value.AddMonths(-windowMonths);
|
||||
return today >= windowStart
|
||||
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
|
||||
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
|
||||
}
|
||||
var windowStart = deadline.Value.AddMonths(-windowMonths);
|
||||
return today >= windowStart
|
||||
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
|
||||
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
|
||||
}
|
||||
|
||||
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
|
||||
public static bool IsStatusConsistent(RegistrationStatus s) =>
|
||||
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
|
||||
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
|
||||
public static bool IsStatusConsistent(RegistrationStatus s) =>
|
||||
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ namespace BigRegister.Domain.Registrations;
|
||||
/// <summary>The three states a BIG registration can be in.</summary>
|
||||
public enum StatusTag
|
||||
{
|
||||
Geregistreerd,
|
||||
Geschorst,
|
||||
Doorgehaald,
|
||||
Geregistreerd,
|
||||
Geschorst,
|
||||
Doorgehaald,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,29 +9,29 @@ namespace BigRegister.Domain.Submissions;
|
||||
/// </summary>
|
||||
public static class SubmissionRules
|
||||
{
|
||||
// RULE: a manually entered diploma cannot be auto-verified.
|
||||
public static string? RejectRegistratie(string diplomaHerkomst) =>
|
||||
diplomaHerkomst == "handmatig"
|
||||
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
|
||||
: null;
|
||||
// RULE: a manually entered diploma cannot be auto-verified.
|
||||
public static string? RejectRegistratie(string diplomaHerkomst) =>
|
||||
diplomaHerkomst == "handmatig"
|
||||
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
|
||||
: null;
|
||||
|
||||
// RULE: an application reporting zero worked hours is rejected.
|
||||
public static string? RejectZeroUren(int uren) =>
|
||||
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
|
||||
// RULE: an application reporting zero worked hours is rejected.
|
||||
public static string? RejectZeroUren(int uren) =>
|
||||
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
|
||||
|
||||
private static readonly Regex PostcodePattern =
|
||||
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex PostcodePattern =
|
||||
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// RULE: a change request needs a street and a well-formed Dutch postcode. The
|
||||
// server re-validates format authoritatively (the FE check is UX-only).
|
||||
public static string? RejectChangeRequest(string straat, string postcode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
|
||||
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
|
||||
return null;
|
||||
}
|
||||
// RULE: a change request needs a street and a well-formed Dutch postcode. The
|
||||
// server re-validates format authoritatively (the FE check is UX-only).
|
||||
public static string? RejectChangeRequest(string straat, string postcode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
|
||||
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string NewReference() =>
|
||||
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
|
||||
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
|
||||
public static string NewReference() =>
|
||||
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
|
||||
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Letters;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -16,16 +20,49 @@ builder.Services.AddSwaggerGen(c =>
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||
{
|
||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
// So the correlation-id scope pushed by the middleware below actually shows up in
|
||||
// the console, not just in memory for a formatter that never renders it.
|
||||
builder.Logging.AddSimpleConsole(o => o.IncludeScopes = true);
|
||||
|
||||
const string SpaCors = "spa";
|
||||
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
// WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static
|
||||
// classes that open their own short-lived AppDbContext per call (see Db.Create),
|
||||
// not DI-injected, so there's no builder.Services.AddDbContext here. Configuring
|
||||
// the connection string still goes through IConfiguration so tests/deployments can
|
||||
// override it (ConnectionStrings:AppDb) without touching this file.
|
||||
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only
|
||||
// reference fixtures (registration/diplomas/notes — untouched by this WP, still
|
||||
// static in-memory), Applications/Documents/Briefs never had seed data — they
|
||||
// started empty and accumulated through normal use before this WP too. A fresh
|
||||
// SQLite file just starts empty again, same as the old in-memory dictionaries did.
|
||||
using (var db = Db.Create())
|
||||
db.Database.Migrate();
|
||||
|
||||
// Every request gets a correlation id (client-supplied X-Correlation-Id if present,
|
||||
// else generated), pushed into the logging scope for every log line the request
|
||||
// produces (not just the Submit helper's) and echoed back as a response header for
|
||||
// support/debugging correlation. Runs first so nothing downstream logs without it.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
: Guid.NewGuid().ToString();
|
||||
ctx.Items["CorrelationId"] = cid;
|
||||
ctx.Response.Headers["X-Correlation-Id"] = cid;
|
||||
using (app.Logger.BeginScope("CorrelationId:{CorrelationId}", cid))
|
||||
await next(ctx);
|
||||
});
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors(SpaCors);
|
||||
@@ -42,10 +79,10 @@ var api = app.MapGroup("/api/v1");
|
||||
|
||||
api.MapGet("/dashboard-view", () =>
|
||||
{
|
||||
var reg = SeedData.Registration;
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
|
||||
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
|
||||
new HerregistratieDecisionsDto(eligible, reason));
|
||||
var reg = SeedData.Registration;
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
|
||||
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
|
||||
new HerregistratieDecisionsDto(eligible, reason));
|
||||
});
|
||||
|
||||
api.MapGet("/notes", () =>
|
||||
@@ -96,21 +133,21 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
|
||||
// and size authoritatively; stores metadata only (no file bytes / PII held).
|
||||
api.MapPost("/uploads", async (HttpRequest request) =>
|
||||
{
|
||||
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
|
||||
var form = await request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file");
|
||||
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
|
||||
if (file is null || categoryId == "" || localId == "" || wizardId == "")
|
||||
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
|
||||
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
|
||||
var form = await request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file");
|
||||
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
|
||||
if (file is null || categoryId == "" || localId == "" || wizardId == "")
|
||||
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
|
||||
|
||||
var category = DocumentRules.Find(wizardId, categoryId);
|
||||
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
|
||||
var category = DocumentRules.Find(wizardId, categoryId);
|
||||
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
|
||||
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
|
||||
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
@@ -118,10 +155,10 @@ api.MapPost("/uploads", async (HttpRequest request) =>
|
||||
// for pdf/image (browser renders it), attachment otherwise (download).
|
||||
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
|
||||
{
|
||||
var doc = DocumentStore.Get(documentId);
|
||||
if (doc is null) return Results.NotFound();
|
||||
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
|
||||
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
|
||||
var doc = DocumentStore.Get(documentId);
|
||||
if (doc is null) return Results.NotFound();
|
||||
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
|
||||
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
@@ -129,23 +166,23 @@ api.MapGet("/uploads/{documentId}/content", (string documentId) =>
|
||||
// Poll-on-return: which of these client localIds have arrived at the BFF.
|
||||
api.MapGet("/uploads/status", (string? localIds) =>
|
||||
{
|
||||
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
|
||||
var results = ids.Select(id => found.TryGetValue(id, out var d)
|
||||
? new UploadStatusItemDto(id, "complete", d.DocumentId)
|
||||
: new UploadStatusItemDto(id, "unknown", null)).ToList();
|
||||
return new UploadStatusDto(results);
|
||||
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
|
||||
var results = ids.Select(id => found.TryGetValue(id, out var d)
|
||||
? new UploadStatusItemDto(id, "complete", d.DocumentId)
|
||||
: new UploadStatusItemDto(id, "unknown", null)).ToList();
|
||||
return new UploadStatusDto(results);
|
||||
});
|
||||
|
||||
// User delete: owner-scoped; 409 once linked to a finalised submission.
|
||||
api.MapDelete("/uploads/{documentId}", (string documentId) =>
|
||||
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
|
||||
{
|
||||
DocumentStore.DeleteResult.Ok => Results.NoContent(),
|
||||
DocumentStore.DeleteResult.Linked => Results.Problem(
|
||||
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
|
||||
statusCode: StatusCodes.Status409Conflict),
|
||||
_ => Results.NotFound(),
|
||||
DocumentStore.DeleteResult.Ok => Results.NoContent(),
|
||||
DocumentStore.DeleteResult.Linked => Results.Problem(
|
||||
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
|
||||
statusCode: StatusCodes.Status409Conflict),
|
||||
_ => Results.NotFound(),
|
||||
})
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
@@ -164,10 +201,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
|
||||
|
||||
api.MapGet("/applications", () =>
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return ApplicationStore.List(DocumentStore.DemoOwner)
|
||||
.OrderByDescending(a => a.UpdatedAt)
|
||||
.Select(a => a.ToSummaryDto(now)).ToList();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return ApplicationStore.List(DocumentStore.DemoOwner)
|
||||
.OrderByDescending(a => a.UpdatedAt)
|
||||
.Select(a => a.ToSummaryDto(now)).ToList();
|
||||
});
|
||||
|
||||
api.MapGet("/applications/{id}", (string id) =>
|
||||
@@ -179,8 +216,8 @@ api.MapGet("/applications/{id}", (string id) =>
|
||||
|
||||
api.MapPost("/applications", (CreateApplicationRequest req) =>
|
||||
{
|
||||
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
|
||||
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
|
||||
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
|
||||
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
|
||||
})
|
||||
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created);
|
||||
|
||||
@@ -195,12 +232,12 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
|
||||
// be withdrawn (out of scope — no "intrekken").
|
||||
api.MapDelete("/applications/{id}", (string id) =>
|
||||
{
|
||||
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
||||
if (a is null) return Results.NotFound();
|
||||
if (a.Submitted)
|
||||
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
|
||||
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
|
||||
return Results.NoContent();
|
||||
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
||||
if (a is null) return Results.NotFound();
|
||||
if (a.Submitted)
|
||||
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
|
||||
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
@@ -210,128 +247,218 @@ api.MapDelete("/applications/{id}", (string id) =>
|
||||
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
|
||||
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
||||
if (existing is null) return Results.NotFound();
|
||||
if (existing.Submitted)
|
||||
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
|
||||
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
|
||||
if (existing is null) return Results.NotFound();
|
||||
if (existing.Submitted)
|
||||
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
|
||||
(string? reject, bool autoApprovable) = existing.Type switch
|
||||
{
|
||||
"registratie" => (null, req.DiplomaHerkomst == "duo"),
|
||||
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
|
||||
};
|
||||
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
|
||||
(string? reject, bool autoApprovable) = existing.Type switch
|
||||
{
|
||||
"registratie" => (null, req.DiplomaHerkomst == "duo"),
|
||||
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
|
||||
};
|
||||
|
||||
var docs = req.Documents;
|
||||
if (docs is not null)
|
||||
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
||||
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||
var docs = req.Documents;
|
||||
if (docs is not null)
|
||||
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
||||
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||
|
||||
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
|
||||
if (submitted is null) return Results.Conflict();
|
||||
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
|
||||
if (submitted is null) return Results.Conflict();
|
||||
|
||||
app.Logger.LogInformation(
|
||||
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
|
||||
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
|
||||
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
|
||||
app.Logger.LogInformation(
|
||||
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
|
||||
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
|
||||
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
|
||||
})
|
||||
.Produces<SubmitApplicationResponse>()
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
||||
// status machine + role rules. Role 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. ---
|
||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
|
||||
// tied to a specific brief's live status — see BriefDecisionsDto for that).
|
||||
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);
|
||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||
return ToView(ctx, e);
|
||||
})
|
||||
.Produces<BriefViewDto>();
|
||||
|
||||
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var (_, isDrafter) = BriefRole(ctx);
|
||||
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
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.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||
{
|
||||
var (_, isDrafter) = BriefRole(ctx);
|
||||
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
||||
LogBrief("submit", r);
|
||||
return BriefResult(r, "Alleen de opsteller mag indienen.");
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
||||
LogBrief("submit", r);
|
||||
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
|
||||
})
|
||||
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||
{
|
||||
var (acting, _) = BriefRole(ctx);
|
||||
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
|
||||
LogBrief("approve", r);
|
||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
|
||||
LogBrief("approve", r);
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var (acting, _) = BriefRole(ctx);
|
||||
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
|
||||
LogBrief("reject", r);
|
||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now());
|
||||
LogBrief("reject", r);
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.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
|
||||
// port); the backend only guards the approved→sent transition.
|
||||
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
||||
LogBrief("send", r);
|
||||
return BriefResult(r, "Versturen kan niet in deze status.");
|
||||
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
||||
// 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());
|
||||
LogBrief("send", r);
|
||||
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.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) =>
|
||||
{
|
||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||
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.
|
||||
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
||||
return ToView(ctx, e);
|
||||
})
|
||||
.WithName("briefReset")
|
||||
.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();
|
||||
|
||||
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");
|
||||
|
||||
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
|
||||
// else (default) acts as the drafter.
|
||||
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
|
||||
{
|
||||
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
|
||||
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
|
||||
}
|
||||
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||
e.ToDto(),
|
||||
BriefSeed.PassagesFor(e.Beroep),
|
||||
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
|
||||
// Sent letters render with the version pinned at send; everything else follows
|
||||
// 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.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
|
||||
BriefStore.Outcome.Ok => Results.Ok(ToView(ctx, r.entity!)),
|
||||
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),
|
||||
};
|
||||
|
||||
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
|
||||
@@ -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,
|
||||
// generated reference and the caller's correlation id (the observability seam — a
|
||||
// real system ships this to structured logging / an audit store).
|
||||
// real system ships this to structured logging / an audit store). A repeated
|
||||
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
|
||||
// — so a retried submit dedupes instead of minting a second reference.
|
||||
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
|
||||
{
|
||||
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
: "none";
|
||||
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
|
||||
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||
? k.ToString()
|
||||
: null;
|
||||
|
||||
if (reject is not null)
|
||||
{
|
||||
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
|
||||
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
|
||||
{
|
||||
app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid);
|
||||
return cached!;
|
||||
}
|
||||
|
||||
IResult result;
|
||||
if (reject is not null)
|
||||
{
|
||||
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
|
||||
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
|
||||
// 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!));
|
||||
foreach (var d in documents.Where(d => d.Channel == "post"))
|
||||
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
||||
// Link digital documents (blocks later user delete) and record post-delivery
|
||||
// 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!));
|
||||
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);
|
||||
return Results.Ok(new ReferentieResponse(reference));
|
||||
result = Results.Ok(new ReferentieResponse(reference));
|
||||
}
|
||||
|
||||
if (idemKey is not null) IdempotencyStore.Set(idemKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||
|
||||
@@ -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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -679,7 +698,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,7 +738,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -758,7 +777,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -807,7 +826,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,7 +865,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"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": {
|
||||
@@ -1030,6 +1281,24 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefDecisionsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canApprove": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canReject": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canSend": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1123,6 +1392,12 @@
|
||||
"$ref": "#/components/schemas/LibraryPassageDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"decisions": {
|
||||
"$ref": "#/components/schemas/BriefDecisionsDto"
|
||||
},
|
||||
"orgTemplate": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1469,6 +1744,131 @@
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1573,6 +1973,20 @@
|
||||
},
|
||||
"additionalProperties": { }
|
||||
},
|
||||
"PublishOrgTemplateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"affectedUnsentBriefs": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReferentieResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1716,6 +2130,33 @@
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -6,131 +6,137 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private async Task<ApplicationDetailDto> Create(string type = "registratie")
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
|
||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
||||
}
|
||||
private async Task<ApplicationDetailDto> Create(string type = "registratie")
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
|
||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
||||
}
|
||||
|
||||
private Task<List<ApplicationSummaryDto>?> List() =>
|
||||
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
|
||||
private Task<List<ApplicationSummaryDto>?> List() =>
|
||||
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
|
||||
|
||||
// --- Lifecycle over HTTP ---
|
||||
// --- Lifecycle over HTTP ---
|
||||
|
||||
[Fact]
|
||||
public async Task Create_then_list_shows_a_concept_with_step_progress()
|
||||
{
|
||||
var a = await Create();
|
||||
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
|
||||
[Fact]
|
||||
public async Task Create_then_list_shows_a_concept_with_step_progress()
|
||||
{
|
||||
var a = await Create();
|
||||
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
|
||||
|
||||
var mine = (await List())!.Single(x => x.Id == a.Id);
|
||||
Assert.Equal("Concept", mine.Status.Tag);
|
||||
Assert.Equal(1, mine.Status.StepIndex);
|
||||
Assert.Equal(4, mine.Status.StepCount);
|
||||
}
|
||||
var mine = (await List())!.Single(x => x.Id == a.Id);
|
||||
Assert.Equal("Concept", mine.Status.Tag);
|
||||
Assert.Equal(1, mine.Status.StepIndex);
|
||||
Assert.Equal(4, mine.Status.StepCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_sync_is_readable_back_from_detail()
|
||||
{
|
||||
var a = await Create();
|
||||
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
|
||||
[Fact]
|
||||
public async Task Draft_sync_is_readable_back_from_detail()
|
||||
{
|
||||
var a = await Create();
|
||||
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
|
||||
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
|
||||
|
||||
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
|
||||
Assert.NotNull(detail!.Draft);
|
||||
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
|
||||
}
|
||||
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
|
||||
Assert.NotNull(detail!.Draft);
|
||||
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||
Assert.StartsWith("BIG-2026-", body.Referentie);
|
||||
Assert.Equal("InBehandeling", body.Status.Tag);
|
||||
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
|
||||
}
|
||||
[Fact]
|
||||
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||
Assert.StartsWith("BIG-2026-", body.Referentie);
|
||||
Assert.Equal("InBehandeling", body.Status.Tag);
|
||||
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
||||
res.EnsureSuccessStatusCode(); // no longer a 422
|
||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||
Assert.Equal("InBehandeling", body.Status.Tag);
|
||||
Assert.True(body.Status.Manual);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
||||
res.EnsureSuccessStatusCode(); // no longer a 422
|
||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||
Assert.Equal("InBehandeling", body.Status.Tag);
|
||||
Assert.True(body.Status.Manual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
|
||||
{
|
||||
var a = await Create("herregistratie");
|
||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
|
||||
res.EnsureSuccessStatusCode(); // the submission is accepted...
|
||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
|
||||
Assert.NotNull(body.Status.Reden);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
|
||||
{
|
||||
var a = await Create("herregistratie");
|
||||
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
|
||||
res.EnsureSuccessStatusCode(); // the submission is accepted...
|
||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
|
||||
Assert.NotNull(body.Status.Reden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submitting_twice_conflicts()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
||||
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
||||
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Submitting_twice_conflicts()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
||||
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
||||
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_concept_removes_it()
|
||||
{
|
||||
var a = await Create();
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Cancel_concept_removes_it()
|
||||
{
|
||||
var a = await Create();
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_submitted_aanvraag_conflicts()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Cancel_submitted_aanvraag_conflicts()
|
||||
{
|
||||
var a = await Create("registratie");
|
||||
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
||||
}
|
||||
|
||||
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
||||
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
||||
|
||||
private static Aanvraag Accepted(bool autoApprovable) => new()
|
||||
{
|
||||
Id = "x", Type = "registratie", Owner = "test",
|
||||
Submitted = true, AutoApprovable = autoApprovable, Referentie = "BIG-2026-1",
|
||||
SubmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
private static Aanvraag Accepted(bool autoApprovable) => new()
|
||||
{
|
||||
Id = "x",
|
||||
Type = "registratie",
|
||||
Owner = "test",
|
||||
Submitted = true,
|
||||
AutoApprovable = autoApprovable,
|
||||
Referentie = "BIG-2026-1",
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
|
||||
{
|
||||
var a = Accepted(autoApprovable: true);
|
||||
var t0 = a.SubmittedAt!.Value;
|
||||
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
|
||||
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
|
||||
}
|
||||
[Fact]
|
||||
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
|
||||
{
|
||||
var a = Accepted(autoApprovable: true);
|
||||
var t0 = a.SubmittedAt!.Value;
|
||||
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
|
||||
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_case_never_auto_advances()
|
||||
{
|
||||
var a = Accepted(autoApprovable: false);
|
||||
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
|
||||
var status = a.ToStatusDto(far);
|
||||
Assert.Equal("InBehandeling", status.Tag);
|
||||
Assert.True(status.Manual);
|
||||
}
|
||||
[Fact]
|
||||
public void Manual_case_never_auto_advances()
|
||||
{
|
||||
var a = Accepted(autoApprovable: false);
|
||||
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
|
||||
var status = a.ToStatusDto(far);
|
||||
Assert.Equal("InBehandeling", status.Tag);
|
||||
Assert.True(status.Manual);
|
||||
}
|
||||
}
|
||||
|
||||
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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="LetterHtml.golden.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,152 +11,181 @@ namespace BigRegister.Tests;
|
||||
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
||||
/// header: absent = drafter, "approver" = a different identity.
|
||||
/// </summary>
|
||||
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
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 LetterBlockDto FreeText(string id) =>
|
||||
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
|
||||
|
||||
// Fill every REQUIRED section with a block so submit is allowed.
|
||||
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);
|
||||
}
|
||||
// Fill every REQUIRED section with a block so submit is allowed.
|
||||
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 async Task<BriefDto> Get()
|
||||
{
|
||||
BriefStore.Reset();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
return view!.Brief;
|
||||
}
|
||||
private async Task<BriefDto> Get()
|
||||
{
|
||||
BriefStore.Reset();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
return view!.Brief;
|
||||
}
|
||||
|
||||
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, 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 HttpRequestMessage Post(string path, string? role = null, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, path);
|
||||
if (role is not null) req.Headers.Add("X-Role", role);
|
||||
if (body is not null) req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
|
||||
{
|
||||
var brief = await Get();
|
||||
Assert.Equal("draft", brief.Status.Tag);
|
||||
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
||||
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
|
||||
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||
Assert.True(aanhef.Locked);
|
||||
Assert.NotEmpty(aanhef.Blocks);
|
||||
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
|
||||
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
|
||||
Assert.False(kern.Locked);
|
||||
Assert.Empty(kern.Blocks);
|
||||
[Fact]
|
||||
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
|
||||
{
|
||||
var brief = await Get();
|
||||
Assert.Equal("draft", brief.Status.Tag);
|
||||
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
||||
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
|
||||
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||
Assert.True(aanhef.Locked);
|
||||
Assert.NotEmpty(aanhef.Blocks);
|
||||
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
|
||||
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
|
||||
Assert.False(kern.Locked);
|
||||
Assert.Empty(kern.Blocks);
|
||||
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
||||
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
||||
}
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
||||
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_is_drafter_only()
|
||||
{
|
||||
var brief = await Get();
|
||||
var save = FilledFrom(brief);
|
||||
[Fact]
|
||||
public async Task Save_is_drafter_only()
|
||||
{
|
||||
var brief = await Get();
|
||||
var save = FilledFrom(brief);
|
||||
|
||||
var approver = Post("/api/v1/brief", role: "approver");
|
||||
approver.Method = HttpMethod.Put;
|
||||
approver.Content = JsonContent.Create(save);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
|
||||
var approver = Post("/api/v1/brief", role: "approver");
|
||||
approver.Method = HttpMethod.Put;
|
||||
approver.Content = JsonContent.Create(save);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
|
||||
{
|
||||
await Get();
|
||||
// Nothing filled yet → required sections empty → 409.
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
||||
[Fact]
|
||||
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
|
||||
{
|
||||
await Get();
|
||||
// Nothing filled yet → required sections empty → 409.
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
||||
|
||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("submitted", submitted!.Status.Tag);
|
||||
}
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("submitted", submitted!.Brief.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
[Fact]
|
||||
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
// drafter role approving own letter → 403
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
|
||||
// drafter role approving own letter → 403
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||
}
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
[Fact]
|
||||
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
var rejected = await (await _client.SendAsync(
|
||||
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("rejected", rejected!.Status.Tag);
|
||||
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
|
||||
var rejected = await (await _client.SendAsync(
|
||||
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("rejected", rejected!.Brief.Status.Tag);
|
||||
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
|
||||
|
||||
// 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>();
|
||||
Assert.Equal("draft", reopened!.Status.Tag);
|
||||
}
|
||||
// A drafter save on a rejected letter reopens it to draft.
|
||||
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("draft", reopened!.Brief.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_only_from_approved()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
[Fact]
|
||||
public async Task Send_only_from_approved()
|
||||
{
|
||||
var brief = await Get();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
// submitted (not approved) → send 409
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
|
||||
// submitted (not approved) → send 409
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
|
||||
|
||||
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||
}
|
||||
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
|
||||
{
|
||||
var brief = await Get();
|
||||
// Advance out of draft so the reset back to draft is observable.
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
[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);
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("draft", view!.Brief.Status.Tag);
|
||||
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||
Assert.True(aanhef.Locked);
|
||||
Assert.NotEmpty(aanhef.Blocks);
|
||||
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
|
||||
}
|
||||
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]
|
||||
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
|
||||
{
|
||||
var brief = await Get();
|
||||
// Advance out of draft so the reset back to draft is observable.
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("draft", view!.Brief.Status.Tag);
|
||||
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||
Assert.True(aanhef.Locked);
|
||||
Assert.NotEmpty(aanhef.Blocks);
|
||||
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,225 +7,241 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task DashboardView_computes_eligibility_decision()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("19012345601", dto.Registration.BigNummer);
|
||||
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
|
||||
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
|
||||
Assert.True(dto.Decisions.EligibleForHerregistratie);
|
||||
Assert.NotNull(dto.Decisions.HerregistratieReason);
|
||||
}
|
||||
[Fact]
|
||||
public async Task DashboardView_computes_eligibility_decision()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("19012345601", dto.Registration.BigNummer);
|
||||
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
|
||||
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
|
||||
Assert.True(dto.Decisions.EligibleForHerregistratie);
|
||||
Assert.NotNull(dto.Decisions.HerregistratieReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notes_returns_seeded_aantekeningen()
|
||||
{
|
||||
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
|
||||
Assert.Equal(3, notes!.Count);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Notes_returns_seeded_aantekeningen()
|
||||
{
|
||||
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
|
||||
Assert.Equal(3, notes!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Brp_returns_address()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
|
||||
Assert.True(dto!.Gevonden);
|
||||
Assert.Equal("2514 EA", dto.Adres!.Postcode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Brp_returns_address()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
|
||||
Assert.True(dto!.Gevonden);
|
||||
Assert.Equal("2514 EA", dto.Adres!.Postcode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
|
||||
Assert.NotNull(dto);
|
||||
[Fact]
|
||||
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
|
||||
Assert.NotNull(dto);
|
||||
|
||||
var english = dto.Diplomas.Single(d => d.Id == "d2");
|
||||
Assert.Equal("Arts", english.Beroep);
|
||||
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
|
||||
var english = dto.Diplomas.Single(d => d.Id == "d2");
|
||||
Assert.Equal("Arts", english.Beroep);
|
||||
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
|
||||
|
||||
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
|
||||
Assert.Empty(dutch.PolicyQuestions);
|
||||
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
|
||||
Assert.Empty(dutch.PolicyQuestions);
|
||||
|
||||
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
|
||||
// which the same lookup provides (maximal question set + declarable professions).
|
||||
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
|
||||
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
|
||||
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
|
||||
}
|
||||
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
|
||||
// which the same lookup provides (maximal question set + declarable professions).
|
||||
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
|
||||
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
|
||||
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IntakePolicy_returns_scholing_threshold()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
|
||||
Assert.Equal(1000, dto!.ScholingThreshold);
|
||||
}
|
||||
[Fact]
|
||||
public async Task IntakePolicy_returns_scholing_threshold()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
|
||||
Assert.Equal(1000, dto!.ScholingThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_with_duo_diploma_succeeds()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Registration_with_duo_diploma_succeeds()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
|
||||
}
|
||||
[Fact]
|
||||
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/v1/intakes")]
|
||||
[InlineData("/api/v1/herregistraties")]
|
||||
public async Task Zero_hours_submission_is_rejected(string route)
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
}
|
||||
[Theory]
|
||||
[InlineData("/api/v1/intakes")]
|
||||
[InlineData("/api/v1/herregistraties")]
|
||||
public async Task Zero_hours_submission_is_rejected(string route)
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/v1/intakes")]
|
||||
[InlineData("/api/v1/herregistraties")]
|
||||
public async Task Worked_hours_submission_succeeds(string route)
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
[Theory]
|
||||
[InlineData("/api/v1/intakes")]
|
||||
[InlineData("/api/v1/herregistraties")]
|
||||
public async Task Worked_hours_submission_succeeds(string route)
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Change_request_with_valid_address_succeeds()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
||||
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Change_request_with_valid_address_succeeds()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
||||
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Change_request_with_bad_postcode_is_rejected()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
||||
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Change_request_with_bad_postcode_is_rejected()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
|
||||
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_endpoint_is_ok()
|
||||
{
|
||||
var res = await _client.GetAsync("/health");
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
[Fact]
|
||||
public async Task Health_endpoint_is_ok()
|
||||
{
|
||||
var res = await _client.GetAsync("/health");
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// --- Document upload ---
|
||||
[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());
|
||||
}
|
||||
|
||||
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
content.Add(file, "file", fileName);
|
||||
content.Add(new StringContent(categoryId), "categoryId");
|
||||
content.Add(new StringContent(localId), "localId");
|
||||
content.Add(new StringContent(wizardId), "wizardId");
|
||||
return content;
|
||||
}
|
||||
[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());
|
||||
}
|
||||
|
||||
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
|
||||
{
|
||||
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
|
||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
||||
}
|
||||
// --- Document upload ---
|
||||
|
||||
[Fact]
|
||||
public async Task Categories_are_server_owned_config()
|
||||
{
|
||||
// A manual diploma requires a diploma upload; identiteit is always required.
|
||||
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
|
||||
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
|
||||
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
|
||||
}
|
||||
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
content.Add(file, "file", fileName);
|
||||
content.Add(new StringContent(categoryId), "categoryId");
|
||||
content.Add(new StringContent(localId), "localId");
|
||||
content.Add(new StringContent(wizardId), "wizardId");
|
||||
return content;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_then_status_reports_complete_for_known_localId()
|
||||
{
|
||||
var localId = Guid.NewGuid().ToString();
|
||||
var doc = await Upload(localId);
|
||||
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
|
||||
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
|
||||
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
|
||||
}
|
||||
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
|
||||
{
|
||||
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
|
||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
|
||||
// pdf/image → inline (no attachment disposition) so the browser previews it
|
||||
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Categories_are_server_owned_config()
|
||||
{
|
||||
// A manual diploma requires a diploma upload; identiteit is always required.
|
||||
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
|
||||
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
|
||||
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_content_404_for_unknown_document()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Upload_then_status_reports_complete_for_known_localId()
|
||||
{
|
||||
var localId = Guid.NewGuid().ToString();
|
||||
var doc = await Upload(localId);
|
||||
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
|
||||
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
|
||||
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_rejects_wrong_type()
|
||||
{
|
||||
var res = await _client.PostAsync("/api/v1/uploads",
|
||||
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
|
||||
// pdf/image → inline (no attachment disposition) so the browser previews it
|
||||
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_delete_succeeds_then_404()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Upload_content_404_for_unknown_document()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_delete_blocked_with_409_once_linked_to_submission()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
|
||||
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
|
||||
submit.EnsureSuccessStatusCode();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Upload_rejects_wrong_type()
|
||||
{
|
||||
var res = await _client.PostAsync("/api/v1/uploads",
|
||||
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_delete_requires_admin_role()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
|
||||
[Fact]
|
||||
public async Task User_delete_succeeds_then_404()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||
}
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
|
||||
req.Headers.Add("X-Admin", "true");
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task User_delete_blocked_with_409_once_linked_to_submission()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
|
||||
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
|
||||
submit.EnsureSuccessStatusCode();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_log_records_upload_and_delete_metadata_only()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
|
||||
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
|
||||
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
|
||||
}
|
||||
[Fact]
|
||||
public async Task Admin_delete_requires_admin_role()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
|
||||
req.Headers.Add("X-Admin", "true");
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_log_records_upload_and_delete_metadata_only()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
|
||||
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
|
||||
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -7,174 +7,174 @@ namespace BigRegister.Tests;
|
||||
|
||||
public class DocumentRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rejects_unknown_category() =>
|
||||
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
|
||||
[Fact]
|
||||
public void Rejects_unknown_category() =>
|
||||
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
|
||||
|
||||
[Fact]
|
||||
public void Rejects_disallowed_type()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
|
||||
}
|
||||
[Fact]
|
||||
public void Rejects_disallowed_type()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_oversized_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
|
||||
}
|
||||
[Fact]
|
||||
public void Rejects_oversized_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_valid_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
|
||||
}
|
||||
[Fact]
|
||||
public void Accepts_valid_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
|
||||
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
|
||||
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
|
||||
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
|
||||
|
||||
[Fact]
|
||||
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
|
||||
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
|
||||
[Fact]
|
||||
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
|
||||
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
|
||||
|
||||
[Fact]
|
||||
public void Manual_diploma_needs_a_diploma_upload() =>
|
||||
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
|
||||
[Fact]
|
||||
public void Manual_diploma_needs_a_diploma_upload() =>
|
||||
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_skips_diploma_upload() =>
|
||||
Assert.DoesNotContain("diploma", Ids("duo", null));
|
||||
[Fact]
|
||||
public void Duo_diploma_skips_diploma_upload() =>
|
||||
Assert.DoesNotContain("diploma", Ids("duo", null));
|
||||
|
||||
[Fact]
|
||||
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
|
||||
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
|
||||
[Fact]
|
||||
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
|
||||
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
|
||||
|
||||
[Fact]
|
||||
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
|
||||
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
|
||||
[Fact]
|
||||
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
|
||||
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
|
||||
|
||||
[Fact]
|
||||
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
|
||||
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
|
||||
[Fact]
|
||||
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
|
||||
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
|
||||
}
|
||||
|
||||
public class HerregistratieRuleTests
|
||||
{
|
||||
private static Registration Active(DateOnly deadline) => new(
|
||||
"19012345601", "Test", "Arts",
|
||||
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
||||
private static Registration Active(DateOnly deadline) => new(
|
||||
"19012345601", "Test", "Arts",
|
||||
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
||||
|
||||
[Fact]
|
||||
public void Eligible_within_window()
|
||||
{
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
||||
Assert.True(eligible);
|
||||
Assert.Contains("12 maanden", reason);
|
||||
}
|
||||
[Fact]
|
||||
public void Eligible_within_window()
|
||||
{
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
||||
Assert.True(eligible);
|
||||
Assert.Contains("12 maanden", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Not_eligible_before_window()
|
||||
{
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
[Fact]
|
||||
public void Not_eligible_before_window()
|
||||
{
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eligible_on_window_boundary()
|
||||
{
|
||||
// window opens exactly 12 months before the deadline
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
||||
Assert.True(eligible);
|
||||
}
|
||||
[Fact]
|
||||
public void Eligible_on_window_boundary()
|
||||
{
|
||||
// window opens exactly 12 months before the deadline
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
||||
Assert.True(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suspended_is_not_eligible()
|
||||
[Fact]
|
||||
public void Suspended_is_not_eligible()
|
||||
{
|
||||
var reg = Active(new DateOnly(2027, 3, 1)) with
|
||||
{
|
||||
var reg = Active(new DateOnly(2027, 3, 1)) with
|
||||
{
|
||||
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
||||
};
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
||||
};
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_consistency_invariant()
|
||||
{
|
||||
Assert.True(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
||||
Assert.False(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd)));
|
||||
}
|
||||
[Fact]
|
||||
public void Status_consistency_invariant()
|
||||
{
|
||||
Assert.True(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
||||
Assert.False(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd)));
|
||||
}
|
||||
}
|
||||
|
||||
public class DiplomaRuleTests
|
||||
{
|
||||
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
||||
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
||||
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
||||
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
||||
|
||||
[Theory]
|
||||
[InlineData("geneeskunde", "Arts")]
|
||||
[InlineData("verpleegkunde", "Verpleegkundige")]
|
||||
[InlineData("onbekend-programma", "Onbekend")]
|
||||
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
||||
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
||||
[Theory]
|
||||
[InlineData("geneeskunde", "Arts")]
|
||||
[InlineData("verpleegkunde", "Verpleegkundige")]
|
||||
[InlineData("onbekend-programma", "Onbekend")]
|
||||
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
||||
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
||||
|
||||
[Fact]
|
||||
public void English_diploma_requires_dutch_proficiency()
|
||||
{
|
||||
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
||||
Assert.Single(questions);
|
||||
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
||||
}
|
||||
[Fact]
|
||||
public void English_diploma_requires_dutch_proficiency()
|
||||
{
|
||||
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
||||
Assert.Single(questions);
|
||||
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dutch_diploma_has_no_policy_questions() =>
|
||||
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
||||
[Fact]
|
||||
public void Dutch_diploma_has_no_policy_questions() =>
|
||||
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
||||
|
||||
[Fact]
|
||||
public void Manual_diploma_gets_maximal_set()
|
||||
{
|
||||
var questions = DiplomaRules.ManualQuestions();
|
||||
Assert.Equal(3, questions.Count);
|
||||
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
||||
questions.Select(q => q.Id));
|
||||
}
|
||||
[Fact]
|
||||
public void Manual_diploma_gets_maximal_set()
|
||||
{
|
||||
var questions = DiplomaRules.ManualQuestions();
|
||||
Assert.Equal(3, questions.Count);
|
||||
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
||||
questions.Select(q => q.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_professions_match_known_programs() =>
|
||||
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
||||
DiplomaRules.ManualProfessions());
|
||||
[Fact]
|
||||
public void Manual_professions_match_known_programs() =>
|
||||
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
||||
DiplomaRules.ManualProfessions());
|
||||
}
|
||||
|
||||
public class SubmissionRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Manual_diploma_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||
[Fact]
|
||||
public void Manual_diploma_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_is_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||
[Fact]
|
||||
public void Duo_diploma_is_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||
|
||||
[Fact]
|
||||
public void Zero_hours_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||
[Fact]
|
||||
public void Zero_hours_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||
|
||||
[Fact]
|
||||
public void Worked_hours_are_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
||||
[Fact]
|
||||
public void Worked_hours_are_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
|
||||
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
|
||||
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
|
||||
public void Change_request_is_validated(string straat, string postcode, string? expected) =>
|
||||
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
|
||||
[Theory]
|
||||
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
|
||||
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
|
||||
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
|
||||
public void Change_request_is_validated(string straat, string postcode, string? expected) =>
|
||||
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
|
||||
}
|
||||
|
||||
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,11 +9,20 @@ services:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
volumes:
|
||||
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
||||
# WP-22: no separate volume needed for the SQLite file — `dotnet run` sets
|
||||
# its cwd to the project dir (src/BigRegister.Api), which this bind mount
|
||||
# already covers, so bigregister.db lands on the host and survives
|
||||
# `docker compose restart api` / `down` + `up` for free (gitignored).
|
||||
- ./backend:/src:z
|
||||
- api-bin:/src/src/BigRegister.Api/bin
|
||||
- api-obj:/src/src/BigRegister.Api/obj
|
||||
# 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:
|
||||
- "5000:5000"
|
||||
- '5000:5000'
|
||||
|
||||
web:
|
||||
image: node:24
|
||||
@@ -24,7 +33,7 @@ services:
|
||||
- ./:/app:z
|
||||
- web-modules:/app/node_modules
|
||||
ports:
|
||||
- "4200:4200"
|
||||
- '4200:4200'
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ profile = computed(() =>
|
||||
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
|
||||
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"
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ backend team.
|
||||
|
||||
## Options considered
|
||||
|
||||
| Option | Fewer calls? | Unifies policy? | Cost |
|
||||
|---|---|---|---|
|
||||
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
|
||||
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
|
||||
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Low–medium |
|
||||
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
|
||||
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Medium–high; new infra |
|
||||
| Option | Fewer calls? | Unifies policy? | Cost |
|
||||
| -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- |
|
||||
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
|
||||
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
|
||||
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Low–medium |
|
||||
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
|
||||
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Medium–high; new infra |
|
||||
|
||||
GraphQL solves over/under-fetching but does not, on its own, move rules
|
||||
server-side — and our problem is policy unification + drift, not field-selection
|
||||
@@ -40,7 +40,7 @@ them.** Keep it minimal: implement BFF-shaped endpoints on the backend we alread
|
||||
own. Promote to a separately-deployed BFF service only when a second consumer
|
||||
(mobile/partner) or a team boundary demands it — not before.
|
||||
|
||||
### Why DTOs *decouple* rather than couple
|
||||
### Why DTOs _decouple_ rather than couple
|
||||
|
||||
The coupling people fear comes from **not** having DTOs — i.e. serializing internal
|
||||
DB/domain entities straight onto the wire, so every schema change ripples to the
|
||||
@@ -53,7 +53,7 @@ DB entity / domain model → DTO (the wire contract) → FE view model
|
||||
|
||||
Each side keeps its own internal model and refactors freely; only the DTO is a
|
||||
deliberate, versioned change. The one coupling that remains — both sides agreeing
|
||||
on the contract — is the *wanted*, reviewable seam. Manage it with **one source of
|
||||
on the contract — is the _wanted_, reviewable seam. Manage it with **one source of
|
||||
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
|
||||
the governance/transparency artifact.
|
||||
|
||||
@@ -76,6 +76,7 @@ This POC has no real backend (static mock JSON + fake submit timers), so the
|
||||
would compute. Two slices were implemented to demonstrate **both** policy shapes:
|
||||
|
||||
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
|
||||
|
||||
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
|
||||
(`DashboardViewDto` = registration + person + `decisions`).
|
||||
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
|
||||
@@ -92,6 +93,7 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
|
||||
`brp.json`) were deleted — those calls live behind the BFF now.
|
||||
|
||||
**B. Intake scholing threshold → config value.**
|
||||
|
||||
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
|
||||
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
|
||||
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
|
||||
|
||||
@@ -7,7 +7,7 @@ Status: Proposed · Date: 2026-07-01
|
||||
Today the app knows exactly one actor. `auth/domain/session.ts` is a flat
|
||||
`Session { bsn, naam }`, authentication is a faked DigiD flow, and the backend has no
|
||||
role model at all (only an `X-Admin: true` header seam in `Program.cs` and a stringly-typed
|
||||
`Actor` on audit entries). This whole repo *is* the **Zorgverlener** self-service portal (SSP).
|
||||
`Actor` on audit entries). This whole repo _is_ the **Zorgverlener** self-service portal (SSP).
|
||||
|
||||
We now need a second user group — **Behandelaar** (backoffice: assessing and deciding on
|
||||
applications) — and want room for others later (admin, auditor, institution rep). The question
|
||||
@@ -27,11 +27,11 @@ Confirmed constraints (with the product owner):
|
||||
|
||||
## Options considered
|
||||
|
||||
| Option | Ubiquitous language respected? | Coupling | Verdict |
|
||||
|---|---|---|---|
|
||||
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject |
|
||||
| 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject |
|
||||
| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
|
||||
| Option | Ubiquitous language respected? | Coupling | Verdict |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------- | --------- |
|
||||
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject |
|
||||
| 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject |
|
||||
| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -42,9 +42,9 @@ Confirmed constraints (with the product owner):
|
||||
|
||||
The same real-world thing is described in two different languages:
|
||||
|
||||
- **Zelfbediening (SSP)** — the Zorgverlener: *"ik vraag herregistratie aan"* — eligibility, fill in
|
||||
- **Zelfbediening (SSP)** — the Zorgverlener: _"ik vraag herregistratie aan"_ — eligibility, fill in
|
||||
my data, upload documents, submit. **This repo.**
|
||||
- **Behandeling (backoffice)** — the Behandelaar: *"ik beoordeel de aanvraag"* — werkvoorraad,
|
||||
- **Behandeling (backoffice)** — the Behandelaar: _"ik beoordeel de aanvraag"_ — werkvoorraad,
|
||||
beoordeling, besluit, meer-info-opvragen, SLA, audit. **A sibling application**, not a folder here.
|
||||
|
||||
Diverging verbs over the same noun is the textbook signal for **two bounded contexts**.
|
||||
@@ -52,9 +52,9 @@ Diverging verbs over the same noun is the textbook signal for **two bounded cont
|
||||
### 2. The aggregate is owned by the backend; the contexts integrate through it
|
||||
|
||||
The aanvraag/registration is the **system of record in the backend domain**. Neither frontend owns
|
||||
it. They integrate *through the backend* using the **BFF-lite decision DTOs of ADR-0001** — the same
|
||||
aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the *published
|
||||
contract* between the two contexts:
|
||||
it. They integrate _through the backend_ using the **BFF-lite decision DTOs of ADR-0001** — the same
|
||||
aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the _published
|
||||
contract_ between the two contexts:
|
||||
|
||||
```
|
||||
Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen
|
||||
@@ -93,7 +93,7 @@ These are two concerns people habitually conflate; keeping them apart is the cru
|
||||
|
||||
```ts
|
||||
type Principal =
|
||||
| { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN
|
||||
| { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN
|
||||
| { kind: 'medewerker'; medewerkerId: string; naam: string; rollen: Rol[] }; // employee SSO
|
||||
```
|
||||
|
||||
@@ -102,14 +102,14 @@ These are two concerns people habitually conflate; keeping them apart is the cru
|
||||
second actor arrives.
|
||||
|
||||
- **Authorization — "what may you do"** → enforced at the **backend / context boundary**, where the
|
||||
backend is the authority (per ADR-0001). It is *not* a permission matrix living in `auth`. The
|
||||
backend is the authority (per ADR-0001). It is _not_ a permission matrix living in `auth`. The
|
||||
frontend receives only the decisions it needs to render (e.g. a `canBeoordelen` flag), exactly like
|
||||
every other server-owned rule.
|
||||
|
||||
### 4. "Other users" slot in without inventing contexts
|
||||
|
||||
Admin, auditor, institution-rep are additional **`Principal` variants** or additional **`rollen` on
|
||||
`medewerker`** — never a new folder-per-role. A genuinely new *bounded context* is warranted only when
|
||||
`medewerker`** — never a new folder-per-role. A genuinely new _bounded context_ is warranted only when
|
||||
an actor brings a new **language and capability** (e.g. an "Toezicht/Handhaving" enforcement context),
|
||||
not merely a new login.
|
||||
|
||||
@@ -121,7 +121,7 @@ not merely a new login.
|
||||
`authGuard`/`SessionStore` seams already localise that (`auth.guard.ts`, `session.store.ts`).
|
||||
- The backend becomes the authority for the **aanvraag status lifecycle** and for **authorization**,
|
||||
publishing both as decision DTOs — a natural extension of ADR-0001, not a new pattern.
|
||||
- `pendingHerregistratie` is understood as a *temporary stand-in* for a real, backend-owned status.
|
||||
- `pendingHerregistratie` is understood as a _temporary stand-in_ for a real, backend-owned status.
|
||||
|
||||
## Out of scope here (next steps, not built)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ layer — not a palette swap.
|
||||
references resolve at runtime. Storybook serves the same via `staticDirs`.
|
||||
2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens
|
||||
onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are
|
||||
now an internal alias set; the *values* are CIBG. This avoided rewriting 300+ token references and
|
||||
now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and
|
||||
keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
|
||||
`check:tokens`, so palette hex lives in that one file only.)
|
||||
3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes
|
||||
@@ -42,9 +42,13 @@ layer — not a palette swap.
|
||||
(`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` +
|
||||
`shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped.
|
||||
- `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply.
|
||||
- Known benign build warning: *"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"* —
|
||||
- Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_ —
|
||||
Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied
|
||||
and the link is preserved (verified: served 200, `.btn-primary` present); the build exits green. The
|
||||
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
|
||||
intentionally dropped, so we accept the warning.
|
||||
- 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,38 +30,61 @@ From WP-01 onward, additionally:
|
||||
npm run test-storybook:ci
|
||||
```
|
||||
|
||||
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
|
||||
only needs re-running if a WP unexpectedly touches `backend/`.
|
||||
Phases 0–5 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
|
||||
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
|
||||
|
||||
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
|
||||
for its existing violations, so every WP ends green.
|
||||
|
||||
| WP | Title | Phase | Status |
|
||||
|----|-------|-------|--------|
|
||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 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-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-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
|
||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
|
||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
|
||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
|
||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
|
||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo |
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
|
||||
| WP | Title | Phase | Status |
|
||||
| --------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ |
|
||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 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-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 | done |
|
||||
| [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 | done |
|
||||
| [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 | done |
|
||||
| [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-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
||||
| [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 | done |
|
||||
| [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);
|
||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||
`<app-async>` before brief adopts it); 13 defines the gap-marker format that 11/12 reference
|
||||
— if 11/12 run first, they define it and 13 adopts it.
|
||||
— if 11/12 run first, they define it and 13 adopts it. 18–22 (phase 5, "productie-volwassenheid")
|
||||
are independent of each other and of phases 1–4 — pick any order; **18 is the recommended
|
||||
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
|
||||
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
|
||||
|
||||
@@ -72,12 +95,20 @@ Status: todo | in-progress | done (<commit>)
|
||||
Phase: N — name
|
||||
|
||||
## Why
|
||||
|
||||
## Read first
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
## Files
|
||||
|
||||
## Steps
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
## Verification
|
||||
|
||||
## Out of scope
|
||||
|
||||
## Risks
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ Phase: 0 — enforcement & gates
|
||||
## Why
|
||||
|
||||
The Storybook a11y addon (`@storybook/addon-a11y`, configured in `.storybook/preview.ts`
|
||||
for `wcag2a, wcag2aa, wcag21a, wcag21aa`) only surfaces violations *interactively*.
|
||||
for `wcag2a, wcag2aa, wcag21a, wcag21aa`) only surfaces violations _interactively_.
|
||||
Nothing gates CI. This WP turns "a panel you can look at" into "a check that fails the
|
||||
build", so every story added or changed by later WPs is automatically covered.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-05 — Parse-don't-validate closure + MDX
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
@@ -30,7 +30,7 @@ principle (every response through a hand-written `parse*` returning `Result`).
|
||||
- `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) —
|
||||
`n.type as AantekeningType` → validated parse
|
||||
- `src/app/brief/infrastructure/brief.adapter.ts` (~line 189) — `dto.scope as
|
||||
PassageScope` → validated parse (the file is otherwise parse-heavy; this one field skips)
|
||||
PassageScope` → validated parse (the file is otherwise parse-heavy; this one field skips)
|
||||
- New co-located specs: `intake-policy.adapter.spec.ts`, extend
|
||||
`big-register.adapter.spec.ts` / `brief.adapter.spec.ts` (create if missing)
|
||||
- New `src/docs/parse-dont-validate.mdx` — title `Foundations/Parse, don't validate`
|
||||
@@ -45,10 +45,10 @@ principle (every response through a hand-written `parse*` returning `Result`).
|
||||
|
||||
## 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).
|
||||
- [ ] Each new parser has a spec including a rejection case.
|
||||
- [ ] MDX renders under Foundations in Storybook.
|
||||
- [x] Each new parser has a spec including a rejection case.
|
||||
- [x] MDX renders under Foundations in Storybook.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -57,7 +57,7 @@ GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy
|
||||
|
||||
## Out of scope
|
||||
|
||||
Runtime validation on *every* endpoint (explicitly out of scope for the POC per
|
||||
Runtime validation on _every_ endpoint (explicitly out of scope for the POC per
|
||||
CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned).
|
||||
|
||||
## Risks
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-06 — Generic async template contexts: kill `$any()` (18×)
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `grep -rn '\$any(' src/app` → zero hits.
|
||||
- [ ] No `as` casts added to compensate in component classes (typed getters are fine).
|
||||
- [ ] Build green with strict template checking.
|
||||
- [x] `grep -rn '\$any(' src/app` → zero hits.
|
||||
- [x] No `as` casts added to compensate in component classes (typed getters are fine).
|
||||
- [x] Build green with strict template checking.
|
||||
|
||||
## 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
|
||||
bounded.
|
||||
`AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
|
||||
deviation is contained to consumer templates, as the WP intended.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-07 — Brief on the shared idioms + RemoteData MDX
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
Depends on: WP-06 (typed `<app-async>`)
|
||||
|
||||
@@ -26,7 +26,7 @@ bypassing the shared molecule.
|
||||
(`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its
|
||||
union shape (align tag style).
|
||||
- Load lifecycle → `RemoteData` + `<app-async>`; the machine keeps owning the letter's
|
||||
*domain* lifecycle (loading tags move out of the machine only if they purely mirror
|
||||
_domain_ lifecycle (loading tags move out of the machine only if they purely mirror
|
||||
the fetch — keep the seam: RemoteData = fetch, machine = letter).
|
||||
- Keep the debounced-save sequencing identical; only re-type the state.
|
||||
|
||||
@@ -50,15 +50,53 @@ bypassing the shared molecule.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No boolean-plus-error signal pairs in `brief/`.
|
||||
- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`).
|
||||
- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle).
|
||||
- [ ] MDX renders under Foundations.
|
||||
- [x] No boolean-plus-error signal pairs in `brief/`.
|
||||
- [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see
|
||||
Deviation for why `empty` isn't meaningful here).
|
||||
- [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts`
|
||||
(new).
|
||||
- [x] MDX renders under Foundations.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → `/brief` with
|
||||
`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow.
|
||||
GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from
|
||||
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
|
||||
|
||||
@@ -67,4 +105,11 @@ Brief component stories (WP-15); machine renaming conventions (WP-08).
|
||||
## Risks
|
||||
|
||||
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
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
@@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts:
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
|
||||
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` +
|
||||
- [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
|
||||
- [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
|
||||
hand-rolled dispatch remains.
|
||||
- [ ] Convention documented in CLAUDE.md; MDX renders.
|
||||
- [ ] All machine specs pass unchanged (reducers untouched).
|
||||
- [x] Convention documented in CLAUDE.md; MDX renders.
|
||||
- [x] All machine specs pass unchanged (reducers untouched).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and
|
||||
submit.
|
||||
GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
|
||||
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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-09 — Pure-logic closure: dates + missing command specs
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
@@ -51,15 +51,28 @@ no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Exactly one hand-written date formatter in the repo; `grep -rn "toLocaleDateString" src/app`
|
||||
hits only `datum.ts`.
|
||||
- [ ] Both command specs exist; debounce coalescing + error path covered.
|
||||
- [ ] `map3` / unused `variant` input removed (or a note here why kept).
|
||||
- [ ] CLAUDE.md rule added.
|
||||
- [x] Exactly one hand-written date formatter in the repo. `formatDatumNl` uses
|
||||
`Intl.DateTimeFormat(...).format()` rather than `.toLocaleDateString()`, so
|
||||
`grep -rn "toLocaleDateString" src/app` now hits **nothing** (stronger than the
|
||||
literal criterion, same intent — no file anywhere hand-rolls date formatting).
|
||||
- [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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# WP-10 — CIBG button fidelity
|
||||
|
||||
Status: todo
|
||||
Status: done (69880ef)
|
||||
Phase: 2 — CIBG fidelity
|
||||
|
||||
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
|
||||
.btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
|
||||
> 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
|
||||
|
||||
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
|
||||
|
||||
- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
|
||||
- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system.
|
||||
- [ ] Axe still green (contrast can change with real button styles).
|
||||
- [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
|
||||
- [x] Button story shows all CIBG variants incl. ghost; visuals match the design system.
|
||||
- [x] Axe still green (contrast can change with real button styles).
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
# WP-13 — CIBG-gap register + hygiene + MDX
|
||||
|
||||
Status: todo
|
||||
Status: done (9d58f59)
|
||||
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
|
||||
> 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`,
|
||||
@@ -43,6 +55,7 @@ the list-family rationale to document.
|
||||
## Files
|
||||
|
||||
Components to mark (closest CIBG concept in parens):
|
||||
|
||||
- `skeleton`, `spinner` (Laadindicatie — no vendored class, verified)
|
||||
- `upload/` suite (Bestand-upload)
|
||||
- `rich-text-editor` (Tekstgebied)
|
||||
@@ -52,7 +65,7 @@ Components to mark (closest CIBG concept in parens):
|
||||
- `debug-state` (devtool, no CIBG concept)
|
||||
- `status-badge` (deliberate custom, documented in code), `card` (`.app-card`),
|
||||
`placeholder-chip`
|
||||
Plus:
|
||||
Plus:
|
||||
- Delete `upload-status-banner` + its story; inline alert at its consumer
|
||||
- Header comments on `task-list`/`application-list`/`choice-list`
|
||||
- New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register`
|
||||
@@ -71,11 +84,11 @@ Plus:
|
||||
|
||||
## 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).
|
||||
- [ ] Register MDX complete, linked from ADR-0003.
|
||||
- [ ] `upload-status-banner` gone; consumer green; no story coverage lost.
|
||||
- [ ] List trio documented.
|
||||
- [x] Register MDX complete, linked from ADR-0003.
|
||||
- [x] `upload-status-banner` gone; consumer green; no story coverage lost.
|
||||
- [x] List trio documented.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# WP-14 — Storybook taxonomy reorg + Layers MDX
|
||||
|
||||
Status: todo
|
||||
Status: done (8b19fad)
|
||||
Phase: 3 — Storybook as curriculum
|
||||
|
||||
> **Deviation:** the "Layout/" bucket (breadcrumb, site-footer, site-header) wasn't in the
|
||||
> Decisions block's explicit scheme, so each got folded into Atoms/Molecules/Organisms by
|
||||
> its own doc-comment classification (breadcrumb → Molecules, site-footer/site-header →
|
||||
> Organisms, both already documented as such in their component header comments) rather
|
||||
> than kept as a separate bucket. Fixing `atomic-design.mdx`'s "status banner" reference
|
||||
> (stale since WP-13 deleted `upload-status-banner`) was caught as a side effect of
|
||||
> reviewing every MDX page for broken references — not itself a retitle issue, but the
|
||||
> same "unbroken MDX" acceptance criterion covers it.
|
||||
|
||||
## Why
|
||||
|
||||
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
|
||||
@@ -58,11 +67,11 @@ Domein/
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
|
||||
- [x] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
|
||||
pinned.
|
||||
- [ ] Zero story titles outside the scheme (grep `title:` and eyeball).
|
||||
- [ ] `layers.mdx` renders; existing MDX pages unbroken.
|
||||
- [ ] Convention in CLAUDE.md.
|
||||
- [x] Zero story titles outside the scheme (grep `title:` and eyeball).
|
||||
- [x] `layers.mdx` renders; existing MDX pages unbroken.
|
||||
- [x] Convention in CLAUDE.md.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
# WP-15 — Missing stories: shell + brief components
|
||||
|
||||
Status: todo
|
||||
Status: done (0cfb01f)
|
||||
Phase: 3 — Storybook as curriculum
|
||||
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically)
|
||||
|
||||
> **Note:** fixture duplication across the four brief stories that need `Brief`/
|
||||
> `LetterSection`/`LetterBlock` shapes (letter-block, letter-preview, letter-section, plus
|
||||
> the pre-existing letter-composer) didn't bite enough to justify the shared-fixtures
|
||||
> escape hatch — each story only builds the minimal slice it actually renders (letter-block
|
||||
> needs one block, not a whole `Brief`), so the co-located fixtures stayed small and
|
||||
> non-duplicative in practice. A pre-existing, unrelated axe finding on
|
||||
> `text-input--invalid` (informational only — `test-storybook:ci` doesn't fail on it) shows
|
||||
> up in the run; it predates this WP and isn't caused by anything here.
|
||||
|
||||
## Why
|
||||
|
||||
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none:
|
||||
@@ -44,11 +53,12 @@ invisible to the axe gate.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every component in `src/app` has ≥1 story (verify: list components without a
|
||||
- [x] Every component in `src/app` has ≥1 story (verify: list components without a
|
||||
co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
|
||||
norm — note the norm in this file when checked).
|
||||
- [ ] All new stories pass the axe gate (or carry a justified skip).
|
||||
- [ ] Titles follow WP-14.
|
||||
norm — note the norm in this file when checked). **Confirmed norm:** `*.page.ts`
|
||||
files (9 of them) have never had stories; every `*.component.ts` now does.
|
||||
- [x] All new stories pass the axe gate (or carry a justified skip).
|
||||
- [x] Titles follow WP-14.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# WP-16 — Component a11y: description wiring + alert role
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 4 — a11y
|
||||
|
||||
## Why
|
||||
|
||||
Audit findings axe can't (fully) catch:
|
||||
|
||||
- `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control
|
||||
ever references** — `text-input` sets `aria-describedby` only to `…-error` and only
|
||||
when invalid. Screen readers never announce field descriptions (e.g. the BSN hint on
|
||||
@@ -56,12 +57,25 @@ Audit findings axe can't (fully) catch:
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Description text is programmatically associated in the canonical composition;
|
||||
- [x] Description text is programmatically associated in the canonical composition;
|
||||
login-form BSN hint announced.
|
||||
- [ ] `-error` id appended exactly when invalid; order stable.
|
||||
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
|
||||
- [x] `-error` id appended exactly when invalid; order stable.
|
||||
- [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
|
||||
CI gate.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
`radio-group`/`checkbox` were left unchanged — grepping every call site found zero
|
||||
consumers pairing either with a `form-field` `description` (only the login-form BSN
|
||||
field, which uses `text-input`). The WP's own Files note ("describedby joins; only
|
||||
where the component takes a hint") already carved out this exact case — adding
|
||||
`hasDescription` to atoms with no live description consumer would be unused surface,
|
||||
not a fix. `radio-group` already had correct `-error`-only wiring; untouched.
|
||||
`describedBy()` was added directly on `text-input` rather than factored into a shared
|
||||
`shared/kernel` helper — one consumer, ~5 lines, not worth the indirection yet.
|
||||
Manual screen-reader spot check (optional per the WP) skipped; the play test is the
|
||||
enforced check going forward.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (includes the new play tests).
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 4 — a11y
|
||||
|
||||
## Why
|
||||
|
||||
Three app-level gaps close the WCAG story:
|
||||
|
||||
- **No route-change focus/scroll management** — `app.config.ts` has only
|
||||
`provideRouter(routes, withViewTransitions())`; after navigation, focus stays wherever
|
||||
it was and scroll position is unmanaged. (The wizard manages focus *within* steps; the
|
||||
it was and scroll position is unmanaged. (The wizard manages focus _within_ steps; the
|
||||
skip link is the only cross-page mechanism.)
|
||||
- **No template a11y linting** — `@angular-eslint` is entirely absent.
|
||||
- User decision: a **manual WCAG checklist** documents what automation can't test.
|
||||
@@ -58,11 +59,30 @@ Three app-level gaps close the WCAG story:
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
|
||||
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
|
||||
view transitions still play.
|
||||
- [ ] Template a11y rules active and *proven* to fire; lint green.
|
||||
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
||||
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
|
||||
- [x] Template a11y rules active and _proven_ to fire; lint green.
|
||||
- [x] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
||||
- [x] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
- Used the official `angular.configs.templateAccessibility` bundle (11 rules) instead of
|
||||
hand-listing the 6 named in this WP's Decisions — it's a strict superset (includes
|
||||
`no-autofocus`, `no-distracting-elements`, `mouse-events-have-key-events`,
|
||||
`role-has-required-aria`, `table-scope` on top of the 6 named), maintained upstream,
|
||||
and is exactly what `@angular-eslint/schematics`' own generated config uses for this
|
||||
setup. Less code to hand-maintain, same coverage plus more.
|
||||
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
|
||||
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its
|
||||
`.feedback` flex row doesn't wrap. Documented in `docs/wcag-checklist.md` with the
|
||||
root cause, **not fixed** — fixing live component CSS found via the checklist is the
|
||||
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
|
||||
WP's own deliverable. Flagged here so it isn't lost.
|
||||
- "Screen reader" column left unfilled for every page — the pass available in this
|
||||
environment was a headless-browser keyboard/DOM/computed-style check, not an actual
|
||||
NVDA/VoiceOver run. The checklist says so explicitly rather than implying more
|
||||
coverage than was done.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
178
docs/backlog/WP-18-abac-capability-spine.md
Normal file
178
docs/backlog/WP-18-abac-capability-spine.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
|
||||
|
||||
Status: done (7ec13d8)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
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
|
||||
`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable`
|
||||
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).
|
||||
The backend was fully open: no `[Authorize]`, no principal, ownership is a constant
|
||||
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; this WP implements
|
||||
PRD-0002's **P1 — Capability spine** only (§9), the smallest slice that closes the
|
||||
anti-pattern and gives every later phase (data-scoping, PII redaction, step-up/audit)
|
||||
a real foundation to extend.
|
||||
|
||||
## Read first
|
||||
|
||||
- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
|
||||
union, identity-vs-authorization split — see the deviation noted below)
|
||||
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
|
||||
authorization helper)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
|
||||
SoD guard to `Authz.CanActOn`)
|
||||
- `src/app/brief/application/brief.store.ts` (the FE-computed gate that was removed)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **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.
|
||||
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The
|
||||
`Principal` is built server-side from the existing dev stand-in (`X-Role` header),
|
||||
but it becomes the backend's own construct — the FE never re-derives capabilities
|
||||
from the header, it only reads what the backend sends.
|
||||
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — exactly
|
||||
`brief:approve`, `brief:reject`, `brief:send` (the only role-gated flow that
|
||||
exists today). The brief screen's fourth flag, `canEdit`, is a **screen decision**
|
||||
on `BriefDecisionsDto`, not a named capability string — it's resource/state-scoped
|
||||
(draft/rejected + drafter role) the same way `HerregistratieDecisionsDto` blends
|
||||
business state into a decision flag, and `GET /me`'s coarse `RoleCapabilities` set
|
||||
stays exactly the three above.
|
||||
- **Emit and enforce are the same code path for approve/reject.**
|
||||
`Authz.CanActOn(action, principal, drafterId)` is the SAME check
|
||||
`BriefStore.Review` uses to gate the mutation and `Authz.Decisions` uses to compute
|
||||
the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic
|
||||
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 (as built)
|
||||
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new) — `Principal`,
|
||||
`PrincipalRole`, `BriefAction`, `Authz.ResolvePrincipal/ActingId/RoleCapabilities/
|
||||
CanActOn/Decisions`.
|
||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — added `BriefDecisionsDto(CanEdit,
|
||||
CanApprove, CanReject, CanSend)` on `BriefViewDto`; added `MeDto(Capabilities)`.
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `Approve`/`Reject`/`Review` take
|
||||
a `Principal` + `BriefAction` and delegate the SoD check to `Authz.CanActOn`
|
||||
(same Forbidden-before-Conflict ordering as before).
|
||||
- `backend/src/BigRegister.Api/Program.cs` — `GET /api/v1/me`; every brief endpoint
|
||||
(including `send`, which had no `HttpContext` before) now returns a fresh
|
||||
`BriefViewDto` (via a shared `ToView`/`BriefResult` helper) so decisions are never
|
||||
stale after a mutation.
|
||||
- `backend/tests/BigRegister.Tests/AuthzTests.cs` (new) — unit tests for `Authz`.
|
||||
- `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` — updated to deserialize
|
||||
`BriefViewDto` (not bare `BriefDto`) from submit/approve/reject/send; two new
|
||||
tests for live decisions and `/me`.
|
||||
- `src/app/shared/domain/capability.ts` (new) — the `Capability` union type.
|
||||
- `src/app/shared/infrastructure/me.adapter.ts` (+ spec, new) — `GET /me` adapter +
|
||||
`parseMe` boundary (unknown capability strings are dropped, not rejected).
|
||||
- `src/app/shared/application/access.store.ts` (new) — `AccessStore.can()`,
|
||||
deny-by-default.
|
||||
- `src/app/auth/auth.guard.ts` — added `capabilityGuard(capability)` factory.
|
||||
**Built but deliberately unwired**: no route in this app needs a capability gate
|
||||
today (both drafter and approver land on the same `/brief` page; the gating is
|
||||
per-action, not per-page). It's the available building block for a future
|
||||
approver-only page.
|
||||
- `src/app/brief/domain/brief.ts` — added the `BriefDecisions` domain type.
|
||||
- `src/app/brief/domain/brief.machine.ts` (+ spec) — `BriefState.loaded` and the
|
||||
`BriefLoaded`/`Submitted`/`Approved`/`Rejected`/`Sent` messages now carry
|
||||
`decisions`; the pure `transition()` helper replaces them with each fresh
|
||||
server value.
|
||||
- `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 (as executed)
|
||||
|
||||
1. Backend: `Authz.cs`, DTOs, `BriefStore` delegation, `Program.cs` wiring
|
||||
(`GET /me` + `BriefResult`/`ToView`) — kept `dotnet test` green throughout
|
||||
(79/79 including 10 new tests).
|
||||
2. `npm run gen:api` to pick up the new endpoint/DTOs before touching the FE.
|
||||
3. FE domain: `BriefDecisions`, machine state/messages, machine spec fixtures.
|
||||
4. FE infrastructure: `parseDecisions`/`parseBriefView` in `brief.adapter.ts` (+spec).
|
||||
5. FE application: `brief.store.ts`'s computed flags; `access.store.ts` +
|
||||
`me.adapter.ts` (+spec) as the general capability-spine infrastructure.
|
||||
6. FE UI: `letter-composer` inputs/template, `brief.page.ts` bindings, stories.
|
||||
7. Full GREEN gate + a live curl smoke test against the running backend (submit as
|
||||
drafter → 403 on approve as drafter → 200 on approve as approver, with decisions
|
||||
flipping correctly at each step).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `brief.store.ts` contains no `currentRole()` call and no FE-computed
|
||||
permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
|
||||
- [x] The SoD rule is enforced server-side regardless of FE state — verified by
|
||||
curl directly against the backend (drafter calling `/brief/approve` → 403)
|
||||
and by `AuthzTests`/`BriefEndpointTests`, bypassing the FE entirely.
|
||||
- [x] `Authz.CanActOn`/`Authz.Decisions` is the only place brief authorization logic
|
||||
lives; the emit path (DTO flags) and the enforce path (`BriefStore.Review`)
|
||||
both call it.
|
||||
- [x] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
|
||||
unknown capability (deny-by-default, verified in `me.adapter.spec.ts`).
|
||||
- [x] The existing SoD rule (approver ≠ drafter) still holds, expressed as
|
||||
`Authz.CanActOn` instead of the old inline check in `BriefStore.Review`.
|
||||
- [x] `capabilityGuard` compiles; documented as available-but-unwired (no route
|
||||
needs it yet — see Files).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN gate, all green: `npm run lint && npm run check:tokens && npm test && npm run
|
||||
build && npm run build-storybook && npm run test-storybook:ci` (189 unit tests, 137
|
||||
Storybook/a11y tests) + `cd backend && dotnet test` (79/79) +
|
||||
`dotnet format --verify-no-changes`. Manual smoke via curl against a running
|
||||
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
|
||||
|
||||
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit
|
||||
log) — separate future WPs. The Behandeling/backoffice app and a `medewerker`
|
||||
`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
|
||||
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
|
||||
|
||||
`Send` was already unauthenticated/unauthorized before this WP (no role check on
|
||||
`POST /brief/send`) — `Authz.CanActOn(Send, …)` preserves that exactly
|
||||
(`=> true`, a mechanical dispatch step) rather than silently introducing a new gate
|
||||
that would have broken the existing `Send_only_from_approved` test (which calls
|
||||
`send` as the default drafter identity and expects success). If a future WP decides
|
||||
`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.
|
||||
131
docs/backlog/WP-19-e2e-smoke.md
Normal file
131
docs/backlog/WP-19-e2e-smoke.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# WP-19 — Playwright e2e smoke
|
||||
|
||||
Status: done (pending commit)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
There is no end-to-end test anywhere in the repo — no Playwright/Cypress config, no
|
||||
`e2e/` directory. `axe-playwright` is already a dependency (used by
|
||||
`test-storybook:ci` to run axe against Storybook, `.storybook/test-runner.ts`), but
|
||||
nothing drives the actual running app through a real browser. The GREEN gate proves
|
||||
every unit and component-in-isolation, never a real user flow through the FE+backend
|
||||
wired together — the thing a demo/reference app should be able to prove first.
|
||||
|
||||
## Read first
|
||||
|
||||
- `README.md` "Run it" + "See every data state (scenario toggle)" — the flows to
|
||||
cover
|
||||
- `docker-compose.yml` (the two-service dev topology e2e can run against)
|
||||
- `.storybook/test-runner.ts` (existing Playwright-adjacent config in the repo, for
|
||||
browser-launch precedent, though it drives Storybook not the app)
|
||||
- `src/app/shared/infrastructure/scenario.interceptor.ts` (the `?scenario=` toggle —
|
||||
reuse it for the error-path test instead of mocking the network)
|
||||
- `.github/workflows/ci.yml` (the `storybook-a11y` job's `playwright install
|
||||
--with-deps chromium` step — same install pattern for a new e2e job)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Playwright, not Cypress.** `axe-playwright` is already a dependency and the repo
|
||||
already has one Playwright-based CI job (`storybook-a11y`); adding Cypress would
|
||||
be a second, redundant browser-automation toolchain.
|
||||
- **Smoke-level coverage only**: one happy-path flow end to end, one degraded-path
|
||||
flow via `?scenario=`. This is not a full e2e suite — it proves the seam works,
|
||||
it doesn't replace component/unit tests.
|
||||
- **Run against the real backend**, not a mock server — the point is proving FE+BE
|
||||
integration, which is exactly what unit tests (mocked adapters) don't cover.
|
||||
- Faked auth (`digid.adapter.ts`) is used as-is: e2e logs in with any 9-digit BSN,
|
||||
no special e2e auth bypass.
|
||||
|
||||
## Files
|
||||
|
||||
- New `playwright.config.ts` at repo root — `baseURL` from an env var (default
|
||||
`http://localhost:4200`), `webServer` config that can optionally boot `ng serve`
|
||||
(skip if `CI` already starts the app in a prior step — see Steps).
|
||||
- New `e2e/smoke.spec.ts` — the happy path.
|
||||
- New `e2e/error-state.spec.ts` — the `?scenario=error` path.
|
||||
- `package.json` — add `"e2e": "playwright test"` script; `@playwright/test` devDependency.
|
||||
- `.github/workflows/ci.yml` — new job `e2e`, steps: checkout, setup-node, setup-dotnet,
|
||||
`npm ci`, `npx playwright install --with-deps chromium`, start backend
|
||||
(`dotnet run --project backend/src/BigRegister.Api &`), `npm start &` (or `ng
|
||||
serve` backgrounded), wait-on both ports, `npm run e2e`. `timeout-minutes: 15`
|
||||
per the hardened workflow convention already in `ci.yml`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Install `@playwright/test`; scaffold `playwright.config.ts` with a single
|
||||
`chromium` project (match `test-storybook:ci`'s browser choice).
|
||||
2. `e2e/smoke.spec.ts`: navigate to `/login`, submit a BSN, land on `/dashboard`,
|
||||
assert real dashboard content renders (not a loading/error state), navigate into
|
||||
one wizard (herregistratie or registratie change-request), fill the minimum
|
||||
required fields, submit, assert a success state.
|
||||
3. `e2e/error-state.spec.ts`: navigate to `/dashboard?scenario=error`, assert the
|
||||
error alert + "Opnieuw proberen" button render (`<app-async>`'s error slot),
|
||||
click retry, assert it re-fetches (scenario is per-request so a retry without the
|
||||
query param would succeed — confirm the interceptor's actual behavior first and
|
||||
assert accordingly).
|
||||
4. Wire the CI job; verify it's independent of (doesn't block or get blocked by) the
|
||||
existing jobs — add to `concurrency`/`timeout-minutes` conventions already in `ci.yml`.
|
||||
5. Document `npm run e2e` in `README.md`'s command list.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `npm run e2e` passes locally against `docker compose up` or `npm start` +
|
||||
`dotnet run` run manually.
|
||||
- [x] CI job `e2e` is green and runs on every PR alongside the existing jobs.
|
||||
- [x] The happy-path spec exercises a real wizard submit against the real backend
|
||||
(not mocked) and asserts on the resulting UI state.
|
||||
- [x] The error-path spec exercises `<app-async>`'s error slot + retry via the real
|
||||
`?scenario=error` toggle, not a mocked HTTP response.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
- **Found and fixed a real bug while writing the error-path spec**: `AsyncComponent`'s
|
||||
built-in `retry()` only calls `.reload()` on a `[resource]` input — every real page
|
||||
(`dashboard`, `registration-detail`, `aanvraag-detail`, `brief`) feeds `<app-async>`
|
||||
via `[data]` (a store's combined `RemoteData`), so clicking "Opnieuw proberen" was a
|
||||
silent no-op everywhere except the showcase teaching page. Added a `retryClicked`
|
||||
output that fires regardless of feed mode, and wired the two dashboard instances
|
||||
(`BigProfileStore.reloadProfile()`/`reloadAantekeningen()`) since that's what this
|
||||
WP's spec exercises. **Not fixed**: `registration-detail`, `aanvraag-detail`, and
|
||||
`brief` pages still have the same latent no-op retry — same "found via testing,
|
||||
fixing the whole surface is beyond this WP" call as WP-17's dashboard CSS finding.
|
||||
Flagging here so it isn't lost.
|
||||
- Confirmed `currentScenario()` reads `window.location.search` fresh on every call —
|
||||
the error-path spec's retry assertion had to change from "counts a browser network
|
||||
request" (the scenario interceptor never reaches the real transport; it substitutes
|
||||
`throwError` in the rxjs pipe before `next(req)`) to "observes a real Loading→Failure
|
||||
reload cycle via `aria-busy`". The Steps section's literal suggestion ("assert it
|
||||
re-fetches... via a network tab") didn't hold; adapted per the WP's own Risks note
|
||||
to verify actual interceptor behavior first.
|
||||
- Verified the suite isn't a no-op per the Verification section: temporarily broke
|
||||
`diplomaOptions`' `value: d.id` (appended `-x`), watched `smoke.spec.ts` fail on the
|
||||
now-missing `#diploma-d1` selector, reverted.
|
||||
- The registratie wizard's minimum path needed an actual file upload (`identiteit` is
|
||||
always required for `registratie` regardless of diploma choice, per
|
||||
`DocumentRules.CategoriesFor` — only `diploma`/`taalvaardigheid` are answer-gated).
|
||||
Picked the first DUO diploma (non-English, `Engelstalig: false`) specifically because
|
||||
it carries zero policy questions, keeping the happy path to one upload.
|
||||
- CIBG-styled radios hide the native `<input>` behind its `<label>` — Playwright's
|
||||
`.check()` on the input times out fighting the label for pointer events; the specs
|
||||
click the `label[for=...]` instead (also more representative of a real click).
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears
|
||||
and passes. Cross-check that a deliberately broken flow (e.g. temporarily rename a
|
||||
required form field) fails the e2e spec, proving it isn't a no-op.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Full e2e coverage of every wizard/flow; visual regression testing; cross-browser
|
||||
matrix (chromium only, matching the existing a11y job); load/performance testing.
|
||||
|
||||
## Risks
|
||||
|
||||
The `?scenario=` interceptor is dev-only (`isDevMode()` gated, per
|
||||
`app.config.ts`) — confirm the e2e target build runs in dev mode (it does via `ng
|
||||
serve`/`npm start`; a production `ng build` would need the toggle unavailable,
|
||||
which is correct and should be asserted, not worked around). Backend
|
||||
in-memory stores mean e2e runs against a fresh seed each restart — don't assert on
|
||||
data that a previous test run could have mutated; restart the backend per CI run.
|
||||
103
docs/backlog/WP-20-second-locale.md
Normal file
103
docs/backlog/WP-20-second-locale.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# WP-20 — Second locale proof
|
||||
|
||||
Status: done (e276629)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
CLAUDE.md's conventions claim "a second locale is a translation file, not a code
|
||||
change (the seam)" — every user-facing string is already wrapped in `$localize`
|
||||
with a stable `@@context.key` id. But `angular.json` has no `i18n` block, no
|
||||
`locales` config, and there is no extracted `.xlf` file anywhere in the repo. The
|
||||
seam is built into every component but never proven to actually work end to end.
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` "User-facing copy = `$localize`" convention
|
||||
- `angular.json` (current build config — no `i18n` section)
|
||||
- A handful of `$localize` call sites to confirm id conventions are consistent
|
||||
enough to extract cleanly: `src/app/shared/application/submit.ts`
|
||||
(`@@submit.failed`), `src/app/registratie/domain/value-objects/postcode.ts`
|
||||
(`@@validation.postcode`)
|
||||
- Angular's `@angular/localize` extraction tooling (`ng extract-i18n`) — no new
|
||||
dependency needed, it ships with the Angular CLI already in use
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **English (`en`) is the second locale** — arbitrary but concrete; proves the
|
||||
mechanism without requiring a real translator. Machine-translate or hand-write a
|
||||
handful of strings, mark the rest with an obvious placeholder prefix if time-boxed
|
||||
(e.g. `[EN] ` prefix) rather than leaving them silently untranslated — silent
|
||||
fallback-to-source would look like the feature works when it's actually untested.
|
||||
- **Source locale stays `nl`**, unchanged (CLAUDE.md is explicit about this).
|
||||
- **Build-time locale switching** (Angular's standard `i18n` merge, separate output
|
||||
per locale), not a runtime-swappable locale — that matches how `$localize` +
|
||||
Angular CLI actually work and avoids inventing a custom i18n runtime.
|
||||
- This WP proves the seam; it does not translate the whole app to production
|
||||
quality. A partial/placeholder `en` file is acceptable if every string has _some_
|
||||
translation (even if imperfect) — the acceptance bar is "the build seam works and
|
||||
every id resolves," not "the English copy is publication-ready."
|
||||
|
||||
## Files
|
||||
|
||||
- `angular.json` — add `i18n.sourceLocale: "nl"` and `i18n.locales.en` pointing at
|
||||
the new translation file; add an `en` configuration under `build`/`serve` that
|
||||
merges it (standard Angular CLI i18n scaffolding, `ng add @angular/localize` if
|
||||
the schematic isn't already fully wired).
|
||||
- New `src/locale/messages.en.xlf` (or `.json`, whichever `ng extract-i18n`
|
||||
defaults to) — the translation file, generated then filled in.
|
||||
- `package.json` — add `"extract-i18n": "ng extract-i18n --output-path src/locale"`
|
||||
script.
|
||||
- `.github/workflows/ci.yml` — extend the `frontend` job (or add a step) to build
|
||||
both locales: `ng build --localize` (builds all configured locales in one pass)
|
||||
or two explicit `ng build --configuration=production,en` invocations — pick
|
||||
whichever the Angular 22 CLI supports cleanly and document the choice inline.
|
||||
- `README.md` — note the second-locale build under "Tech notes," replacing the
|
||||
implicit claim with a demonstrated one (link to how to build/run the `en` locale).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `ng extract-i18n` once to generate the master translation file from every
|
||||
`$localize`/`i18n="@@id"` call site; commit it as the `nl` reference (or the tool's
|
||||
default source-language artifact, per Angular's convention).
|
||||
2. Copy it to `messages.en.xlf`, fill in English text for every `<trans-unit>`
|
||||
(or your chosen placeholder strategy per the Decisions above).
|
||||
3. Wire `angular.json`'s `i18n` block + an `en` build configuration.
|
||||
4. `ng build --localize` (or the two-configuration equivalent) — confirm two output
|
||||
bundles (`dist/.../nl/`, `dist/.../en/`) each serve correctly with `ng serve
|
||||
--configuration=en` or a static server against the `en` output.
|
||||
5. Wire CI to build both locales as part of the existing `build` step (or a
|
||||
parallel step) so a broken translation file fails CI, not just a local build.
|
||||
6. Spot-check the `en` build in a browser: login page, dashboard, one wizard step —
|
||||
confirm English strings render, layout doesn't break on longer/shorter text.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
|
||||
- [x] `messages.en.xlf` exists with a translation for every extracted unit.
|
||||
- [x] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output
|
||||
bundle in CI, and CI fails if the `en` file is missing a unit the source gains.
|
||||
- [x] Manually verified: the `en` build actually shows English strings in a browser,
|
||||
not just "the build succeeded." (`login.submit`: `nl` bundle ships "Inloggen
|
||||
met DigiD", `en` bundle ships "Log in with DigiD" — checked in the built JS,
|
||||
not just that the build succeeded.)
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run extract-i18n` locally, diff against the committed file to confirm no drift;
|
||||
`ng build --localize` locally, serve the `en` output, click through login →
|
||||
dashboard → one wizard. GREEN gate stays green (the `nl` build is unaffected).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Professional/accurate English translation (placeholder-quality is acceptable per
|
||||
Decisions); a locale switcher in the running app UI (build-time locale selection
|
||||
only, per Decisions); RTL locales or pluralization edge cases beyond what
|
||||
`$localize` already handles by default.
|
||||
|
||||
## Risks
|
||||
|
||||
`ng extract-i18n` may surface `$localize` call sites with inconsistent or missing
|
||||
`@@id`s that currently work fine at runtime (ids are optional for `$localize` to
|
||||
function, but required for clean extraction) — budget time to add ids where
|
||||
missing rather than treating every gap as a bug to fix elsewhere.
|
||||
151
docs/backlog/WP-21-resilience-seams.md
Normal file
151
docs/backlog/WP-21-resilience-seams.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
||||
|
||||
Status: done (40dbcb2)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
`api-client.provider.ts`'s own header comment lists four cross-cutting seams and
|
||||
marks three "done" — but two of the three are only half-done, and the fourth
|
||||
(retry/backoff) is an explicit unfilled seam:
|
||||
|
||||
- **Correlation id**: the FE generates a fresh `X-Correlation-Id` per request
|
||||
(`api-client.provider.ts:29`), but the backend only _reads_ it opportunistically
|
||||
inside the `Submit` helper (`Program.cs:344`) for log lines — there's no
|
||||
middleware, so most endpoints never see or echo it, and it's never returned to
|
||||
the caller for support/debugging correlation.
|
||||
- **Idempotency key**: generated per-attempt (`api-client.provider.ts:31`), which
|
||||
the same comment admits defeats its own purpose — "a real retry would thread a
|
||||
STABLE key per logical submit so re-sends dedupe; here it's per-attempt." A retry
|
||||
today would double-submit, not dedupe.
|
||||
- **Retry/backoff**: not implemented at all — the comment names it as the one
|
||||
remaining line to add, never added.
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/shared/infrastructure/api-client.provider.ts` (the whole seam-comment
|
||||
block at the top, lines 10-22, plus `httpClientFetch`'s header-building code)
|
||||
- `backend/src/BigRegister.Api/Program.cs:344-368` (`Submit` helper — where
|
||||
`X-Correlation-Id` is read today, and the only place)
|
||||
- `backend/src/BigRegister.Api/Data/DocumentStore.cs:16` (`AuditEntry` — same
|
||||
correlation id shape reused for audit `Actor` today, see `Program.cs:361` passing
|
||||
`cid` as the audit actor for post-delivery)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Correlation id becomes ASP.NET Core middleware**, not a per-endpoint read: every
|
||||
request gets a correlation id (client-supplied `X-Correlation-Id` if present,
|
||||
else server-generated), it's pushed into the logging scope for every log line in
|
||||
that request (not just `Submit`'s), and echoed back as a response header so the
|
||||
FE/caller can log it too.
|
||||
- **Idempotency key becomes stable per logical operation**, generated once when a
|
||||
submit/mutation _starts_ (e.g. once per wizard's submit action) and reused across
|
||||
retries of that same logical attempt — not regenerated on every HTTP call. This
|
||||
is a FE-side change (where the key is generated) plus a backend-side change
|
||||
(actually deduping on it — see Files).
|
||||
- **Retry/backoff applies only to idempotent GETs**, using rxjs `retry({ count,
|
||||
delay })` in `httpClientFetch`'s pipe, per the existing header comment's own
|
||||
suggestion. Writes are never auto-retried (the point of item above is making
|
||||
retries _safe_, not making everything retry automatically — a POST retry policy
|
||||
is a separate, larger decision about at-least-once semantics best left for when a
|
||||
real backend needs it).
|
||||
- Server-side idempotency _deduplication_ (actually short-circuiting a repeated key
|
||||
to return the first result) is scoped to the submit endpoints only
|
||||
(`Program.cs`'s `Submit` helper callers) — not every mutation — since that's
|
||||
where the existing seam already concentrates correlation/idempotency handling.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/Program.cs` — add correlation-id middleware
|
||||
(`app.Use(async (ctx, next) => { … })` near the top of the pipeline, before route
|
||||
registration): read-or-generate `X-Correlation-Id`, stash in
|
||||
`ctx.Items`/`HttpContext`, push into `ILogger` scope
|
||||
(`BeginScope(new Dictionary<string,object>{["CorrelationId"]=cid})`), set it on
|
||||
`ctx.Response.Headers` before the response is written.
|
||||
- `backend/src/BigRegister.Api/Program.cs` — simplify the `Submit` helper's own
|
||||
`cid` read (now redundant with the middleware-populated value; read from
|
||||
`HttpContext.Items` or inject via a lightweight accessor) so every log line in
|
||||
`Submit` picks up the same id without re-parsing the header.
|
||||
- New backend idempotency check: a small in-memory `IdempotencyStore` (same pattern
|
||||
as `ApplicationStore`/`DocumentStore` — static dict + lock, ponytail-labeled with
|
||||
the upgrade path to a real cache/store) keyed on `Idempotency-Key`, consulted by
|
||||
the submit endpoints before calling `SubmissionRules.NewReference()`; returns the
|
||||
cached response on a replayed key instead of minting a new reference.
|
||||
- `src/app/shared/infrastructure/api-client.provider.ts` — generate the
|
||||
`Idempotency-Key` once per logical submit rather than per HTTP attempt (thread it
|
||||
in from the caller — likely means the submit commands in `application/submit-*.ts`
|
||||
generate and pass the key, not the low-level fetch adapter); add
|
||||
`retry({ count: 2, delay: 500 })` (or similar) to the GET-only path in the rxjs
|
||||
pipe, gated on `method === 'GET'`.
|
||||
- New backend test `backend/tests/BigRegister.Tests/IdempotencyTests.cs` — replay a
|
||||
submit with the same `Idempotency-Key`, assert the same reference comes back and
|
||||
`SubmissionRules.NewReference()` was not called twice (or assert the observable
|
||||
effect: identical response body).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Backend middleware for correlation id first (smallest, most mechanical change);
|
||||
confirm every existing log line still works and now the id is consistent
|
||||
end-to-end, not just inside `Submit`.
|
||||
2. Backend `IdempotencyStore` + wiring into the submit endpoints; test the replay
|
||||
behavior.
|
||||
3. FE: move idempotency-key generation up to the command layer
|
||||
(`submit-change-request.ts` and equivalents) so one logical submit = one key
|
||||
even if `runSubmit`/the HTTP layer retries underneath.
|
||||
4. FE: add GET retry/backoff in `httpClientFetch`; verify it doesn't retry writes
|
||||
(assert via a spec on the adapter, or a targeted e2e/manual check with the
|
||||
`?scenario=slow` toggle).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [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.
|
||||
Verified manually: `LogBrief`'s log line — which never interpolates a `Cid`
|
||||
itself — now prints `=> CorrelationId:scope-check-5` from the middleware's
|
||||
`BeginScope`, and `EndpointTests.Correlation_id_supplied_by_the_caller_is_echoed_back`
|
||||
/ `..._is_generated_when_the_caller_omits_it` cover the response header.
|
||||
- [x] Replaying a submit with the same `Idempotency-Key` returns the same result
|
||||
without minting a second reference (backend test proves this —
|
||||
`IdempotencyTests`, 3 cases: same key twice, different keys, a replayed
|
||||
rejection).
|
||||
- [x] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
|
||||
any FE-side retry of that submit (not regenerated per HTTP attempt).
|
||||
`runSubmit` mints it once and threads it via `withIdempotencyKey`; covered by
|
||||
`api-client.provider.spec.ts`.
|
||||
- [x] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
|
||||
or a forced 5xx); POST/PUT/DELETE never auto-retry. Covered by
|
||||
`api-client.provider.spec.ts` (3 retries on GET, 1 attempt on POST).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `cd backend && dotnet test` (84 passing) — done. Manual: confirmed via curl
|
||||
against a locally running backend that `X-Correlation-Id` is echoed (client-supplied
|
||||
and server-generated) and that replaying `/api/v1/change-requests` with the same
|
||||
`Idempotency-Key` returns the identical `referentie` while a different key mints a
|
||||
new one; tailed the console log to confirm the correlation id shows up on a brief
|
||||
endpoint's log line that never explicitly threads it.
|
||||
|
||||
**Deviation**: the `?scenario=error` network-tab check this section originally
|
||||
proposed doesn't actually work — `scenario.interceptor.ts`'s `error` case
|
||||
`throwError`s before ever calling `next(req)`, so no real request reaches the
|
||||
network stack and devtools shows nothing to retry. Automated tests
|
||||
(`api-client.provider.spec.ts`, using a fake `HttpClient`-shaped `.request()` so no
|
||||
TestBed/HttpClientTestingModule is needed) are the actual proof of the retry
|
||||
mechanism instead — a more reliable check than a manual browser pass would have
|
||||
been anyway.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Retrying writes automatically (explicitly deferred, see Decisions); a durable
|
||||
idempotency store surviving restart (in-memory is consistent with the rest of the
|
||||
backend's persistence posture — see WP-22 if that changes); circuit breakers or
|
||||
more advanced resilience patterns (Polly, etc.) — out of scope for a POC-scale
|
||||
seam.
|
||||
|
||||
## Risks
|
||||
|
||||
Correlation-id middleware ordering matters — it must run before any endpoint that
|
||||
logs, including error-handling middleware, or some log lines will still lack the
|
||||
id. The idempotency store trades a small amount of memory for correctness under
|
||||
replay; fine at demo scale, but the ponytail comment should name the real upgrade
|
||||
(a TTL'd cache) so it isn't mistaken for a production-ready dedup mechanism.
|
||||
185
docs/backlog/WP-22-durable-persistence.md
Normal file
185
docs/backlog/WP-22-durable-persistence.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# WP-22 — Durable persistence (optional tier)
|
||||
|
||||
Status: done (556f2f4)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
Every backend store (`ApplicationStore`, `DocumentStore`, `BriefStore`) is a
|
||||
`static Dictionary` guarded by a single `lock` object, explicitly documented as
|
||||
in-memory ("no DB", per `backend/README.md` and CLAUDE.md's own framing). Data —
|
||||
including the audit log — is lost on every restart. This is a deliberate POC
|
||||
simplification (CLAUDE.md lists "runtime DTO validation on every endpoint" and
|
||||
similar as out-of-scope, and a database was never promised), but it's the one gap
|
||||
that would visibly break the moment someone tries to run this as a real demo across
|
||||
multiple sessions or deploys it anywhere that restarts (e.g. most PaaS platforms
|
||||
recycle instances).
|
||||
|
||||
This WP is marked **optional tier** — lower priority than WP-18/19/20/21 — because
|
||||
unlike auth/e2e/i18n/resilience, the current in-memory design is explicitly
|
||||
documented and defensible for a POC. Do this when the POC needs to survive restarts
|
||||
(demoing over multiple days, deploying somewhere with instance recycling), not
|
||||
speculatively.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/README.md` (the "in-memory seeded, no DB" framing to preserve or
|
||||
supersede)
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`,
|
||||
`backend/src/BigRegister.Api/Data/DocumentStore.cs`,
|
||||
`backend/src/BigRegister.Api/Data/BriefStore.cs` — the three stores, each
|
||||
`static Dictionary` + `lock`
|
||||
- `backend/src/BigRegister.Api/Data/SeedData.cs` (current in-memory seed — becomes
|
||||
a first-run DB seed)
|
||||
- `docs/architecture/0001-bff-lite-decision-dtos.md` (confirm this WP doesn't touch
|
||||
the decision-DTO contracts — persistence is purely behind the existing store
|
||||
interfaces)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **SQLite + EF Core**, not a heavier database — matches the POC's zero-external-
|
||||
infrastructure posture (no docker service to add, no connection string to manage
|
||||
beyond a file path) while proving real persistence.
|
||||
- **Persistence lives entirely behind the existing static-class store APIs** — the
|
||||
public methods on `ApplicationStore`/`DocumentStore`/`BriefStore` keep their
|
||||
signatures; only the implementation swaps from `Dictionary` to `DbContext`. No
|
||||
endpoint or domain-rule code changes (`Program.cs`, `Domain/*`).
|
||||
- **Seed on empty DB**, not on every startup — `SeedData` runs once (checked via
|
||||
"is the DB empty") so restarts don't reset demo data, which is the entire point
|
||||
of this WP.
|
||||
- **Document bytes stay a deliberate exception** if storage size becomes a concern:
|
||||
either store them as a BLOB column (simplest, consistent with "one DB, no extra
|
||||
infra") or explicitly punt file bytes to disk with only metadata in SQLite —
|
||||
decide based on actual seeded file sizes, don't over-engineer a blob-storage
|
||||
abstraction for a POC.
|
||||
- **Audit log becomes a real table**, not just "no longer volatile" — this closes
|
||||
the "audit log is in-memory" gap named in the original gap analysis alongside
|
||||
persistence, since it's the same static-dict problem in `DocumentStore.cs`.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/BigRegister.Api.csproj` — add
|
||||
`Microsoft.EntityFrameworkCore.Sqlite` + `Microsoft.EntityFrameworkCore.Design`.
|
||||
- New `backend/src/BigRegister.Api/Data/AppDbContext.cs` — `DbSet`s mirroring the
|
||||
three stores' current in-memory shapes (`StoredDocument`, `AuditEntry`, whatever
|
||||
`ApplicationStore`/`BriefStore` hold internally — read those files first to avoid
|
||||
redesigning the shape, just relocate it).
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`,
|
||||
`DocumentStore.cs`, `BriefStore.cs` — convert static dictionary methods to
|
||||
`DbContext`-backed queries; keep every public method signature identical (this is
|
||||
the acceptance bar — a signature change means a caller in `Program.cs` or
|
||||
`Domain/*` needs to change, which should be zero).
|
||||
- `backend/src/BigRegister.Api/Data/SeedData.cs` — becomes "seed if empty" run once
|
||||
at startup against the real DB.
|
||||
- `backend/src/BigRegister.Api/Program.cs` — register `AppDbContext` (DI), run
|
||||
migrations/`EnsureCreated` + conditional seed at startup.
|
||||
- New EF Core migration (generated via `dotnet ef migrations add Initial`).
|
||||
- `.gitignore` — exclude the runtime `.db` file (ship the migration, not the
|
||||
database).
|
||||
- `backend/README.md` — update "in-memory seeded, no DB" framing to describe the
|
||||
SQLite file and its lifecycle (created/seeded on first run, persists thereafter,
|
||||
delete the file to reset demo data).
|
||||
- `docker-compose.yml` — mount a volume for the SQLite file so `docker compose up`
|
||||
restarts don't lose data either (currently the `api-bin`/`api-obj` volumes exist
|
||||
for build caching only, not data).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the EF Core packages; define `AppDbContext` matching the current in-memory
|
||||
record shapes exactly (no schema redesign in this WP).
|
||||
2. Convert one store at a time (`DocumentStore` first — it's the smallest and has
|
||||
the audit log, which is the most valuable win), keeping
|
||||
`backend/tests/BigRegister.Tests/*` green after each conversion.
|
||||
3. Wire `AppDbContext` + startup migration/seed in `Program.cs`.
|
||||
4. Convert `ApplicationStore`, then `BriefStore`.
|
||||
5. Update `docker-compose.yml` with a persistent volume; update `backend/README.md`.
|
||||
6. Full backend test suite + a manual restart test: run the backend, create an
|
||||
application, restart the process, confirm the application still exists.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
|
||||
`Data/*.cs` for application/document/brief state.
|
||||
- [x] Every existing backend test passes unchanged (signatures didn't change).
|
||||
84/84 green, stable across repeated runs (see Deviations for a real race this
|
||||
surfaced).
|
||||
- [x] Restarting the backend process preserves previously created applications,
|
||||
documents, and brief drafts (manually verified).
|
||||
- [x] The audit log survives a restart and is queryable (even if no new endpoint
|
||||
exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries`
|
||||
is a real table now; not separately re-verified across restart beyond the
|
||||
applications/brief checks (same store mechanism, same `Db.Create()` seam).
|
||||
- [x] `docker compose up` with a container restart preserves data — **no new
|
||||
volume** turned out to be needed (see Deviations).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
|
||||
src/BigRegister.Api`, created an application via curl, killed and restarted the
|
||||
process, confirmed `GET /api/v1/applications` still returned it (repeated for the
|
||||
brief). Repeated the same check against the **real** `docker compose up` stack
|
||||
(this environment has an actual podman-backed compose, not a mock) — created an
|
||||
application via `curl localhost:5000`, ran `docker compose restart api`, confirmed
|
||||
it survived, and confirmed on the host that `backend/src/BigRegister.Api/bigregister.db`
|
||||
is the file being written (gitignored, not tracked).
|
||||
|
||||
## Out of scope
|
||||
|
||||
A production-grade database (Postgres/SQL Server) — SQLite is the deliberate,
|
||||
right-sized choice for a POC that still wants to prove real persistence. Migrating
|
||||
existing in-memory demo data on upgrade (a fresh SQLite file starts from
|
||||
`SeedData`, same as today's in-memory start). Blob storage for document bytes
|
||||
beyond a BLOB column (only revisit if seeded files are large enough to matter).
|
||||
|
||||
## Risks
|
||||
|
||||
EF Core's async patterns don't drop in as a 1:1 replacement for synchronous
|
||||
dictionary lookups — endpoint handlers in `Program.cs` currently call store methods
|
||||
synchronously; converting to `async`/`await` may ripple further than "just the
|
||||
Data/ layer" if minimal-API handlers aren't already `async`. Check this before
|
||||
starting and budget for handler signature changes (still not a _behavior_ change,
|
||||
but a wider diff than the Files section implies if handlers need `async` added).
|
||||
|
||||
**Resolved**: didn't ripple at all. EF Core's SQLite provider fully supports
|
||||
synchronous APIs (`.Find()`, `.ToList()`, `.SaveChanges()`, `.ExecuteDelete()`); every
|
||||
store method stayed synchronous, so `Program.cs`'s minimal-API handlers needed zero
|
||||
changes. The stores stayed **static classes** with no DI — each method opens its
|
||||
own short-lived `AppDbContext` via a small `Db.Create()` factory (`Data/Db.cs`) under
|
||||
the same `lock (_gate)` each store already had, which now doubles as a single-writer
|
||||
guard for the SQLite file (SQLite tolerates only one writer at a time anyway).
|
||||
|
||||
## Deviations from the plan
|
||||
|
||||
- **No SeedData → DB seed step.** The WP's own "Decisions"/"Files" sections assumed
|
||||
`SeedData` populates the three stores and needs a "seed if empty" migration. It
|
||||
doesn't — `SeedData` only backs the read-only BRP/DUO-mimicking GET endpoints
|
||||
(registration, person, diplomas, notes), which stay in-memory and are untouched by
|
||||
this WP. Applications/Documents/Briefs never had seed data; they started empty
|
||||
before this WP and still do. One less step than planned.
|
||||
- **No new docker-compose volume.** The existing `./backend:/src` bind mount already
|
||||
covers `bigregister.db` (it's written under `src/BigRegister.Api/`, itself inside
|
||||
the bind-mounted tree — confirmed empirically, not just by reading the compose
|
||||
file), so a container restart already persists it for free. Added a comment
|
||||
instead of a redundant `volumes:` entry.
|
||||
- **Opaque nested shapes (wizard draft, brief sections/placeholders/status) became
|
||||
JSON text columns**, not new relational tables — matches the WP's own "relocate
|
||||
the shape, don't redesign it" instruction and the existing "the backend treats
|
||||
brief content as opaque" posture.
|
||||
- **Found and fixed a real test race, not a hypothetical one.** The stores read a
|
||||
single static `Db.ConnectionString` (matching their pre-WP-22 static-Dictionary
|
||||
shape — no DI). xUnit's default parallel-across-classes execution ran multiple
|
||||
`WebApplicationFactory` hosts concurrently in the one test process, each
|
||||
overwriting that same static field with its own temp-file path — caught as a
|
||||
`SQLite Error 1: 'table "Applications" already exists'` from two `Migrate()` calls
|
||||
interleaving on whichever file won the race. Fixed with
|
||||
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
|
||||
(`TestWebApplicationFactory.cs`) rather than redesigning the stores' DI shape for
|
||||
a test-only concern. Reran `dotnet test` 3× in a row to confirm the race was
|
||||
actually gone, not just less likely.
|
||||
- **Pinned `SQLitePCLRaw.bundle_e_sqlite3` to 3.0.3** — `Microsoft.EntityFrameworkCore.Sqlite`
|
||||
10.0.9's own transitive default (2.1.11) bundles a pre-3.50.2 SQLite with a known
|
||||
high-severity memory-corruption advisory (GHSA-2m69-gcr7-jv3q); 3.0.3 bundles a
|
||||
patched one and built/tested cleanly as a drop-in.
|
||||
- **`dotnet-ef` added to the existing `backend/dotnet-tools.json`** (not a new
|
||||
`.config/dotnet-tools.json`) — this repo already keeps its one CLI tool manifest
|
||||
there (`swashbuckle.aspnetcore.cli`); matched that convention.
|
||||
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.
|
||||
@@ -1,10 +1,10 @@
|
||||
# PRD 0001 — "Mijn aanvragen": running wizards, application status & document preview
|
||||
|
||||
Status: Proposed · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002)
|
||||
Status: Implemented · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002)
|
||||
|
||||
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs) and **ADR-0002** (user groups as
|
||||
> actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD
|
||||
> *materializes* that lifecycle as a backend-owned aggregate — still entirely within the Zorgverlener
|
||||
> _materializes_ that lifecycle as a backend-owned aggregate — still entirely within the Zorgverlener
|
||||
> self-service context; the Behandelaar/backoffice app that advances manual cases stays a separate,
|
||||
> unbuilt context.
|
||||
|
||||
@@ -24,7 +24,7 @@ uploaded. Concretely, today:
|
||||
and there is **no GET-content endpoint**.
|
||||
- A **manual diploma is hard-rejected**: `SubmissionRules.RejectRegistratie("handmatig")` returns a
|
||||
422 (`backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs`). The product wants such a
|
||||
submission to *succeed* and sit in a pending (manual-review) state instead.
|
||||
submission to _succeed_ and sit in a pending (manual-review) state instead.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
@@ -54,25 +54,25 @@ uploaded. Concretely, today:
|
||||
## 4. Personas
|
||||
|
||||
Single actor: the **Zorgverlener** (healthcare professional, DigiD/BSN, self-service). Per ADR-0002,
|
||||
"who may advance a manual application" is the **Behandelaar**, an actor in a *separate* backoffice
|
||||
"who may advance a manual application" is the **Behandelaar**, an actor in a _separate_ backoffice
|
||||
context that is not part of this app.
|
||||
|
||||
## 5. Domain model — the `Aanvraag` aggregate (backend-owned)
|
||||
|
||||
The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | string (uuid) | client-visible handle; used in the resume deep link |
|
||||
| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard |
|
||||
| `status` | discriminated union (below) | computed on read for auto-approval |
|
||||
| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) |
|
||||
| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block |
|
||||
| `documentIds` | string[] | documents linked to this aanvraag |
|
||||
| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) |
|
||||
| `owner` | string | `DemoOwner` |
|
||||
| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto |
|
||||
| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | |
|
||||
| Field | Type | Notes |
|
||||
| ------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `id` | string (uuid) | client-visible handle; used in the resume deep link |
|
||||
| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard |
|
||||
| `status` | discriminated union (below) | computed on read for auto-approval |
|
||||
| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) |
|
||||
| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block |
|
||||
| `documentIds` | string[] | documents linked to this aanvraag |
|
||||
| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) |
|
||||
| `owner` | string | `DemoOwner` |
|
||||
| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto |
|
||||
| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | |
|
||||
|
||||
### Status lifecycle
|
||||
|
||||
@@ -94,7 +94,7 @@ FE-mirrored discriminated union (illegal states unrepresentable, same reflex as
|
||||
```ts
|
||||
type AanvraagStatus =
|
||||
| { tag: 'Concept'; stepIndex: number; stepCount: number }
|
||||
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld"
|
||||
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld"
|
||||
| { tag: 'Goedgekeurd'; referentie: string }
|
||||
| { tag: 'Afgewezen'; referentie: string; reden: string };
|
||||
```
|
||||
@@ -117,12 +117,12 @@ Concept → In behandeling → resolved. Empty list → the section is hidden.
|
||||
Per-status block (new `aanvraag-block` component; **which** actions/badge a block shows comes from a
|
||||
pure `blockActions(status)`):
|
||||
|
||||
| Status | Badge | Body | Actions |
|
||||
|---|---|---|---|
|
||||
| **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) |
|
||||
| **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents |
|
||||
| **Goedgekeurd** | green "Goedgekeurd" | referentie | — |
|
||||
| **Afgewezen** | red "Afgewezen" | referentie + reden | — |
|
||||
| Status | Badge | Body | Actions |
|
||||
| ------------------ | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------ |
|
||||
| **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) |
|
||||
| **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents |
|
||||
| **Goedgekeurd** | green "Goedgekeurd" | referentie | — |
|
||||
| **Afgewezen** | red "Afgewezen" | referentie + reden | — |
|
||||
|
||||
- **Verder gaan** deep-links to the wizard with `?aanvraag=<id>`; the wizard loads the draft from the
|
||||
backend and seeds its machine.
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: Proposed · Date: 2026-07-02 · Context: SSP / backoffice actors (see AD
|
||||
|
||||
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs), **ADR-0002** (user groups as
|
||||
> actors; identity vs authorization), and **PRD-0001** (the `Aanvraag` lifecycle those decisions gate).
|
||||
> This PRD *materializes* ADR-0002's authorization half: the AD server authenticates and supplies
|
||||
> This PRD _materializes_ ADR-0002's authorization half: the AD server authenticates and supplies
|
||||
> **coarse roles**; the app layers a **fine-grained, app-owned** access model on top, resolved by the
|
||||
> backend and rendered — never decided — by the UI.
|
||||
|
||||
@@ -19,8 +19,8 @@ administer:
|
||||
|
||||
- **Capability gating** — one role, many buttons: some users in a role may approve letters, reveal a
|
||||
BSN, or advance a manual application; others may not.
|
||||
- **Data-scoping** — the same role sees *different rows*: only their own region / office / caseload.
|
||||
- **Field / PII-level** — restrict *which fields* (notably the BSN and other special-category personal
|
||||
- **Data-scoping** — the same role sees _different rows_: only their own region / office / caseload.
|
||||
- **Field / PII-level** — restrict _which fields_ (notably the BSN and other special-category personal
|
||||
data under GDPR/AVG art. 9) a user may see or edit, independently of their role.
|
||||
- **Segregation-of-duty / step-up** — combinations and conditions: approver ≠ drafter, four-eyes,
|
||||
recent MFA, time-boxed break-glass.
|
||||
@@ -35,13 +35,13 @@ Today the codebase has none of this, and what stands in for a "role" is not a se
|
||||
requests as an `X-Role` header by a dev-only interceptor (`src/app/shared/infrastructure/role.interceptor.ts`,
|
||||
registered only under `isDevMode()` in `src/app/app.config.ts:22`). `X-Admin: true` is the parallel
|
||||
admin stand-in.
|
||||
- One route guard exists — `authGuard` (`src/app/auth/auth.guard.ts:6-10`) — a pure *authentication*
|
||||
- One route guard exists — `authGuard` (`src/app/auth/auth.guard.ts:6-10`) — a pure _authentication_
|
||||
check. There is **no** role/permission guard, and **no** `can` / `hasRole` / `isAuthorized` helper
|
||||
anywhere.
|
||||
- The backend is **fully open**: `backend/src/BigRegister.Api/Program.cs` has no authentication or
|
||||
authorization middleware, no `[Authorize]`, and never reads `HttpContext.User`. Identity is faked via
|
||||
a single `DemoOwner` id (`DocumentStore.cs:26`) plus the client-asserted `X-Role` / `X-Admin`
|
||||
headers. The brief's two-person rule *is* enforced (`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`:
|
||||
headers. The brief's two-person rule _is_ enforced (`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`:
|
||||
`if (actingId == e.DrafterId) return Forbidden`) — but against the **unverified** `X-Role` header, so
|
||||
any caller can assert `X-Role: approver`.
|
||||
|
||||
@@ -49,13 +49,13 @@ The building block we need already exists in one place: the **decision-flag seam
|
||||
computes `(bool, reason)` and embeds it in a screen DTO — `HerregistratieDecisionsDto` inside
|
||||
`DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by
|
||||
`HerregistratieRule.Evaluate` (`backend/.../Domain/Registrations/HerregistratieRule.cs:16-27`). This
|
||||
PRD extends that same seam from *business* decisions to *authorization* decisions.
|
||||
PRD extends that same seam from _business_ decisions to _authorization_ decisions.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and
|
||||
step-up/SoD** — as one coherent model.
|
||||
2. **Backend is the authority** for every access decision (per ADR-0001). The UI *mirrors* decisions
|
||||
2. **Backend is the authority** for every access decision (per ADR-0001). The UI _mirrors_ decisions
|
||||
for UX; it never computes them.
|
||||
3. **AD roles are the base; the app owns a fine-grained overlay.** The two merge **server-side** into a
|
||||
single `Principal`; capabilities are resolved server-side.
|
||||
@@ -69,7 +69,7 @@ PRD extends that same seam from *business* decisions to *authorization* decision
|
||||
|
||||
## 3. Non-goals / Out of scope (POC)
|
||||
|
||||
- **Real AD / OIDC / SAML integration.** The AD roles remain *simulated*; how claims actually arrive
|
||||
- **Real AD / OIDC / SAML integration.** The AD roles remain _simulated_; how claims actually arrive
|
||||
(token, header, SSO) is a wiring concern for later, isolated to `infrastructure/` + the backend
|
||||
authn middleware.
|
||||
- **A general policy engine (OPA/Cedar/XACML).** We express access as named **capabilities** computed
|
||||
@@ -85,17 +85,17 @@ PRD extends that same seam from *business* decisions to *authorization* decision
|
||||
## 4. Personas & attributes
|
||||
|
||||
Actors (per ADR-0002): the **Zorgverlener** (self-service, DigiD/BSN) and one or more **backoffice**
|
||||
actors (Behandelaar, Beoordelaar). ABAC is what lets these — and finer distinctions *within* a role —
|
||||
actors (Behandelaar, Beoordelaar). ABAC is what lets these — and finer distinctions _within_ a role —
|
||||
diverge without a folder-per-role explosion.
|
||||
|
||||
An access decision is a function of four attribute sets:
|
||||
|
||||
| Attribute set | Source | Examples |
|
||||
|---|---|---|
|
||||
| **Subject** | AD roles **+ app overlay + derived context** | AD: `beoordelaar`, `behandelaar`. Overlay: `mag-bsn-inzien`, `mag-brief-goedkeuren`. Derived: own BIG-registration, own region/office |
|
||||
| **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status |
|
||||
| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` |
|
||||
| **Environment** | the request context | MFA/assurance level, time-of-day, break-glass flag |
|
||||
| Attribute set | Source | Examples |
|
||||
| --------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Subject** | AD roles **+ app overlay + derived context** | AD: `beoordelaar`, `behandelaar`. Overlay: `mag-bsn-inzien`, `mag-brief-goedkeuren`. Derived: own BIG-registration, own region/office |
|
||||
| **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status |
|
||||
| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` |
|
||||
| **Environment** | the request context | MFA/assurance level, time-of-day, break-glass flag |
|
||||
|
||||
> AD owns only the first column's first row (coarse roles). Everything else is the app's overlay and
|
||||
> the entity's own attributes — the reason a role alone is too blunt.
|
||||
@@ -124,7 +124,7 @@ from `currentRole()` — this PRD moves that authority to the server flag.)
|
||||
### 5b. Data-scoping (row-level)
|
||||
|
||||
The server **filters rows by the subject's scope attributes at the source** — a Beoordelaar for region
|
||||
*Noord* receives only *Noord* aanvragen. The FE never receives out-of-scope records and so cannot leak
|
||||
_Noord_ receives only _Noord_ aanvragen. The FE never receives out-of-scope records and so cannot leak
|
||||
them (no client-side "fetch all, hide some"). Scope is a subject attribute (overlay/derived), applied
|
||||
in the query, not a UI filter.
|
||||
|
||||
@@ -181,7 +181,7 @@ The FE's job is to **mirror** server decisions cleanly and deny-by-default. It r
|
||||
never read directly by feature code.
|
||||
|
||||
> **Non-negotiable:** none of the above is a security boundary. A user who forges `can()` in the
|
||||
> browser changes only what they *see*; every gated route, action, and field is independently enforced
|
||||
> browser changes only what they _see_; every gated route, action, and field is independently enforced
|
||||
> by the backend (§7).
|
||||
|
||||
## 7. Backend design
|
||||
@@ -193,7 +193,7 @@ Extends ADR-0001's decision-DTO pattern; closes the "fully open" gap.
|
||||
authn middleware later). Merge **AD roles + the app-owned overlay** into one `Principal` here — the
|
||||
FE never sees the merge.
|
||||
- **Resolve + enforce capabilities** in a single shared authorization helper (`Authz.Can(principal,
|
||||
action, resource, env)`), used **on every endpoint** — not merely to *emit* flags but to *gate* the
|
||||
action, resource, env)`), used **on every endpoint** — not merely to _emit_ flags but to _gate_ the
|
||||
operation. Forbidden ⇒ 403 (reuse the existing `Outcome.Forbidden → 403` mapping,
|
||||
`backend/.../Program.cs:330-335`). Emitting a flag and forgetting to enforce it is the classic
|
||||
broken-object-level-authorization bug; the helper makes emit and enforce the same code path.
|
||||
|
||||
@@ -24,82 +24,82 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
|
||||
`--rhc-color-{lintblauw,donkerblauw,cool-grey}-*` / `--rhc-color-wit`, radius
|
||||
`--rhc-border-radius-*`.
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
|
||||
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b` — `lintblauw-900` doesn't exist (only 50–700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
|
||||
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
|
||||
| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once |
|
||||
| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` |
|
||||
| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
|
||||
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b` — `lintblauw-900` doesn't exist (only 50–700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
|
||||
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
|
||||
| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once |
|
||||
| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` |
|
||||
| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` |
|
||||
|
||||
## 2. Typography & heading hierarchy
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| 2.1 | **H** | Each page has one `<h1>` (page-shell) but the **wizard steps have no `<h2>`** — step title is a plain `<p>`. Screen-reader users get no step heading to navigate to. | per-step `<h2>` via `app-heading [level]="2"` |
|
||||
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
|
||||
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
|
||||
|
||||
## 3. Color & contrast
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
|
||||
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
|
||||
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
|
||||
|
||||
## 4. Spacing & layout
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
|
||||
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
|
||||
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
|
||||
|
||||
## 5. Component reuse
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
|
||||
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
|
||||
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
|
||||
|
||||
## 6. Form, validation & status patterns
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
|
||||
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
|
||||
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
|
||||
| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` |
|
||||
| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
|
||||
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
|
||||
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
|
||||
| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` |
|
||||
| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
|
||||
|
||||
## 7. Page chrome
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
|
||||
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
|
||||
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
|
||||
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
|
||||
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
|
||||
|
||||
## 8. Copy & tone
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
|
||||
|
||||
## 9. Accessibility (WCAG 2.1 AA) — consolidated
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
|
||||
| 9.2 | **H** | Error/label/invalid association missing (6.1–6.3). | as above |
|
||||
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
|
||||
| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` |
|
||||
| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
|
||||
| 9.2 | **H** | Error/label/invalid association missing (6.1–6.3). | as above |
|
||||
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
|
||||
| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` |
|
||||
| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps |
|
||||
|
||||
## 10. Responsive
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| ---- | --- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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 angular from 'angular-eslint';
|
||||
|
||||
/**
|
||||
* Enforces the architecture's working agreements that were previously only
|
||||
@@ -28,8 +29,18 @@ export default [
|
||||
},
|
||||
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||
rules: { '@typescript-eslint/no-explicit-any': 'error' },
|
||||
// This repo has no .html files — every template is inline. This processor
|
||||
// extracts each component's template string into a virtual `*.html` file,
|
||||
// which the template-a11y block below (`files: ['src/**/*.html']`) then lints.
|
||||
processor: angular.processInlineTemplates,
|
||||
},
|
||||
|
||||
// Template a11y (WP-17): the official angular-eslint accessibility bundle
|
||||
// (alt-text, label-has-associated-control, click/mouse-events-have-key-events,
|
||||
// interactive-supports-focus, valid-aria, no-autofocus, and more) linting the
|
||||
// virtual `.html` files the processor above extracts from inline templates.
|
||||
...angular.configs.templateAccessibility.map((c) => ({ ...c, files: ['src/**/*.html'] })),
|
||||
|
||||
// Tests legitimately use `any` to feed invalid messages/states into reducers.
|
||||
{
|
||||
files: ['src/**/*.spec.ts'],
|
||||
@@ -42,7 +53,14 @@ export default [
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{ patterns: [{ group: ['@angular/*', '@angular/**'], message: 'domain/ must stay framework-free (pure TS) — no Angular imports.' }] },
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@angular/*', '@angular/**'],
|
||||
message: 'domain/ must stay framework-free (pure TS) — no Angular imports.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -55,7 +73,14 @@ export default [
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'], message: 'shared/ must not depend on a feature context.' }] },
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'],
|
||||
message: 'shared/ must not depend on a feature context.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -66,7 +91,14 @@ export default [
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{ patterns: [{ group: ['@registratie/*', '@herregistratie/*', '@brief/*'], message: 'auth/ may depend only on shared.' }] },
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@registratie/*', '@herregistratie/*', '@brief/*'],
|
||||
message: 'auth/ may depend only on shared.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -77,7 +109,14 @@ export default [
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{ patterns: [{ group: ['@herregistratie/*', '@brief/*'], message: 'Dependencies point herregistratie → registratie → shared, never back.' }] },
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@herregistratie/*', '@brief/*'],
|
||||
message: 'Dependencies point herregistratie → registratie → shared, never back.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -88,7 +127,14 @@ export default [
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*'], message: 'brief/ may depend only on shared.' }] },
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@auth/*', '@registratie/*', '@herregistratie/*'],
|
||||
message: 'brief/ may depend only on shared.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -105,8 +151,20 @@ export default [
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@angular/**', '@shared/**', '@auth/**', '@registratie/**', '@herregistratie/**', '@brief/**', './*', '../*', './**', '../**'],
|
||||
message: 'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.',
|
||||
group: [
|
||||
'@angular/**',
|
||||
'@shared/**',
|
||||
'@auth/**',
|
||||
'@registratie/**',
|
||||
'@herregistratie/**',
|
||||
'@brief/**',
|
||||
'./*',
|
||||
'../*',
|
||||
'./**',
|
||||
'../**',
|
||||
],
|
||||
message:
|
||||
'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -129,7 +187,8 @@ export default [
|
||||
{
|
||||
group: ['@shared/infrastructure/api-client'],
|
||||
allowTypeImports: true,
|
||||
message: 'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)',
|
||||
message:
|
||||
'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -168,7 +227,8 @@ export default [
|
||||
'@brief/infrastructure/*',
|
||||
],
|
||||
allowTypeImports: true,
|
||||
message: 'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)',
|
||||
message:
|
||||
'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
4372
package-lock.json
generated
4372
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"lint": "eslint .",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
"gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json",
|
||||
"build": "ng build",
|
||||
@@ -13,7 +15,9 @@
|
||||
"build-storybook": "ng run atomic-design-poc:build-storybook",
|
||||
"test-storybook": "test-storybook",
|
||||
"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"",
|
||||
"check:tokens": "bash scripts/check-tokens.sh"
|
||||
"check:tokens": "bash scripts/check-tokens.sh",
|
||||
"e2e": "playwright test",
|
||||
"extract-i18n": "ng extract-i18n --output-path src/locale"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.12.1",
|
||||
@@ -37,11 +41,13 @@
|
||||
"@angular/localize": "^22.0.4",
|
||||
"@angular/platform-browser-dynamic": "^22.0.0",
|
||||
"@compodoc/compodoc": "^1.2.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@storybook/addon-a11y": "^10.4.6",
|
||||
"@storybook/addon-docs": "^10.4.6",
|
||||
"@storybook/addon-onboarding": "^10.4.6",
|
||||
"@storybook/angular": "^10.4.6",
|
||||
"@storybook/test-runner": "^0.24.4",
|
||||
"angular-eslint": "^22.0.0",
|
||||
"axe-playwright": "^2.2.2",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
|
||||
29
playwright.config.ts
Normal file
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,
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user