Compare commits
3 Commits
546097434d
...
bf920696ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
43
.github/workflows/ci.yml
vendored
43
.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,17 @@ 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
|
||||
# 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 +51,43 @@ 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
|
||||
|
||||
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
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
59
README.md
59
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.
|
||||
@@ -48,28 +48,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 +81,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 +125,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 +139,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
|
||||
@@ -184,6 +184,7 @@ 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.)
|
||||
itself _is_ implemented.)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -12,20 +12,20 @@ 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>
|
||||
@@ -35,76 +35,76 @@ public sealed class Aanvraag
|
||||
/// </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 Dictionary<string, Aanvraag> _apps = 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) _apps[a.Id] = a;
|
||||
return a;
|
||||
}
|
||||
|
||||
public static Aanvraag? Get(string id, string owner)
|
||||
{
|
||||
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Aanvraag> List(string owner)
|
||||
{
|
||||
lock (_gate) return _apps.Values.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)
|
||||
{
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public static Aanvraag? Get(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)
|
||||
{
|
||||
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
|
||||
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;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Aanvraag> List(string owner)
|
||||
/// 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) return _apps.Values.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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,134 +11,134 @@ namespace BigRegister.Api.Data;
|
||||
/// </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");
|
||||
|
||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||
}
|
||||
|
||||
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 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 Dictionary<string, BriefEntity> _byOwner = 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;
|
||||
}
|
||||
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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, string actingId, string at) =>
|
||||
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, 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, string actingId, string comments, string at) =>
|
||||
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) _byOwner.Clear();
|
||||
}
|
||||
|
||||
/// 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.Clear();
|
||||
_byOwner.Remove(owner);
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
// 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)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_byOwner.Remove(owner);
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
return created;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 +147,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 +170,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 +193,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ 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);
|
||||
@@ -22,81 +22,81 @@ public sealed record AuditEntry(DateTimeOffset At, string Action, string Documen
|
||||
/// </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 Dictionary<string, StoredDocument> _docs = new();
|
||||
private static readonly List<AuditEntry> _audit = new();
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
|
||||
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
|
||||
{
|
||||
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
|
||||
lock (_gate) _docs[doc.DocumentId] = doc;
|
||||
Audit("upload", doc.DocumentId, categoryId, owner);
|
||||
return doc;
|
||||
}
|
||||
|
||||
public static StoredDocument? Get(string documentId)
|
||||
{
|
||||
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
|
||||
}
|
||||
|
||||
/// 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) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
|
||||
}
|
||||
|
||||
/// Mark digital documents as linked to a finalised submission (blocks user delete).
|
||||
public static void Link(IEnumerable<string> documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
foreach (var id in documentIds)
|
||||
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
string categoryId;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
public static StoredDocument? Get(string documentId)
|
||||
/// 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)
|
||||
{
|
||||
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
|
||||
if (!_docs.TryGetValue(documentId, out var d)) return false;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
}
|
||||
Audit("delete-admin", documentId, categoryId, actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 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) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
|
||||
}
|
||||
public static void Audit(string action, string documentId, string categoryId, string actor)
|
||||
{
|
||||
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||
}
|
||||
|
||||
/// Mark digital documents as linked to a finalised submission (blocks user delete).
|
||||
public static void Link(IEnumerable<string> documentIds)
|
||||
{
|
||||
lock (_gate)
|
||||
foreach (var id in documentIds)
|
||||
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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)
|
||||
{
|
||||
if (!_docs.TryGetValue(documentId, out var d)) return false;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
}
|
||||
Audit("delete-admin", documentId, categoryId, actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Audit(string action, string documentId, string categoryId, string actor)
|
||||
{
|
||||
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AuditEntry> AuditLog
|
||||
{
|
||||
get { lock (_gate) return _audit.ToList(); }
|
||||
}
|
||||
public static IReadOnlyList<AuditEntry> AuditLog
|
||||
{
|
||||
get { lock (_gate) return _audit.ToList(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,67 @@ 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 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>(),
|
||||
};
|
||||
_ => 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)
|
||||
{
|
||||
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>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
public static DocumentCategory? Find(string wizardId, string categoryId) =>
|
||||
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
|
||||
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;
|
||||
}
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ 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;
|
||||
});
|
||||
|
||||
const string SpaCors = "spa";
|
||||
@@ -42,10 +42,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 +96,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 +118,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 +129,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 +164,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 +179,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 +195,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,30 +210,30 @@ 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)
|
||||
@@ -245,15 +245,15 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
|
||||
api.MapGet("/brief", () =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||
})
|
||||
.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) = BriefRole(ctx);
|
||||
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
@@ -261,10 +261,10 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||
|
||||
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) = BriefRole(ctx);
|
||||
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
||||
LogBrief("submit", r);
|
||||
return BriefResult(r, "Alleen de opsteller mag indienen.");
|
||||
})
|
||||
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
||||
.Produces<BriefDto>()
|
||||
@@ -273,10 +273,10 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||
|
||||
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 (acting, _) = BriefRole(ctx);
|
||||
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
|
||||
LogBrief("approve", r);
|
||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
@@ -284,10 +284,10 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||
|
||||
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 (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.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
@@ -295,20 +295,20 @@ api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||
|
||||
api.MapPost("/brief/send", () =>
|
||||
{
|
||||
// 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.
|
||||
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
||||
LogBrief("send", r);
|
||||
return BriefResult(r, "Versturen kan niet in deze status.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/reset", () =>
|
||||
{
|
||||
// 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));
|
||||
// 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));
|
||||
})
|
||||
.WithName("briefReset")
|
||||
.Produces<BriefViewDto>();
|
||||
@@ -323,15 +323,15 @@ static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||
// 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);
|
||||
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
|
||||
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
|
||||
}
|
||||
|
||||
IResult BriefResult((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(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),
|
||||
};
|
||||
|
||||
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
|
||||
@@ -343,30 +343,30 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
|
||||
// real system ships this to structured logging / an audit store).
|
||||
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.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
: "none";
|
||||
|
||||
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 (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 (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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||
|
||||
@@ -8,129 +8,135 @@ namespace BigRegister.Tests;
|
||||
|
||||
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,150 +13,150 @@ namespace BigRegister.Tests;
|
||||
/// </summary>
|
||||
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
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<BriefDto>();
|
||||
Assert.Equal("submitted", submitted!.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<BriefDto>())!.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<BriefDto>();
|
||||
Assert.Equal("rejected", rejected!.Status.Tag);
|
||||
Assert.Equal("Graag aanvullen.", rejected.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<BriefDto>();
|
||||
Assert.Equal("draft", reopened!.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<BriefDto>())!.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 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,223 +9,223 @@ namespace BigRegister.Tests;
|
||||
|
||||
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
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 ---
|
||||
// --- Document upload ---
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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>())!;
|
||||
}
|
||||
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 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 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_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_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_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 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 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_content_404_for_unknown_document()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).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 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 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 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 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 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 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 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);
|
||||
}
|
||||
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");
|
||||
}
|
||||
[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- api-bin:/src/src/BigRegister.Api/bin
|
||||
- api-obj:/src/src/BigRegister.Api/obj
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- '5000:5000'
|
||||
|
||||
web:
|
||||
image: node:24
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
- ./:/app:z
|
||||
- web-modules:/app/node_modules
|
||||
ports:
|
||||
- "4200:4200"
|
||||
- '4200:4200'
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
|
||||
@@ -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,7 +42,7 @@ 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
|
||||
|
||||
@@ -38,25 +38,25 @@ only needs re-running if a WP unexpectedly touches `backend/`.
|
||||
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 | 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 |
|
||||
|
||||
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
|
||||
@@ -72,12 +72,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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -43,6 +43,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 +53,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`
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
|
||||
@@ -6,9 +6,10 @@ 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.
|
||||
@@ -60,7 +61,7 @@ Three app-level gaps close the WCAG story:
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: Proposed · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-000
|
||||
|
||||
> 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,7 +42,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 +62,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 +80,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 +98,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 +116,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 +140,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 +176,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 +216,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`.)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ApplicationConfig, LOCALE_ID, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
LOCALE_ID,
|
||||
isDevMode,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter, withViewTransitions } from '@angular/router';
|
||||
import type { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
@@ -43,5 +48,5 @@ export const appConfig: ApplicationConfig = {
|
||||
provideApiClient(),
|
||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||
]
|
||||
],
|
||||
};
|
||||
|
||||
@@ -8,15 +8,53 @@ export const routes: Routes = [
|
||||
component: ShellComponent, // persistent header/footer; only children swap
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'login' },
|
||||
{ path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) },
|
||||
{ path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) },
|
||||
{ path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) },
|
||||
{ path: 'aanvraag/:id', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/aanvraag-detail.page').then(m => m.AanvraagDetailPage) },
|
||||
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) },
|
||||
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
|
||||
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },
|
||||
{ path: 'brief', canActivate: [authGuard], loadComponent: () => import('@brief/ui/brief.page').then(m => m.BriefPage) },
|
||||
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) },
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage),
|
||||
},
|
||||
{
|
||||
path: 'registratie',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage),
|
||||
},
|
||||
{
|
||||
path: 'aanvraag/:id',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage),
|
||||
},
|
||||
{
|
||||
path: 'registreren',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage),
|
||||
},
|
||||
{
|
||||
path: 'herregistratie',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage),
|
||||
},
|
||||
{
|
||||
path: 'intake',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('@herregistratie/ui/intake.page').then((m) => m.IntakePage),
|
||||
},
|
||||
{
|
||||
path: 'brief',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
||||
},
|
||||
{
|
||||
path: 'concepts',
|
||||
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),
|
||||
},
|
||||
{ path: '**', redirectTo: 'login' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@ export class DigidAdapter {
|
||||
// Swap for a real OIDC redirect flow when there's a backend.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
const t = bsn.trim();
|
||||
if (!/^\d{9}$/.test(t)) return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
|
||||
if (!/^\d{9}$/.test(t))
|
||||
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
|
||||
return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,19 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action"><span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span></div>
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field i18n-label="@@login.bsnLabel" label="BSN" fieldId="bsn" required i18n-description="@@login.bsnDescription" description="9 cijfers (demo: vul iets in)">
|
||||
<app-form-field
|
||||
i18n-label="@@login.bsnLabel"
|
||||
label="BSN"
|
||||
fieldId="bsn"
|
||||
required
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9 cijfers (demo: vul iets in)"
|
||||
>
|
||||
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
|
||||
</app-form-field>
|
||||
|
||||
@@ -22,7 +31,9 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit">Inloggen met DigiD</app-button>
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
||||
>Inloggen met DigiD</app-button
|
||||
>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -9,9 +9,16 @@ import { SessionStore } from '@auth/application/session.store';
|
||||
selector: 'app-login-page',
|
||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-shell i18n-heading="@@login.heading" heading="Inloggen" width="narrow"
|
||||
i18n-intro="@@login.intro" intro="Log in op uw persoonlijke BIG-register omgeving.">
|
||||
@if (error()) { <app-alert type="error">{{ error() }}</app-alert> }
|
||||
<app-page-shell
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="login($event)" />
|
||||
</app-page-shell>
|
||||
`,
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Result } from '@shared/kernel/fp';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { Role } from '@shared/domain/role';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief';
|
||||
import {
|
||||
Brief,
|
||||
allDiagnostics,
|
||||
canSubmit,
|
||||
hasBlockingErrors,
|
||||
unresolvedPlaceholders,
|
||||
} from '@brief/domain/brief';
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
||||
|
||||
@@ -34,7 +40,9 @@ export class BriefStore {
|
||||
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
|
||||
readonly editable = computed(() => {
|
||||
const b = this.brief();
|
||||
return !!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter';
|
||||
return (
|
||||
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'
|
||||
);
|
||||
});
|
||||
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||
@@ -46,7 +54,12 @@ export class BriefStore {
|
||||
|
||||
async load() {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
|
||||
if (r.ok)
|
||||
this.store.dispatch({
|
||||
tag: 'BriefLoaded',
|
||||
brief: r.value.brief,
|
||||
availablePassages: r.value.availablePassages,
|
||||
});
|
||||
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
}
|
||||
|
||||
@@ -84,7 +97,12 @@ export class BriefStore {
|
||||
const r = await this.adapter.reset();
|
||||
this.busy.set(false);
|
||||
this.saveState.set('idle');
|
||||
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
|
||||
if (r.ok)
|
||||
this.store.dispatch({
|
||||
tag: 'BriefLoaded',
|
||||
brief: r.value.brief,
|
||||
availablePassages: r.value.availablePassages,
|
||||
});
|
||||
else this.lastError.set(r.error);
|
||||
}
|
||||
|
||||
@@ -119,7 +137,12 @@ export class BriefStore {
|
||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
|
||||
break;
|
||||
case 'rejected':
|
||||
this.store.dispatch({ tag: 'Rejected', by: s.rejectedBy, at: s.rejectedAt, comments: s.comments });
|
||||
this.store.dispatch({
|
||||
tag: 'Rejected',
|
||||
by: s.rejectedBy,
|
||||
at: s.rejectedAt,
|
||||
comments: s.comments,
|
||||
});
|
||||
break;
|
||||
case 'sent':
|
||||
this.store.dispatch({ tag: 'Sent', at: s.sentAt });
|
||||
|
||||
@@ -9,7 +9,9 @@ const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'reden', label: 'Reden', autoResolvable: false },
|
||||
];
|
||||
|
||||
const text = (t: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
const text = (t: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({
|
||||
passageId: id,
|
||||
@@ -35,7 +37,10 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
};
|
||||
}
|
||||
|
||||
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => ({
|
||||
const loaded = (
|
||||
status: BriefStatus = { tag: 'draft' },
|
||||
sections?: Brief['sections'],
|
||||
): BriefState => ({
|
||||
tag: 'loaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
@@ -46,14 +51,27 @@ const sectionBlocks = (s: BriefState, key: string) =>
|
||||
|
||||
describe('brief.machine reduce', () => {
|
||||
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [] }).tag).toBe('loaded');
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ tag: 'failed', reason: 'x' });
|
||||
expect(
|
||||
reduce(initialLoading(), {
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [],
|
||||
}).tag,
|
||||
).toBe('loaded');
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||
tag: 'failed',
|
||||
reason: 'x',
|
||||
});
|
||||
const seeded = loaded();
|
||||
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
|
||||
});
|
||||
|
||||
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => {
|
||||
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
|
||||
const s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
});
|
||||
const blocks = sectionBlocks(s, 'aanhef');
|
||||
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
|
||||
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
|
||||
@@ -62,7 +80,11 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => {
|
||||
const passage = libPassage('p1', 'aanhef');
|
||||
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [passage] });
|
||||
const s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [passage],
|
||||
});
|
||||
// Mutate the source passage object after insertion.
|
||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||
const block = sectionBlocks(s, 'aanhef')[0];
|
||||
@@ -77,7 +99,11 @@ describe('brief.machine reduce', () => {
|
||||
});
|
||||
|
||||
it('BlockContentEdited replaces content and marks a passage block edited', () => {
|
||||
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] });
|
||||
let s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef')],
|
||||
});
|
||||
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
|
||||
const block = sectionBlocks(s, 'aanhef')[0];
|
||||
expect(block.type === 'passage' && block.edited).toBe(true);
|
||||
@@ -85,7 +111,11 @@ describe('brief.machine reduce', () => {
|
||||
});
|
||||
|
||||
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
|
||||
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
|
||||
let s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
});
|
||||
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
|
||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
||||
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
|
||||
@@ -94,17 +124,33 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
|
||||
const lockedSections: Brief['sections'] = [
|
||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }] },
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
|
||||
},
|
||||
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
||||
];
|
||||
const s = loaded({ tag: 'draft' }, lockedSections);
|
||||
// The brief value is left untouched (withEdit reallocates state, but the guard returns
|
||||
// the same brief), so assert on deep equality of the section contents.
|
||||
expect(reduce(s, { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] })).toEqual(s);
|
||||
expect(
|
||||
reduce(s, {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef')],
|
||||
}),
|
||||
).toEqual(s);
|
||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') })).toEqual(s);
|
||||
expect(
|
||||
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
|
||||
).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(
|
||||
s,
|
||||
);
|
||||
// the unlocked section still accepts edits
|
||||
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
|
||||
@@ -116,7 +162,12 @@ describe('brief.machine reduce', () => {
|
||||
});
|
||||
|
||||
it('editing a rejected letter reopens it to draft', () => {
|
||||
const s = loaded({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'graag aanpassen' });
|
||||
const s = loaded({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't',
|
||||
comments: 'graag aanpassen',
|
||||
});
|
||||
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
|
||||
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||
@@ -128,7 +179,11 @@ describe('brief.machine reduce', () => {
|
||||
// fill the required section, then submit
|
||||
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'u1',
|
||||
submittedAt: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('approve/reject fire only from submitted; send only from approved', () => {
|
||||
@@ -136,10 +191,19 @@ describe('brief.machine reduce', () => {
|
||||
// approve from draft is a no-op
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ tag: 'approved', approvedBy: 'u2', approvedAt: 't2' });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||
tag: 'approved',
|
||||
approvedBy: 'u2',
|
||||
approvedAt: 't2',
|
||||
});
|
||||
// reject carries comments
|
||||
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't2', comments: 'nee' });
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't2',
|
||||
comments: 'nee',
|
||||
});
|
||||
// send only from approved
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3' });
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, allBlocks, canSubmit } from './brief';
|
||||
import {
|
||||
Brief,
|
||||
BriefStatus,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
LibraryPassage,
|
||||
allBlocks,
|
||||
canSubmit,
|
||||
} from './brief';
|
||||
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
@@ -59,8 +67,15 @@ function nextLocalIndex(brief: Brief): number {
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => LetterSection): Brief {
|
||||
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
|
||||
function mapSection(
|
||||
brief: Brief,
|
||||
sectionKey: string,
|
||||
f: (s: LetterSection) => LetterSection,
|
||||
): Brief {
|
||||
return {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The section a block currently lives in, or undefined if the block is gone. */
|
||||
@@ -86,7 +101,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||
return { ...s, brief };
|
||||
}
|
||||
|
||||
function insertPassages(brief: Brief, sectionKey: string, passages: readonly LibraryPassage[]): Brief {
|
||||
function insertPassages(
|
||||
brief: Brief,
|
||||
sectionKey: string,
|
||||
passages: readonly LibraryPassage[],
|
||||
): Brief {
|
||||
let idx = nextLocalIndex(brief);
|
||||
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
|
||||
// so later library edits can never mutate this letter (frozen snapshot).
|
||||
@@ -102,7 +121,11 @@ function insertPassages(brief: Brief, sectionKey: string, passages: readonly Lib
|
||||
}
|
||||
|
||||
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
||||
const block: LetterBlock = { type: 'freeText', blockId: `local-${nextLocalIndex(brief)}`, content: emptyBlock() };
|
||||
const block: LetterBlock = {
|
||||
type: 'freeText',
|
||||
blockId: `local-${nextLocalIndex(brief)}`,
|
||||
content: emptyBlock(),
|
||||
};
|
||||
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
|
||||
}
|
||||
|
||||
@@ -118,7 +141,11 @@ function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock)
|
||||
);
|
||||
}
|
||||
|
||||
function moveWithinSection(blocks: readonly LetterBlock[], blockId: string, toIndex: number): LetterBlock[] {
|
||||
function moveWithinSection(
|
||||
blocks: readonly LetterBlock[],
|
||||
blockId: string,
|
||||
toIndex: number,
|
||||
): LetterBlock[] {
|
||||
const from = blocks.findIndex((b) => b.blockId === blockId);
|
||||
if (from === -1) return [...blocks];
|
||||
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
|
||||
@@ -140,12 +167,18 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a
|
||||
// Msg reaches the reducer. The UI already hides controls for locked sections.
|
||||
case 'PassagesInserted':
|
||||
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b));
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b,
|
||||
);
|
||||
case 'FreeTextBlockAdded':
|
||||
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b));
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b,
|
||||
);
|
||||
case 'BlockContentEdited':
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? editBlockContent(b, m.blockId, m.content) : b,
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? editBlockContent(b, m.blockId, m.content)
|
||||
: b,
|
||||
);
|
||||
case 'BlockRemoved':
|
||||
return withEdit(s, (b) =>
|
||||
@@ -157,18 +190,34 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? mapBlocks(b, (blocks) =>
|
||||
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
||||
blocks.some((x) => x.blockId === m.blockId)
|
||||
? moveWithinSection(blocks, m.blockId, m.toIndex)
|
||||
: [...blocks],
|
||||
)
|
||||
: b,
|
||||
);
|
||||
|
||||
case 'Submitted':
|
||||
// Guard the transition AND the completeness invariant.
|
||||
return transition(s, 'draft', () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), canSubmit);
|
||||
return transition(
|
||||
s,
|
||||
'draft',
|
||||
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
|
||||
canSubmit,
|
||||
);
|
||||
case 'Approved':
|
||||
return transition(s, 'submitted', () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }));
|
||||
return transition(s, 'submitted', () => ({
|
||||
tag: 'approved',
|
||||
approvedBy: m.by,
|
||||
approvedAt: m.at,
|
||||
}));
|
||||
case 'Rejected':
|
||||
return transition(s, 'submitted', () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }));
|
||||
return transition(s, 'submitted', () => ({
|
||||
tag: 'rejected',
|
||||
rejectedBy: m.by,
|
||||
rejectedAt: m.at,
|
||||
comments: m.comments,
|
||||
}));
|
||||
case 'Sent':
|
||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, LetterBlock, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from './brief';
|
||||
import {
|
||||
Brief,
|
||||
LetterBlock,
|
||||
allDiagnostics,
|
||||
canSubmit,
|
||||
hasBlockingErrors,
|
||||
unresolvedPlaceholders,
|
||||
} from './brief';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
@@ -19,21 +26,47 @@ const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
|
||||
});
|
||||
|
||||
function brief(sections: Brief['sections']): Brief {
|
||||
return { briefId: 'b1', beroep: 'arts', templateId: 't1', placeholders, sections, status: { tag: 'draft' }, drafterId: 'u1' };
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
placeholders,
|
||||
sections,
|
||||
status: { tag: 'draft' },
|
||||
drafterId: 'u1',
|
||||
};
|
||||
}
|
||||
|
||||
describe('brief selectors', () => {
|
||||
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
||||
const b = brief([
|
||||
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'naam', 'reden')] },
|
||||
{ sectionKey: 's2', title: 'S2', required: false, locked: false, blocks: [passage('local-2', 'reden')] },
|
||||
{
|
||||
sectionKey: 's1',
|
||||
title: 'S1',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [passage('local-1', 'naam', 'reden')],
|
||||
},
|
||||
{
|
||||
sectionKey: 's2',
|
||||
title: 'S2',
|
||||
required: false,
|
||||
locked: false,
|
||||
blocks: [passage('local-2', 'reden')],
|
||||
},
|
||||
]);
|
||||
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
|
||||
});
|
||||
|
||||
it('allDiagnostics flattens across sections and blocks', () => {
|
||||
const b = brief([
|
||||
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'reden', 'onbekend')] },
|
||||
{
|
||||
sectionKey: 's1',
|
||||
title: 'S1',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [passage('local-1', 'reden', 'onbekend')],
|
||||
},
|
||||
]);
|
||||
const codes = allDiagnostics(b).map((d) => d.code);
|
||||
expect(codes).toContain('unresolved-at-send'); // reden
|
||||
@@ -42,8 +75,28 @@ describe('brief selectors', () => {
|
||||
});
|
||||
|
||||
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]))).toBe(false);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]))).toBe(true);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1')] }]))).toBe(true);
|
||||
expect(
|
||||
canSubmit(
|
||||
brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSubmit(
|
||||
brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSubmit(
|
||||
brief([
|
||||
{
|
||||
sectionKey: 's1',
|
||||
title: 'S1',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [passage('local-1')],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,12 @@ export type BriefStatus =
|
||||
| { readonly tag: 'draft' }
|
||||
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
|
||||
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
|
||||
| { readonly tag: 'rejected'; readonly rejectedBy: string; readonly rejectedAt: string; readonly comments: string }
|
||||
| {
|
||||
readonly tag: 'rejected';
|
||||
readonly rejectedBy: string;
|
||||
readonly rejectedAt: string;
|
||||
readonly comments: string;
|
||||
}
|
||||
| { readonly tag: 'sent'; readonly sentAt: string };
|
||||
|
||||
export interface Brief {
|
||||
@@ -81,7 +86,9 @@ export function allBlocks(brief: Brief): LetterBlock[] {
|
||||
/** Every diagnostic in the letter, in section→block→node order. This is what the
|
||||
diagnostics panel renders and what the send gate checks. */
|
||||
export function allDiagnostics(brief: Brief): Diagnostic[] {
|
||||
return allBlocks(brief).flatMap((b) => lintPlaceholders(b.content, brief.placeholders, b.blockId));
|
||||
return allBlocks(brief).flatMap((b) =>
|
||||
lintPlaceholders(b.content, brief.placeholders, b.blockId),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {
|
||||
|
||||
@@ -9,8 +9,12 @@ const valid: PlaceholderDef[] = [
|
||||
{ key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false },
|
||||
];
|
||||
|
||||
const withPlaceholder = (key: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'placeholder', key }] }] });
|
||||
const withText = (text: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text }] }] });
|
||||
const withPlaceholder = (key: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'placeholder', key }] }],
|
||||
});
|
||||
const withText = (text: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
describe('lintPlaceholders', () => {
|
||||
it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => {
|
||||
@@ -55,10 +59,17 @@ describe('lintPlaceholders', () => {
|
||||
const content: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'placeholder', key: 'onbekend' }] },
|
||||
{ nodes: [{ type: 'text', text: 'ok' }, { type: 'placeholder', key: 'reden' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'ok' },
|
||||
{ type: 'placeholder', key: 'reden' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const codes = lintPlaceholders(content, valid, 'b1').map((d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`);
|
||||
const codes = lintPlaceholders(content, valid, 'b1').map(
|
||||
(d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`,
|
||||
);
|
||||
expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,13 @@ function messageFor(code: DiagnosticCode, key?: string): string {
|
||||
}
|
||||
|
||||
function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic {
|
||||
return { severity: severityOf(code), code, placeholderKey: key, location, message: messageFor(code, key) };
|
||||
return {
|
||||
severity: severityOf(code),
|
||||
code,
|
||||
placeholderKey: key,
|
||||
location,
|
||||
message: messageFor(code, key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,14 +19,32 @@ const view: BriefViewDto = {
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
blocks: [
|
||||
{ type: 'passage', blockId: 'local-1', content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, sourcePassageId: 'p1', sourceVersion: 2, edited: true },
|
||||
{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] } },
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] },
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 2,
|
||||
edited: true,
|
||||
},
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
availablePassages: [
|
||||
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Aanhef', content: { paragraphs: [{ nodes: [] }] }, version: 1 },
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Aanhef',
|
||||
content: { paragraphs: [{ nodes: [] }] },
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -35,16 +53,31 @@ describe('brief.adapter parse boundary', () => {
|
||||
const r = parseBriefView(view);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.brief.status).toEqual({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' });
|
||||
expect(r.value.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'demo-drafter',
|
||||
submittedAt: '2026-07-01',
|
||||
});
|
||||
const [passage, free] = r.value.brief.sections[0].blocks;
|
||||
expect(passage.type === 'passage' && passage.edited).toBe(true);
|
||||
expect(free.type).toBe('freeText');
|
||||
expect(r.value.brief.placeholders[1]).toEqual({ key: 'code', label: 'Code', autoResolvable: true, fillable: false });
|
||||
expect(r.value.brief.placeholders[1]).toEqual({
|
||||
key: 'code',
|
||||
label: 'Code',
|
||||
autoResolvable: true,
|
||||
fillable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows node variants and rejects unknown ones', () => {
|
||||
expect(parseNode({ type: 'text', text: 'x' })).toEqual({ ok: true, value: { type: 'text', text: 'x' } });
|
||||
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({ ok: true, value: { type: 'placeholder', key: 'k' } });
|
||||
expect(parseNode({ type: 'text', text: 'x' })).toEqual({
|
||||
ok: true,
|
||||
value: { type: 'text', text: 'x' },
|
||||
});
|
||||
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({
|
||||
ok: true,
|
||||
value: { type: 'placeholder', key: 'k' },
|
||||
});
|
||||
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
|
||||
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
|
||||
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
|
||||
@@ -87,13 +120,24 @@ describe('brief.adapter parse boundary', () => {
|
||||
const [aanhef, kern] = r.value.sections;
|
||||
expect(aanhef.locked).toBe(true);
|
||||
expect(kern.locked).toBe(false);
|
||||
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual(['bullet', 'number', undefined]);
|
||||
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual([
|
||||
'bullet',
|
||||
'number',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a passage block missing provenance', () => {
|
||||
const r = parseBrief({
|
||||
...view.brief,
|
||||
sections: [{ sectionKey: 's', title: 'S', required: false, blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }] }],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 's',
|
||||
title: 'S',
|
||||
required: false,
|
||||
blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
RichTextBlockDto,
|
||||
RichTextNodeDto,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
|
||||
import {
|
||||
Brief,
|
||||
BriefStatus,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
LibraryPassage,
|
||||
PassageScope,
|
||||
} from '@brief/domain/brief';
|
||||
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
@@ -43,7 +50,10 @@ export class BriefAdapter {
|
||||
}
|
||||
|
||||
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
|
||||
const r = await runSubmit(() => this.client.briefPUT({ sections: sections.map(sectionToDto) }), BRIEF_ACTION_FAILED);
|
||||
const r = await runSubmit(
|
||||
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
|
||||
BRIEF_ACTION_FAILED,
|
||||
);
|
||||
return r.ok ? parseBrief(r.value) : r;
|
||||
}
|
||||
|
||||
@@ -83,10 +93,16 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
|
||||
case 'text': {
|
||||
if (typeof dto.text !== 'string') return err('node: text missing text');
|
||||
const marks = dto.marks?.filter((m): m is Mark => MARKS.includes(m));
|
||||
return ok(marks && marks.length ? { type: 'text', text: dto.text, marks } : { type: 'text', text: dto.text });
|
||||
return ok(
|
||||
marks && marks.length
|
||||
? { type: 'text', text: dto.text, marks }
|
||||
: { type: 'text', text: dto.text },
|
||||
);
|
||||
}
|
||||
case 'placeholder':
|
||||
return typeof dto.key === 'string' ? ok({ type: 'placeholder', key: dto.key }) : err('node: placeholder missing key');
|
||||
return typeof dto.key === 'string'
|
||||
? ok({ type: 'placeholder', key: dto.key })
|
||||
: err('node: placeholder missing key');
|
||||
case 'lineBreak':
|
||||
return ok({ type: 'lineBreak' });
|
||||
default:
|
||||
@@ -94,7 +110,9 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
|
||||
export function parseBlockContent(
|
||||
dto: RichTextBlockDto | undefined,
|
||||
): Result<string, RichTextBlock> {
|
||||
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
|
||||
const paragraphs: Paragraph[] = [];
|
||||
for (const p of dto.paragraphs) {
|
||||
@@ -116,7 +134,8 @@ function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
|
||||
if (!content.ok) return content;
|
||||
switch (dto.type) {
|
||||
case 'passage':
|
||||
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number') return err('block: bad passage provenance');
|
||||
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number')
|
||||
return err('block: bad passage provenance');
|
||||
return ok({
|
||||
type: 'passage',
|
||||
blockId: dto.blockId,
|
||||
@@ -133,7 +152,11 @@ function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
|
||||
}
|
||||
|
||||
function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
|
||||
if (typeof dto.sectionKey !== 'string' || typeof dto.title !== 'string' || typeof dto.required !== 'boolean') {
|
||||
if (
|
||||
typeof dto.sectionKey !== 'string' ||
|
||||
typeof dto.title !== 'string' ||
|
||||
typeof dto.required !== 'boolean'
|
||||
) {
|
||||
return err('section: bad shape');
|
||||
}
|
||||
const blocks: LetterBlock[] = [];
|
||||
@@ -142,11 +165,21 @@ function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
|
||||
if (!parsed.ok) return parsed;
|
||||
blocks.push(parsed.value);
|
||||
}
|
||||
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, locked: dto.locked ?? false, blocks });
|
||||
return ok({
|
||||
sectionKey: dto.sectionKey,
|
||||
title: dto.title,
|
||||
required: dto.required,
|
||||
locked: dto.locked ?? false,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
|
||||
if (typeof dto.key !== 'string' || typeof dto.label !== 'string' || typeof dto.autoResolvable !== 'boolean') {
|
||||
if (
|
||||
typeof dto.key !== 'string' ||
|
||||
typeof dto.label !== 'string' ||
|
||||
typeof dto.autoResolvable !== 'boolean'
|
||||
) {
|
||||
return err('placeholder: bad shape');
|
||||
}
|
||||
return ok({
|
||||
@@ -163,14 +196,26 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
|
||||
case 'draft':
|
||||
return ok({ tag: 'draft' });
|
||||
case 'submitted':
|
||||
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string') return err('status: bad submitted');
|
||||
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string')
|
||||
return err('status: bad submitted');
|
||||
return ok({ tag: 'submitted', submittedBy: dto.submittedBy, submittedAt: dto.submittedAt });
|
||||
case 'approved':
|
||||
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string') return err('status: bad approved');
|
||||
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string')
|
||||
return err('status: bad approved');
|
||||
return ok({ tag: 'approved', approvedBy: dto.approvedBy, approvedAt: dto.approvedAt });
|
||||
case 'rejected':
|
||||
if (typeof dto.rejectedBy !== 'string' || typeof dto.rejectedAt !== 'string' || typeof dto.comments !== 'string') return err('status: bad rejected');
|
||||
return ok({ tag: 'rejected', rejectedBy: dto.rejectedBy, rejectedAt: dto.rejectedAt, comments: dto.comments });
|
||||
if (
|
||||
typeof dto.rejectedBy !== 'string' ||
|
||||
typeof dto.rejectedAt !== 'string' ||
|
||||
typeof dto.comments !== 'string'
|
||||
)
|
||||
return err('status: bad rejected');
|
||||
return ok({
|
||||
tag: 'rejected',
|
||||
rejectedBy: dto.rejectedBy,
|
||||
rejectedAt: dto.rejectedAt,
|
||||
comments: dto.comments,
|
||||
});
|
||||
case 'sent':
|
||||
if (typeof dto.sentAt !== 'string') return err('status: bad sent');
|
||||
return ok({ tag: 'sent', sentAt: dto.sentAt });
|
||||
@@ -180,8 +225,14 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
|
||||
}
|
||||
|
||||
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
||||
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) return err('passage: bad shape');
|
||||
if (typeof dto.sectionKey !== 'string' || typeof dto.label !== 'string' || typeof dto.version !== 'number') return err('passage: bad shape');
|
||||
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
|
||||
return err('passage: bad shape');
|
||||
if (
|
||||
typeof dto.sectionKey !== 'string' ||
|
||||
typeof dto.label !== 'string' ||
|
||||
typeof dto.version !== 'number'
|
||||
)
|
||||
return err('passage: bad shape');
|
||||
const content = parseBlockContent(dto.content);
|
||||
if (!content.ok) return content;
|
||||
return ok({
|
||||
@@ -196,7 +247,12 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
||||
}
|
||||
|
||||
export function parseBrief(dto: BriefDto): Result<string, Brief> {
|
||||
if (typeof dto.briefId !== 'string' || typeof dto.drafterId !== 'string' || typeof dto.beroep !== 'string' || typeof dto.templateId !== 'string') {
|
||||
if (
|
||||
typeof dto.briefId !== 'string' ||
|
||||
typeof dto.drafterId !== 'string' ||
|
||||
typeof dto.beroep !== 'string' ||
|
||||
typeof dto.templateId !== 'string'
|
||||
) {
|
||||
return err('brief: missing ids');
|
||||
}
|
||||
const status = parseStatus(dto.status);
|
||||
@@ -252,15 +308,33 @@ function nodeToDto(n: RichTextNode): RichTextNodeDto {
|
||||
}
|
||||
|
||||
function contentToDto(content: RichTextBlock): RichTextBlockDto {
|
||||
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto), ...(p.list ? { list: p.list } : {}) })) };
|
||||
return {
|
||||
paragraphs: content.paragraphs.map((p) => ({
|
||||
nodes: p.nodes.map(nodeToDto),
|
||||
...(p.list ? { list: p.list } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function blockToDto(b: LetterBlock): LetterBlockDto {
|
||||
return b.type === 'passage'
|
||||
? { type: 'passage', blockId: b.blockId, content: contentToDto(b.content), sourcePassageId: b.sourcePassageId, sourceVersion: b.sourceVersion, edited: b.edited }
|
||||
? {
|
||||
type: 'passage',
|
||||
blockId: b.blockId,
|
||||
content: contentToDto(b.content),
|
||||
sourcePassageId: b.sourcePassageId,
|
||||
sourceVersion: b.sourceVersion,
|
||||
edited: b.edited,
|
||||
}
|
||||
: { type: 'freeText', blockId: b.blockId, content: contentToDto(b.content) };
|
||||
}
|
||||
|
||||
function sectionToDto(s: LetterSection): LetterSectionDto {
|
||||
return { sectionKey: s.sectionKey, title: s.title, required: s.required, locked: s.locked, blocks: s.blocks.map(blockToDto) };
|
||||
return {
|
||||
sectionKey: s.sectionKey,
|
||||
title: s.title,
|
||||
required: s.required,
|
||||
locked: s.locked,
|
||||
blocks: s.blocks.map(blockToDto),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,20 +11,38 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
this just wires signals to the organism and events back to store commands. */
|
||||
@Component({
|
||||
selector: 'app-brief-page',
|
||||
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
|
||||
styles: [`
|
||||
.brief-toolbar{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-lg)}
|
||||
.save{color:var(--rhc-color-foreground-subtle);font-size:0.9em}
|
||||
`],
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
SpinnerComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
LetterComposerComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
.brief-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell
|
||||
[heading]="heading"
|
||||
[intro]="intro"
|
||||
backLink="/dashboard">
|
||||
@if (lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (lastError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
|
||||
@switch (model().tag) {
|
||||
@case ('loading') { <app-spinner /> }
|
||||
@case ('loading') {
|
||||
<app-spinner />
|
||||
}
|
||||
@case ('failed') {
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
@@ -32,7 +50,9 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
@case ('loaded') {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ resetLabel }}</app-button>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="brief()!"
|
||||
@@ -46,7 +66,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()" />
|
||||
(send)="store.send()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</app-page-shell>
|
||||
@@ -70,10 +91,14 @@ export class BriefPage {
|
||||
/** Debounced-save state, surfaced in a polite live region. */
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState()) {
|
||||
case 'saving': return this.savingText;
|
||||
case 'saved': return this.savedText;
|
||||
case 'error': return this.saveErrorText;
|
||||
default: return '';
|
||||
case 'saving':
|
||||
return this.savingText;
|
||||
case 'saved':
|
||||
return this.savedText;
|
||||
case 'error':
|
||||
return this.saveErrorText;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,17 +8,38 @@ import { Diagnostic } from '@brief/domain/placeholders';
|
||||
@Component({
|
||||
selector: 'app-diagnostics-panel',
|
||||
imports: [AlertComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
ul{margin:0;padding-inline-start:1.1rem;display:grid;gap:0.15rem}
|
||||
button{background:none;border:0;padding:0;color:var(--rhc-color-foreground-link);cursor:pointer;text-align:start;text-decoration:underline}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-inline-start: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
button {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--rhc-color-foreground-link);
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (errors().length) {
|
||||
<app-alert type="error">
|
||||
<strong>{{ errorsTitle() }}</strong>
|
||||
<ul>
|
||||
@for (d of errors(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> }
|
||||
@for (d of errors(); track $index) {
|
||||
<li>
|
||||
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
}
|
||||
@@ -26,7 +47,11 @@ import { Diagnostic } from '@brief/domain/placeholders';
|
||||
<app-alert type="warning">
|
||||
<strong>{{ warningsTitle() }}</strong>
|
||||
<ul>
|
||||
@for (d of warnings(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> }
|
||||
@for (d of warnings(); track $index) {
|
||||
<li>
|
||||
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { RichTextEditorComponent, PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import {
|
||||
RichTextEditorComponent,
|
||||
PlaceholderOption,
|
||||
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { LetterBlock } from '@brief/domain/brief';
|
||||
|
||||
@@ -9,21 +12,43 @@ import { LetterBlock } from '@brief/domain/brief';
|
||||
@Component({
|
||||
selector: 'app-letter-block',
|
||||
imports: [RichTextEditorComponent, ButtonComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.block{border-inline-start:var(--rhc-border-width-lg) solid var(--rhc-color-border-subtle);padding-inline-start:var(--rhc-space-max-md)}
|
||||
.meta{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-sm)}
|
||||
.controls{display:flex;gap:var(--rhc-space-max-sm)}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.block {
|
||||
border-inline-start: var(--rhc-border-width-lg) solid var(--rhc-color-border-subtle);
|
||||
padding-inline-start: var(--rhc-space-max-md);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="block">
|
||||
<div class="meta">
|
||||
<span class="app-text-subtle">{{ provenance() }}</span>
|
||||
@if (editable()) {
|
||||
<span class="controls">
|
||||
<app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp">Omhoog</app-button>
|
||||
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown">Omlaag</app-button>
|
||||
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove">Verwijderen</app-button>
|
||||
<app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp"
|
||||
>Omhoog</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown"
|
||||
>Omlaag</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove"
|
||||
>Verwijderen</app-button
|
||||
>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@@ -31,7 +56,8 @@ import { LetterBlock } from '@brief/domain/brief';
|
||||
[content]="block().content"
|
||||
[placeholders]="placeholders()"
|
||||
[editable]="editable()"
|
||||
(contentChanged)="contentChanged.emit($event)" />
|
||||
(contentChanged)="contentChanged.emit($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -19,16 +19,44 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
@Component({
|
||||
selector: 'app-letter-composer',
|
||||
imports: [
|
||||
HeadingComponent, StatusBadgeComponent, ButtonComponent, AlertComponent,
|
||||
LetterSectionComponent, LetterPreviewComponent, DiagnosticsPanelComponent, RejectionCommentsComponent,
|
||||
HeadingComponent,
|
||||
StatusBadgeComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
LetterSectionComponent,
|
||||
LetterPreviewComponent,
|
||||
DiagnosticsPanelComponent,
|
||||
RejectionCommentsComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
flex-wrap: wrap;
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.sections {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-2xl);
|
||||
}
|
||||
.panel {
|
||||
margin-block: var(--rhc-space-max-xl);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
align-items: center;
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
`,
|
||||
],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.head{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);flex-wrap:wrap;margin-block-end:var(--rhc-space-max-lg)}
|
||||
.sections{display:grid;gap:var(--rhc-space-max-2xl)}
|
||||
.panel{margin-block:var(--rhc-space-max-xl)}
|
||||
.bar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;margin-block-start:var(--rhc-space-max-xl)}
|
||||
`],
|
||||
template: `
|
||||
<div class="head">
|
||||
<app-heading [level]="2">{{ title() }}</app-heading>
|
||||
@@ -47,7 +75,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="menu()"
|
||||
[editable]="!section.locked"
|
||||
(edit)="edit.emit($event)" />
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@@ -62,25 +91,41 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
@switch (status()) {
|
||||
@case ('draft') {
|
||||
@if (editable()) {
|
||||
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ submitLabel() }}</app-button>
|
||||
@if (!canSubmit()) { <span class="app-text-subtle">{{ submitHint() }}</span> }
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
(click)="submit.emit()"
|
||||
>{{ submitLabel() }}</app-button
|
||||
>
|
||||
@if (!canSubmit()) {
|
||||
<span class="app-text-subtle">{{ submitHint() }}</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
@case ('rejected') {
|
||||
@if (editable()) {
|
||||
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ resubmitLabel() }}</app-button>
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
(click)="submit.emit()"
|
||||
>{{ resubmitLabel() }}</app-button
|
||||
>
|
||||
}
|
||||
}
|
||||
@case ('submitted') {
|
||||
@if (role() === 'approver') {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ approveLabel() }}</app-button>
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
|
||||
approveLabel()
|
||||
}}</app-button>
|
||||
<app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" />
|
||||
} @else {
|
||||
<app-alert type="info">{{ awaitingText() }}</app-alert>
|
||||
}
|
||||
}
|
||||
@case ('approved') {
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{ sendLabel() }}</app-button>
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
|
||||
sendLabel()
|
||||
}}</app-button>
|
||||
}
|
||||
@case ('sent') {
|
||||
<app-alert type="ok">{{ sentText() }}</app-alert>
|
||||
@@ -108,10 +153,14 @@ export class LetterComposerComponent {
|
||||
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
||||
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
||||
submitHint = input($localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`);
|
||||
submitHint = input(
|
||||
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
|
||||
);
|
||||
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
|
||||
sendLabel = input($localize`:@@brief.send:Versturen`);
|
||||
awaitingText = input($localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`);
|
||||
awaitingText = input(
|
||||
$localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`,
|
||||
);
|
||||
sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
|
||||
|
||||
protected status = computed(() => this.brief().status.tag);
|
||||
@@ -130,21 +179,30 @@ export class LetterComposerComponent {
|
||||
|
||||
protected statusLabel = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'draft': return $localize`:@@brief.status.draft:Concept`;
|
||||
case 'submitted': return $localize`:@@brief.status.submitted:Ter beoordeling`;
|
||||
case 'approved': return $localize`:@@brief.status.approved:Goedgekeurd`;
|
||||
case 'rejected': return $localize`:@@brief.status.rejected:Afgewezen`;
|
||||
case 'sent': return $localize`:@@brief.status.sent:Verzonden`;
|
||||
case 'draft':
|
||||
return $localize`:@@brief.status.draft:Concept`;
|
||||
case 'submitted':
|
||||
return $localize`:@@brief.status.submitted:Ter beoordeling`;
|
||||
case 'approved':
|
||||
return $localize`:@@brief.status.approved:Goedgekeurd`;
|
||||
case 'rejected':
|
||||
return $localize`:@@brief.status.rejected:Afgewezen`;
|
||||
case 'sent':
|
||||
return $localize`:@@brief.status.sent:Verzonden`;
|
||||
}
|
||||
});
|
||||
|
||||
protected statusColor = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'draft': return 'var(--rhc-color-border-strong)';
|
||||
case 'submitted': return 'var(--rhc-color-oranje-500)';
|
||||
case 'draft':
|
||||
return 'var(--rhc-color-border-strong)';
|
||||
case 'submitted':
|
||||
return 'var(--rhc-color-oranje-500)';
|
||||
case 'approved':
|
||||
case 'sent': return 'var(--rhc-color-groen-500)';
|
||||
case 'rejected': return 'var(--rhc-color-rood-500)';
|
||||
case 'sent':
|
||||
return 'var(--rhc-color-groen-500)';
|
||||
case 'rejected':
|
||||
return 'var(--rhc-color-rood-500)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,8 +4,33 @@ import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||
import { allDiagnostics } from '@brief/domain/brief';
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Standaard aanhef', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw ' }, { type: 'placeholder', key: 'naam_zorgverlener' }, { type: 'text', text: ',' }] }] } },
|
||||
{ passageId: 'p2', scope: 'beroep', beroep: 'arts', sectionKey: 'kern', label: 'Toelichting arts', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] } },
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Standaard aanhef',
|
||||
version: 1,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
passageId: 'p2',
|
||||
scope: 'beroep',
|
||||
beroep: 'arts',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toelichting arts',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
function brief(status: BriefStatus): Brief {
|
||||
@@ -25,22 +50,69 @@ function brief(status: BriefStatus): Brief {
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }],
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: passages[0].content,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten om reden ' }, { type: 'placeholder', key: 'reden_besluit' }, { type: 'text', text: '.' }] }] } }],
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'slot',
|
||||
title: 'Slot',
|
||||
required: false,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-3',
|
||||
content: {
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'local-3', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }] } }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const render = (b: Brief, editable: boolean, role: 'drafter' | 'approver') => ({
|
||||
props: { brief: b, availablePassages: passages, diagnostics: allDiagnostics(b), editable, role, canSubmit: true, busy: false },
|
||||
props: {
|
||||
brief: b,
|
||||
availablePassages: passages,
|
||||
diagnostics: allDiagnostics(b),
|
||||
editable,
|
||||
role,
|
||||
canSubmit: true,
|
||||
busy: false,
|
||||
},
|
||||
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
|
||||
[editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||
});
|
||||
@@ -52,7 +124,30 @@ const meta: Meta<LetterComposerComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterComposerComponent>;
|
||||
|
||||
export const DraftDrafter: Story = { render: () => render(brief({ tag: 'draft' }), true, 'drafter') };
|
||||
export const SubmittedApprover: Story = { render: () => render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), false, 'approver') };
|
||||
export const Rejected: Story = { render: () => render(brief({ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de aanhef formeler.' }), true, 'drafter') };
|
||||
export const Sent: Story = { render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter') };
|
||||
export const DraftDrafter: Story = {
|
||||
render: () => render(brief({ tag: 'draft' }), true, 'drafter'),
|
||||
};
|
||||
export const SubmittedApprover: Story = {
|
||||
render: () =>
|
||||
render(
|
||||
brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }),
|
||||
false,
|
||||
'approver',
|
||||
),
|
||||
};
|
||||
export const Rejected: Story = {
|
||||
render: () =>
|
||||
render(
|
||||
brief({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'demo-approver',
|
||||
rejectedAt: '2026-07-01',
|
||||
comments: 'Graag de aanhef formeler.',
|
||||
}),
|
||||
true,
|
||||
'drafter',
|
||||
),
|
||||
};
|
||||
export const Sent: Story = {
|
||||
render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter'),
|
||||
};
|
||||
|
||||
@@ -8,7 +8,10 @@ import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = { readonly list: 'bullet' | 'number' | null; readonly items: readonly Paragraph[] };
|
||||
type PreviewSegment = {
|
||||
readonly list: 'bullet' | 'number' | null;
|
||||
readonly items: readonly Paragraph[];
|
||||
};
|
||||
|
||||
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||
@@ -34,25 +37,54 @@ const SAMPLE_VALUES: Record<string, string> = {
|
||||
@Component({
|
||||
selector: 'app-letter-preview',
|
||||
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.toolbar{display:flex;justify-content:flex-end;margin-block-end:var(--rhc-space-max-sm)}
|
||||
.letter{background:var(--rhc-color-wit);border:var(--rhc-border-width-sm) solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)}
|
||||
section{margin-block-end:var(--rhc-space-max-xl)}
|
||||
p{margin:0 0 var(--rhc-space-max-sm)}
|
||||
ul,ol{margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.letter {
|
||||
background: var(--rhc-color-wit);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-2xl);
|
||||
}
|
||||
section {
|
||||
margin-block-end: var(--rhc-space-max-xl);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') { <span>{{ node.text }}</span> }
|
||||
@case ('lineBreak') { <br> }
|
||||
@case ('text') {
|
||||
<span>{{ node.text }}</span>
|
||||
}
|
||||
@case ('lineBreak') {
|
||||
<br />
|
||||
}
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip [label]="labelFor(node.key)" [autoResolvable]="autoFor(node.key)" [state]="stateFor(node.key)" />
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +92,11 @@ const SAMPLE_VALUES: Record<string, string> = {
|
||||
</ng-template>
|
||||
|
||||
<div class="toolbar">
|
||||
<app-button variant="subtle" (click)="showSample.set(!showSample())" [attr.aria-pressed]="showSample()">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
@@ -72,11 +108,34 @@ const SAMPLE_VALUES: Record<string, string> = {
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ul>
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (seg.list === 'number') {
|
||||
<ol>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ol>
|
||||
<ol>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<p><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }" /></p>
|
||||
<p>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +152,11 @@ export class LetterPreviewComponent {
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
private today = new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
private today = new Date().toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
@@ -110,5 +173,6 @@ export class LetterPreviewComponent {
|
||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||
protected sampleFor = (key: string) => SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
|
||||
protected sampleFor = (key: string) =>
|
||||
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
|
||||
}
|
||||
|
||||
@@ -14,17 +14,36 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
|
||||
@Component({
|
||||
selector: 'app-letter-section',
|
||||
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.blocks{display:grid;gap:var(--rhc-space-max-lg);margin-block:var(--rhc-space-max-md)}
|
||||
.actions{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm)}
|
||||
.required{color:var(--rhc-color-foreground-subtle)}
|
||||
.empty{color:var(--rhc-color-foreground-subtle);font-style:italic}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.blocks {
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-lg);
|
||||
margin-block: var(--rhc-space-max-md);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.required {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.empty {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-heading [level]="3">
|
||||
{{ section().title }}
|
||||
@if (section().required) { <span class="required">· {{ requiredLabel() }}</span> }
|
||||
@if (section().required) {
|
||||
<span class="required">· {{ requiredLabel() }}</span>
|
||||
}
|
||||
</app-heading>
|
||||
|
||||
<div class="blocks">
|
||||
@@ -35,7 +54,8 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
|
||||
[editable]="editable()"
|
||||
(contentChanged)="onContent(block.blockId, $event)"
|
||||
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
|
||||
(moved)="onMove(block.blockId, $event)" />
|
||||
(moved)="onMove(block.blockId, $event)"
|
||||
/>
|
||||
} @empty {
|
||||
<p class="empty">{{ emptyLabel() }}</p>
|
||||
}
|
||||
@@ -43,8 +63,14 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
|
||||
|
||||
@if (editable()) {
|
||||
<div class="actions">
|
||||
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{ addPassageLabel() }}</app-button>
|
||||
<app-button variant="subtle" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })">{{ addFreeLabel() }}</app-button>
|
||||
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
|
||||
addPassageLabel()
|
||||
}}</app-button>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
|
||||
>{{ addFreeLabel() }}</app-button
|
||||
>
|
||||
</div>
|
||||
@if (pickerOpen()) {
|
||||
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
|
||||
@@ -65,7 +91,9 @@ export class LetterSectionComponent {
|
||||
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
||||
|
||||
protected pickerOpen = signal(false);
|
||||
protected sectionPassages = computed(() => this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey));
|
||||
protected sectionPassages = computed(() =>
|
||||
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
|
||||
);
|
||||
|
||||
protected onContent(blockId: string, content: RichTextBlock) {
|
||||
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
|
||||
|
||||
@@ -10,11 +10,26 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
@Component({
|
||||
selector: 'app-passage-picker',
|
||||
imports: [FormsModule, CheckboxComponent, ButtonComponent],
|
||||
styles: [`
|
||||
:host{display:block;border:var(--rhc-border-width-sm) solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-md)}
|
||||
ul{list-style:none;margin:0 0 var(--rhc-space-max-md);padding:0;display:grid;gap:var(--rhc-space-max-sm)}
|
||||
.scope{color:var(--rhc-color-foreground-subtle)}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-md);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.scope {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<ul>
|
||||
@for (p of passages(); track p.passageId) {
|
||||
@@ -22,12 +37,15 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
<app-checkbox
|
||||
[label]="p.label"
|
||||
[ngModel]="!!checked()[p.passageId]"
|
||||
(ngModelChange)="set(p.passageId, $event)" />
|
||||
(ngModelChange)="set(p.passageId, $event)"
|
||||
/>
|
||||
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()">{{ addLabel() }} ({{ count() }})</app-button>
|
||||
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
|
||||
>{{ addLabel() }} ({{ count() }})</app-button
|
||||
>
|
||||
`,
|
||||
})
|
||||
export class PassagePickerComponent {
|
||||
|
||||
@@ -8,18 +8,33 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
@Component({
|
||||
selector: 'app-rejection-comments',
|
||||
imports: [FormsModule, AlertComponent, ButtonComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
textarea{inline-size:100%;box-sizing:border-box;min-block-size:4rem;margin-block:var(--rhc-space-max-sm)}
|
||||
label{font-weight:600}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
textarea {
|
||||
inline-size: 100%;
|
||||
box-sizing: border-box;
|
||||
min-block-size: 4rem;
|
||||
margin-block: var(--rhc-space-max-sm);
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (mode() === 'show') {
|
||||
<app-alert type="warning"><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert>
|
||||
<app-alert type="warning"
|
||||
><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert
|
||||
>
|
||||
} @else {
|
||||
<label for="reject-comments">{{ entryLabel() }}</label>
|
||||
<textarea id="reject-comments" [(ngModel)]="draft"></textarea>
|
||||
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{ rejectLabel() }}</app-button>
|
||||
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{
|
||||
rejectLabel()
|
||||
}}</app-button>
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -15,5 +15,7 @@ export class IntakePolicyStore {
|
||||
private policy = inject(IntakePolicyAdapter);
|
||||
private policyRes = this.policy.policyResource();
|
||||
|
||||
readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
|
||||
readonly scholingThreshold = computed(
|
||||
() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import { initial, next, back, gaNaarStap, submit, resolve, reduce, WizardState } from './herregistratie.machine';
|
||||
import {
|
||||
initial,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
WizardState,
|
||||
} from './herregistratie.machine';
|
||||
|
||||
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
|
||||
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
|
||||
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 3, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
|
||||
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 2,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({
|
||||
tag: 'Editing',
|
||||
step: 3,
|
||||
draft: { uren, jaren, punten },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
|
||||
describe('wizard.machine', () => {
|
||||
it('next advances only when step 1 parses', () => {
|
||||
@@ -76,19 +103,37 @@ describe('reduce (message-driven)', () => {
|
||||
});
|
||||
|
||||
it('blocks submit until required documents are satisfied', () => {
|
||||
const cat = { categoryId: 'bewijs', label: 'Bewijs', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true };
|
||||
let s = reduce(editing3('4160', '200'), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
|
||||
const cat = {
|
||||
categoryId: 'bewijs',
|
||||
label: 'Bewijs',
|
||||
description: '',
|
||||
required: true,
|
||||
acceptedTypes: [],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
};
|
||||
let s = reduce(editing3('4160', '200'), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
expect((s as any).errors.documenten).toBeTruthy();
|
||||
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' } });
|
||||
s = reduce(s, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' },
|
||||
});
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]);
|
||||
});
|
||||
|
||||
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
|
||||
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
|
||||
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
expect(s.tag).toBe('Failed');
|
||||
s = reduce(s, { tag: 'Retry' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
|
||||
@@ -38,11 +38,23 @@ export type WizardState =
|
||||
| { tag: 'Submitted'; data: Valid }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
|
||||
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload };
|
||||
export const initial: WizardState = {
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren: '', jaren: '', punten: '' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
};
|
||||
|
||||
/** Has the user meaningfully started, so it's worth persisting as a Concept? */
|
||||
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
|
||||
return s.step > 1 || !!s.draft.uren || !!s.draft.jaren || !!s.draft.punten || deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId);
|
||||
return (
|
||||
s.step > 1 ||
|
||||
!!s.draft.uren ||
|
||||
!!s.draft.jaren ||
|
||||
!!s.draft.punten ||
|
||||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse every field; on success hand back a Valid, else the per-field errors. */
|
||||
@@ -58,7 +70,15 @@ function validate(draft: Draft, upload: UploadState): Result<StepErrors, Valid>
|
||||
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
|
||||
}
|
||||
if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {
|
||||
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value, documents: deliveryRefs(upload) } };
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
uren: uren.value,
|
||||
jaren: jaren.value,
|
||||
punten: punten.value,
|
||||
documents: deliveryRefs(upload),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: errors };
|
||||
}
|
||||
@@ -110,7 +130,9 @@ export function upload(s: WizardState, msg: UploadMsg): WizardState {
|
||||
/** Resolve the async submit. Only meaningful while Submitting. */
|
||||
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };
|
||||
return r.ok
|
||||
? { tag: 'Submitted', data: s.data }
|
||||
: { tag: 'Failed', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
/** Update one draft field while editing; ignored in any other state. */
|
||||
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
IntakeState,
|
||||
} from './intake.machine';
|
||||
|
||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold });
|
||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
|
||||
tag: 'Answering',
|
||||
answers,
|
||||
cursor,
|
||||
errors: {},
|
||||
scholingThreshold,
|
||||
});
|
||||
|
||||
describe('STEPS (fixed) and inline questions', () => {
|
||||
it('always has the same three steps', () => {
|
||||
@@ -40,7 +46,9 @@ describe('STEPS (fixed) and inline questions', () => {
|
||||
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
|
||||
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
|
||||
// And the threshold from state flows through submit:
|
||||
const lowThreshold = submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000));
|
||||
const lowThreshold = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
|
||||
);
|
||||
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
|
||||
expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy();
|
||||
});
|
||||
@@ -61,7 +69,11 @@ describe('navigation', () => {
|
||||
});
|
||||
|
||||
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
|
||||
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
|
||||
tag: 'SetAnswer',
|
||||
key: 'buitenlandGewerkt',
|
||||
value: 'nee',
|
||||
});
|
||||
expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
|
||||
});
|
||||
|
||||
@@ -89,7 +101,11 @@ describe('submit', () => {
|
||||
|
||||
it('reaches Submitting ONLY with valid answers', () => {
|
||||
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
|
||||
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' })).tag).toBe('Answering');
|
||||
expect(
|
||||
submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
|
||||
).tag,
|
||||
).toBe('Answering');
|
||||
const good = submit(answering(complete));
|
||||
expect(good.tag).toBe('Submitting');
|
||||
expect((good as any).data.uren).toBe(4160);
|
||||
@@ -98,17 +114,23 @@ describe('submit', () => {
|
||||
|
||||
it('punten is required only when aanvullende scholing was gevolgd', () => {
|
||||
// scholing = ja but punten missing -> blocked on punten.
|
||||
const missing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }));
|
||||
const missing = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }),
|
||||
);
|
||||
expect(missing.tag).toBe('Answering');
|
||||
expect((missing as any).errors.punten).toBeTruthy();
|
||||
// scholing = nee -> punten not required, submits without it.
|
||||
expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag).toBe('Submitting');
|
||||
expect(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
|
||||
).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('low hours requires the scholing answer before submit', () => {
|
||||
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||
const withScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }));
|
||||
const withScholing = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
|
||||
);
|
||||
expect(withScholing.tag).toBe('Submitting');
|
||||
expect((withScholing as any).data.aanvullendeScholing).toBe(true);
|
||||
expect((withScholing as any).data.punten).toBe(200);
|
||||
|
||||
@@ -56,12 +56,24 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
||||
type Errors = Partial<Record<keyof Answers, string>>;
|
||||
|
||||
export type IntakeState =
|
||||
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number }
|
||||
| {
|
||||
tag: 'Answering';
|
||||
answers: Answers;
|
||||
cursor: number;
|
||||
errors: Errors;
|
||||
scholingThreshold: number;
|
||||
}
|
||||
| { tag: 'Submitting'; data: ValidIntake }
|
||||
| { tag: 'Submitted'; data: ValidIntake }
|
||||
| { tag: 'Failed'; data: ValidIntake; error: string };
|
||||
|
||||
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT };
|
||||
export const initial: IntakeState = {
|
||||
tag: 'Answering',
|
||||
answers: {},
|
||||
cursor: 0,
|
||||
errors: {},
|
||||
scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,
|
||||
};
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
|
||||
@@ -79,9 +91,11 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
||||
const errors: Errors = {};
|
||||
switch (step) {
|
||||
case 'buitenland':
|
||||
if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
if (!a.buitenlandGewerkt)
|
||||
errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
else if (a.buitenlandGewerkt === 'ja') {
|
||||
if (!a.land || a.land.trim() === '') errors.land = $localize`:@@validation.land:Vul een land in.`;
|
||||
if (!a.land || a.land.trim() === '')
|
||||
errors.land = $localize`:@@validation.land:Vul een land in.`;
|
||||
const u = parseUren(a.buitenlandseUren ?? '');
|
||||
if (!u.ok) errors.buitenlandseUren = u.error;
|
||||
}
|
||||
@@ -89,7 +103,8 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
||||
case 'werk': {
|
||||
const u = parseUren(a.uren ?? '');
|
||||
if (!u.ok) errors.uren = u.error;
|
||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
|
||||
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
// Nascholingspunten are only asked (and required) when scholing was followed.
|
||||
if (a.scholingGevolgd === 'ja') {
|
||||
const p = parseUren(a.punten ?? '');
|
||||
@@ -171,7 +186,9 @@ export function submit(s: IntakeState): IntakeState {
|
||||
|
||||
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };
|
||||
return r.ok
|
||||
? { tag: 'Submitted', data: s.data }
|
||||
: { tag: 'Failed', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
export type IntakeMsg =
|
||||
|
||||
@@ -3,12 +3,24 @@ import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import { WizardState, WizardMsg, Draft, initial, reduce, hasProgress } from '@herregistratie/domain/herregistratie.machine';
|
||||
import {
|
||||
WizardState,
|
||||
WizardMsg,
|
||||
Draft,
|
||||
initial,
|
||||
reduce,
|
||||
hasProgress,
|
||||
} from '@herregistratie/domain/herregistratie.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||
import { createUploadController } from '@shared/upload/upload-controller';
|
||||
@@ -23,13 +35,22 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
||||
the dashboard shows "in behandeling" immediately. */
|
||||
@Component({
|
||||
selector: 'app-herregistratie-wizard',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, ConfirmationComponent, WizardShellComponent, DocumentUploadComponent],
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormFieldComponent,
|
||||
TextInputComponent,
|
||||
AlertComponent,
|
||||
ConfirmationComponent,
|
||||
WizardShellComponent,
|
||||
DocumentUploadComponent,
|
||||
],
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="step() - 1"
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@herregWizard.processName" processName="Herregistratie aanvragen"
|
||||
i18n-processName="@@herregWizard.processName"
|
||||
processName="Herregistratie aanvragen"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="step() > 1"
|
||||
@@ -39,23 +60,62 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()"
|
||||
(goToStep)="goToStep($event)">
|
||||
|
||||
(goToStep)="goToStep($event)"
|
||||
>
|
||||
@switch (step()) {
|
||||
@case (1) {
|
||||
<app-form-field i18n-label="@@herregWizard.urenLabel" label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
|
||||
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||
name="uren" [invalid]="!!errUren()" i18n-placeholder="@@herregWizard.urenPlaceholder" placeholder="bijv. 4160" />
|
||||
<app-form-field
|
||||
i18n-label="@@herregWizard.urenLabel"
|
||||
label="Gewerkte uren (afgelopen 5 jaar)"
|
||||
fieldId="uren"
|
||||
required
|
||||
[error]="errUren()"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="uren"
|
||||
[ngModel]="draft().uren"
|
||||
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||
name="uren"
|
||||
[invalid]="!!errUren()"
|
||||
i18n-placeholder="@@herregWizard.urenPlaceholder"
|
||||
placeholder="bijv. 4160"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field i18n-label="@@herregWizard.jarenLabel" label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()">
|
||||
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||
name="jaren" [invalid]="!!errJaren()" i18n-placeholder="@@herregWizard.jarenPlaceholder" placeholder="bijv. 5" />
|
||||
<app-form-field
|
||||
i18n-label="@@herregWizard.jarenLabel"
|
||||
label="Aantal jaren werkzaam"
|
||||
fieldId="jaren"
|
||||
required
|
||||
[error]="errJaren()"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="jaren"
|
||||
[ngModel]="draft().jaren"
|
||||
(ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||
name="jaren"
|
||||
[invalid]="!!errJaren()"
|
||||
i18n-placeholder="@@herregWizard.jarenPlaceholder"
|
||||
placeholder="bijv. 5"
|
||||
/>
|
||||
</app-form-field>
|
||||
}
|
||||
@case (2) {
|
||||
<app-form-field i18n-label="@@herregWizard.puntenLabel" label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()">
|
||||
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
||||
name="punten" [invalid]="!!errPunten()" i18n-placeholder="@@herregWizard.puntenPlaceholder" placeholder="bijv. 200" />
|
||||
<app-form-field
|
||||
i18n-label="@@herregWizard.puntenLabel"
|
||||
label="Behaalde nascholingspunten"
|
||||
fieldId="punten"
|
||||
required
|
||||
[error]="errPunten()"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="punten"
|
||||
[ngModel]="draft().punten"
|
||||
(ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
||||
name="punten"
|
||||
[invalid]="!!errPunten()"
|
||||
i18n-placeholder="@@herregWizard.puntenPlaceholder"
|
||||
placeholder="bijv. 200"
|
||||
/>
|
||||
</app-form-field>
|
||||
}
|
||||
@case (3) {
|
||||
@@ -66,7 +126,8 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
||||
(removeUpload)="uploadCtl.onRemove($event)"
|
||||
(retryUpload)="uploadCtl.onRetry($event)"
|
||||
(deleteUpload)="uploadCtl.onDelete($event)"
|
||||
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)" />
|
||||
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
|
||||
/>
|
||||
@if (errDocumenten()) {
|
||||
<app-alert type="warning">{{ errDocumenten() }}</app-alert>
|
||||
}
|
||||
@@ -74,7 +135,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
||||
}
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-confirmation i18n-title="@@herregWizard.success.title" title="Uw aanvraag tot herregistratie is ontvangen" />
|
||||
<app-confirmation
|
||||
i18n-title="@@herregWizard.success.title"
|
||||
title="Uw aanvraag tot herregistratie is ontvangen"
|
||||
/>
|
||||
</div>
|
||||
</app-wizard-shell>
|
||||
`,
|
||||
@@ -102,7 +166,9 @@ export class HerregistratieWizardComponent {
|
||||
snapshot: () => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
|
||||
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);
|
||||
const documentIds = deliveryRefs(s.upload)
|
||||
.filter((r) => r.channel === 'digital' && r.documentId)
|
||||
.map((r) => r.documentId!);
|
||||
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
|
||||
},
|
||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
|
||||
@@ -110,12 +176,22 @@ export class HerregistratieWizardComponent {
|
||||
});
|
||||
|
||||
// Stepper labels + per-step heading titles (presentational only).
|
||||
readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.nascholing:Nascholing`, $localize`:@@herregWizard.step.documenten:Documenten`];
|
||||
private stepTitles = [$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`, $localize`:@@herregWizard.title.nascholing:Nascholing`, $localize`:@@herregWizard.title.documenten:Documenten aanleveren`];
|
||||
readonly stepLabels = [
|
||||
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
|
||||
$localize`:@@herregWizard.step.nascholing:Nascholing`,
|
||||
$localize`:@@herregWizard.step.documenten:Documenten`,
|
||||
];
|
||||
private stepTitles = [
|
||||
$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`,
|
||||
$localize`:@@herregWizard.title.nascholing:Nascholing`,
|
||||
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
|
||||
];
|
||||
|
||||
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||
protected step = computed(() => this.editing()?.step ?? 1);
|
||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
||||
protected draft = computed<Draft>(
|
||||
() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' },
|
||||
);
|
||||
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
|
||||
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||
@@ -132,20 +208,28 @@ export class HerregistratieWizardComponent {
|
||||
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
||||
protected primaryLabel = computed(() => {
|
||||
const step = this.step();
|
||||
return step < 3 ? naarStapLabel(step + 1, this.stepLabels[step]) : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
|
||||
return step < 3
|
||||
? naarStapLabel(step + 1, this.stepLabels[step])
|
||||
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
|
||||
});
|
||||
|
||||
/** Stepper emits a 0-based index for an earlier (visited) step. */
|
||||
protected goToStep(index: number) {
|
||||
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
||||
}
|
||||
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
case 'Editing': return 'editing';
|
||||
case 'Submitting': return 'submitting';
|
||||
case 'Submitted': return 'submitted';
|
||||
case 'Failed': return 'failed';
|
||||
case 'Editing':
|
||||
return 'editing';
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
case 'Failed':
|
||||
return 'failed';
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. */
|
||||
@@ -160,7 +244,9 @@ export class HerregistratieWizardComponent {
|
||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||
const seeded = this.seed();
|
||||
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
|
||||
queueMicrotask(() =>
|
||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
||||
);
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
|
||||
@@ -20,12 +20,55 @@ export default meta;
|
||||
type Story = StoryObj<HerregistratieWizardComponent>;
|
||||
|
||||
// Each story seeds one state of the machine — one render per union variant.
|
||||
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload } } };
|
||||
export const Step1Error: Story = {
|
||||
args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' }, upload: initialUpload } satisfies WizardState },
|
||||
export const Step1: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren: '', jaren: '', punten: '' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Step1Error: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 1,
|
||||
draft: { uren: 'abc', jaren: '', punten: '' },
|
||||
errors: {
|
||||
uren: 'Vul een geheel aantal in (0 of meer).',
|
||||
jaren: 'Vul een geheel aantal in (0 of meer).',
|
||||
},
|
||||
upload: initialUpload,
|
||||
} satisfies WizardState,
|
||||
},
|
||||
};
|
||||
export const Step2: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 2,
|
||||
draft: { uren: '4160', jaren: '5', punten: '' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Step3: Story = {
|
||||
args: {
|
||||
seed: {
|
||||
tag: 'Editing',
|
||||
step: 3,
|
||||
draft: { uren: '4160', jaren: '5', punten: '200' },
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {}, upload: initialUpload } } };
|
||||
export const Step3: Story = { args: { seed: { tag: 'Editing', step: 3, draft: { uren: '4160', jaren: '5', punten: '200' }, errors: {}, upload: initialUpload } } };
|
||||
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
|
||||
export const Failed: Story = {
|
||||
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
|
||||
};
|
||||
|
||||
@@ -13,7 +13,11 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
|
||||
selector: 'app-herregistratie-page',
|
||||
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
|
||||
template: `
|
||||
<app-page-shell i18n-heading="@@herregistratie.heading" heading="Herregistratie aanvragen" backLink="/dashboard">
|
||||
<app-page-shell
|
||||
i18n-heading="@@herregistratie.heading"
|
||||
heading="Herregistratie aanvragen"
|
||||
backLink="/dashboard"
|
||||
>
|
||||
<app-async [data]="eligibility()">
|
||||
<ng-template appAsyncLoaded let-eligible>
|
||||
@if (eligible) {
|
||||
|
||||
@@ -8,7 +8,12 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
import { WizardShellComponent, WizardError, WizardStatus, naarStapLabel } from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
@@ -34,13 +39,25 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
||||
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
|
||||
@Component({
|
||||
selector: 'app-intake-wizard',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, ReviewSectionComponent, ConfirmationComponent, WizardShellComponent],
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormFieldComponent,
|
||||
TextInputComponent,
|
||||
RadioGroupComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
DataRowComponent,
|
||||
ReviewSectionComponent,
|
||||
ConfirmationComponent,
|
||||
WizardShellComponent,
|
||||
],
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="cursor()"
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@intake.processName" processName="Herregistratie-intake"
|
||||
i18n-processName="@@intake.processName"
|
||||
processName="Herregistratie-intake"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
@@ -50,68 +67,186 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()"
|
||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })">
|
||||
|
||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||
>
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<app-form-field i18n-label="@@intake.q.buitenland" label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" required [error]="err('buitenlandGewerkt')">
|
||||
<app-radio-group name="buitenlandGewerkt" [options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenland"
|
||||
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
||||
fieldId="buitenlandGewerkt"
|
||||
required
|
||||
[error]="err('buitenlandGewerkt')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="buitenlandGewerkt"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
||||
(ngModelChange)="set('buitenlandGewerkt', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<app-form-field i18n-label="@@intake.q.land" label="In welk land?" fieldId="land" required [error]="err('land')">
|
||||
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" i18n-placeholder="@@intake.q.landPlaceholder" placeholder="bijv. België" />
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.land"
|
||||
label="In welk land?"
|
||||
fieldId="land"
|
||||
required
|
||||
[error]="err('land')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="land"
|
||||
[ngModel]="answers().land ?? ''"
|
||||
(ngModelChange)="set('land', $event)"
|
||||
name="land"
|
||||
i18n-placeholder="@@intake.q.landPlaceholder"
|
||||
placeholder="bijv. België"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field i18n-label="@@intake.q.buitenlandseUren" label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" required [error]="err('buitenlandseUren')">
|
||||
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder" placeholder="bijv. 800" />
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenlandseUren"
|
||||
label="Hoeveel uur heeft u daar gewerkt?"
|
||||
fieldId="buitenlandseUren"
|
||||
required
|
||||
[error]="err('buitenlandseUren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="buitenlandseUren"
|
||||
[ngModel]="answers().buitenlandseUren ?? ''"
|
||||
(ngModelChange)="set('buitenlandseUren', $event)"
|
||||
name="buitenlandseUren"
|
||||
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
||||
placeholder="bijv. 800"
|
||||
/>
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
@case ('werk') {
|
||||
<app-form-field i18n-label="@@intake.q.urenNl" label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" required [error]="err('uren')">
|
||||
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" i18n-placeholder="@@intake.q.urenNlPlaceholder" placeholder="bijv. 4160" />
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.urenNl"
|
||||
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
||||
fieldId="uren"
|
||||
required
|
||||
[error]="err('uren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="uren"
|
||||
[ngModel]="answers().uren ?? ''"
|
||||
(ngModelChange)="set('uren', $event)"
|
||||
name="uren"
|
||||
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
||||
placeholder="bijv. 4160"
|
||||
/>
|
||||
</app-form-field>
|
||||
@if (scholingZichtbaar()) {
|
||||
<app-form-field i18n-label="@@intake.q.scholing" label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" required [error]="err('scholingGevolgd')">
|
||||
<app-radio-group name="scholingGevolgd" [options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.scholing"
|
||||
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
||||
fieldId="scholingGevolgd"
|
||||
required
|
||||
[error]="err('scholingGevolgd')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="scholingGevolgd"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''"
|
||||
(ngModelChange)="set('scholingGevolgd', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<app-form-field i18n-label="@@intake.q.punten" label="Behaalde nascholingspunten" fieldId="punten" required [error]="err('punten')">
|
||||
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" i18n-placeholder="@@intake.q.puntenPlaceholder" placeholder="bijv. 200" />
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.punten"
|
||||
label="Behaalde nascholingspunten"
|
||||
fieldId="punten"
|
||||
required
|
||||
[error]="err('punten')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="punten"
|
||||
[ngModel]="answers().punten ?? ''"
|
||||
(ngModelChange)="set('punten', $event)"
|
||||
name="punten"
|
||||
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
||||
placeholder="bijv. 200"
|
||||
/>
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
@case ('review') {
|
||||
<app-alert type="info" i18n="@@intake.review.controleer">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
|
||||
<app-review-section i18n-heading="@@intake.sectie.buitenland" heading="Buitenland"
|
||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria" editAriaLabel="Wijzigen buitenland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })">
|
||||
<div app-data-row i18n-key="@@intake.review.buitenNl" key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'"></div>
|
||||
<app-alert type="info" i18n="@@intake.review.controleer"
|
||||
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
||||
>
|
||||
<app-review-section
|
||||
i18n-heading="@@intake.sectie.buitenland"
|
||||
heading="Buitenland"
|
||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
||||
editAriaLabel="Wijzigen buitenland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenNl"
|
||||
key="Buiten NL gewerkt"
|
||||
[value]="answers().buitenlandGewerkt ?? '—'"
|
||||
></div>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<div app-data-row i18n-key="@@intake.review.land" key="Land" [value]="answers().land ?? ''"></div>
|
||||
<div app-data-row i18n-key="@@intake.review.buitenlandseUren" key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''"></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.land"
|
||||
key="Land"
|
||||
[value]="answers().land ?? ''"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenlandseUren"
|
||||
key="Buitenlandse uren"
|
||||
[value]="answers().buitenlandseUren ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
<app-review-section class="app-section" i18n-heading="@@intake.sectie.werk" heading="Werk in Nederland"
|
||||
i18n-editAriaLabel="@@intake.werkWijzigenAria" editAriaLabel="Wijzigen werk in Nederland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })">
|
||||
<div app-data-row i18n-key="@@intake.review.urenNl" key="Uren NL" [value]="answers().uren ?? ''"></div>
|
||||
<app-review-section
|
||||
class="app-section"
|
||||
i18n-heading="@@intake.sectie.werk"
|
||||
heading="Werk in Nederland"
|
||||
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
||||
editAriaLabel="Wijzigen werk in Nederland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.urenNl"
|
||||
key="Uren NL"
|
||||
[value]="answers().uren ?? ''"
|
||||
></div>
|
||||
@if (scholingZichtbaar()) {
|
||||
<div app-data-row i18n-key="@@intake.review.scholing" key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''"></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.scholing"
|
||||
key="Aanvullende scholing"
|
||||
[value]="answers().scholingGevolgd ?? ''"
|
||||
></div>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<div app-data-row i18n-key="@@intake.review.punten" key="Nascholingspunten" [value]="answers().punten ?? ''"></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.punten"
|
||||
key="Nascholingspunten"
|
||||
[value]="answers().punten ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
}
|
||||
}
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-confirmation i18n-title="@@intake.success.title" title="Uw aanvraag tot herregistratie is ontvangen">
|
||||
<app-confirmation
|
||||
i18n-title="@@intake.success.title"
|
||||
title="Uw aanvraag tot herregistratie is ontvangen"
|
||||
>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw">Opnieuw beginnen</app-button>
|
||||
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw"
|
||||
>Opnieuw beginnen</app-button
|
||||
>
|
||||
</div>
|
||||
</app-confirmation>
|
||||
</div>
|
||||
@@ -151,13 +286,19 @@ export class IntakeWizardComponent {
|
||||
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
||||
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
|
||||
protected scholingThreshold = computed(
|
||||
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||
);
|
||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
readonly stepLabels = [$localize`:@@intake.step.buitenland:Buitenland`, $localize`:@@intake.step.werk:Werk`, $localize`:@@intake.step.controle:Controle`];
|
||||
readonly stepLabels = [
|
||||
$localize`:@@intake.step.buitenland:Buitenland`,
|
||||
$localize`:@@intake.step.werk:Werk`,
|
||||
$localize`:@@intake.step.controle:Controle`,
|
||||
];
|
||||
private stepTitles: Record<StepId, string> = {
|
||||
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
||||
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
||||
@@ -169,13 +310,19 @@ export class IntakeWizardComponent {
|
||||
const next = this.cursor() + 1;
|
||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||
});
|
||||
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
case 'Answering': return 'editing';
|
||||
case 'Submitting': return 'submitting';
|
||||
case 'Submitted': return 'submitted';
|
||||
case 'Failed': return 'failed';
|
||||
case 'Answering':
|
||||
return 'editing';
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
case 'Failed':
|
||||
return 'failed';
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. The
|
||||
@@ -188,13 +335,16 @@ export class IntakeWizardComponent {
|
||||
});
|
||||
|
||||
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
||||
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
|
||||
protected set = (key: keyof Answers, value: string) =>
|
||||
this.dispatch({ tag: 'SetAnswer', key, value });
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||
const seeded = this.seed();
|
||||
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
|
||||
queueMicrotask(() =>
|
||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
||||
);
|
||||
// Apply the server-owned threshold into machine state as it arrives. Track
|
||||
// only the policy value; untrack the dispatch (it reads the state signal
|
||||
// internally, which would otherwise make this effect loop on its own write).
|
||||
|
||||
@@ -17,14 +17,26 @@ const meta: Meta<IntakeWizardComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<IntakeWizardComponent>;
|
||||
|
||||
const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold: 1000 });
|
||||
const answering = (answers: Answers, cursor = 0): IntakeState => ({
|
||||
tag: 'Answering',
|
||||
answers,
|
||||
cursor,
|
||||
errors: {},
|
||||
scholingThreshold: 1000,
|
||||
});
|
||||
|
||||
export const Start: Story = { args: { seed: answering({}) } };
|
||||
// Inline reveal: country/hours appear within the buitenland step (cursor 0).
|
||||
export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 0) } };
|
||||
// Inline reveal: the scholing question appears within the werk step (cursor 1).
|
||||
export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) } };
|
||||
export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) } };
|
||||
export const LowHoursScholing: Story = {
|
||||
args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 1) },
|
||||
};
|
||||
export const Review: Story = {
|
||||
args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 2) },
|
||||
};
|
||||
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
|
||||
export const Failed: Story = {
|
||||
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
|
||||
};
|
||||
|
||||
@@ -9,11 +9,14 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w
|
||||
selector: 'app-intake-page',
|
||||
imports: [PageShellComponent, AlertComponent, IntakeWizardComponent],
|
||||
template: `
|
||||
<app-page-shell i18n-heading="@@intake.heading" heading="Herregistratie — intake" backLink="/dashboard">
|
||||
<app-page-shell
|
||||
i18n-heading="@@intake.heading"
|
||||
heading="Herregistratie — intake"
|
||||
backLink="/dashboard"
|
||||
>
|
||||
<app-alert type="info" i18n="@@intake.intro">
|
||||
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw
|
||||
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
|
||||
de pagina herlaadt.
|
||||
Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw antwoorden
|
||||
verschijnen er extra vragen. Uw antwoorden blijven bewaard als u de pagina herlaadt.
|
||||
</app-alert>
|
||||
<div class="app-section">
|
||||
<app-intake-wizard />
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
parseApplications,
|
||||
} from '@registratie/infrastructure/applications.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
@@ -31,7 +34,11 @@ export class ApplicationsStore {
|
||||
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||
try {
|
||||
const parsed = parseApplications(await this.adapter.list());
|
||||
this.state.set(parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) });
|
||||
this.state.set(
|
||||
parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||
);
|
||||
} catch (e) {
|
||||
this.state.set({ tag: 'Failure', error: e as Error });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { Aantekening } from '../domain/registration';
|
||||
import { BigProfile } from '../domain/big-profile';
|
||||
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
|
||||
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
|
||||
import { DashboardView, DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
|
||||
import {
|
||||
DashboardView,
|
||||
DashboardViewAdapter,
|
||||
parseDashboardView,
|
||||
} from '../infrastructure/dashboard-view.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
@@ -32,14 +36,20 @@ export class BigProfileStore {
|
||||
const rd = fromResource(this.viewRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDashboardView(rd.value);
|
||||
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
|
||||
return parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Registration + person, from the single aggregated call. */
|
||||
readonly profile = computed<RemoteData<Err, BigProfile>>(() => map(this.view(), (v) => v.profile));
|
||||
readonly profile = computed<RemoteData<Err, BigProfile>>(() =>
|
||||
map(this.view(), (v) => v.profile),
|
||||
);
|
||||
|
||||
/** Server-computed decisions (e.g. herregistratie eligibility) — rendered, not recomputed. */
|
||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() =>
|
||||
map(this.view(), (v) => v.decisions),
|
||||
);
|
||||
|
||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
|
||||
|
||||
@@ -2,9 +2,15 @@ import { DestroyRef, effect, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import type { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
|
||||
import type {
|
||||
SubmitApplicationRequest,
|
||||
SubmitApplicationResponse,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
parseApplications,
|
||||
} from '@registratie/infrastructure/applications.adapter';
|
||||
|
||||
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
||||
export interface DraftSnapshot {
|
||||
@@ -59,7 +65,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
ensuring ??= adapter.create(deps.type).then((newId) => {
|
||||
id = newId;
|
||||
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
|
||||
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: newId }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
void router!.navigate([], {
|
||||
relativeTo: route!,
|
||||
queryParams: { aanvraag: newId },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
return newId;
|
||||
});
|
||||
return ensuring;
|
||||
@@ -78,7 +89,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
const snap = deps.snapshot();
|
||||
if (!snap) return;
|
||||
const theId = await ensureId();
|
||||
await adapter.syncDraft(theId, { draft: snap.draft, stepIndex: snap.stepIndex, stepCount: snap.stepCount, documentIds: snap.documentIds });
|
||||
await adapter.syncDraft(theId, {
|
||||
draft: snap.draft,
|
||||
stepIndex: snap.stepIndex,
|
||||
stepCount: snap.stepCount,
|
||||
documentIds: snap.documentIds,
|
||||
});
|
||||
};
|
||||
|
||||
// One effect watches the snapshot; each change resets a debounce timer. The timer's
|
||||
@@ -117,7 +133,9 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
const findConcept = async (): Promise<string | undefined> => {
|
||||
try {
|
||||
const parsed = parseApplications(await adapter.list());
|
||||
return parsed.ok ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id : undefined;
|
||||
return parsed.ok
|
||||
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -143,7 +161,12 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
if (existing) {
|
||||
await load(existing);
|
||||
// Stamp the id into the URL so a reload resumes the same Concept.
|
||||
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: existing }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
void router!.navigate([], {
|
||||
relativeTo: route!,
|
||||
queryParams: { aanvraag: existing },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyResume(null);
|
||||
@@ -169,7 +192,13 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
id = undefined;
|
||||
ensuring = undefined;
|
||||
}
|
||||
if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
if (active())
|
||||
void router!.navigate([], {
|
||||
relativeTo: route!,
|
||||
queryParams: { aanvraag: null },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,19 +36,23 @@ export class RegistratieLookupStore {
|
||||
|
||||
/** The address to prefill the draft with, once BRP resolves with a found address;
|
||||
null otherwise (loading, error, no address, malformed). */
|
||||
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => {
|
||||
const json = this.adresRes.value();
|
||||
if (json === undefined) return null;
|
||||
const parsed = parseBrpAddress(json);
|
||||
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
|
||||
});
|
||||
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(
|
||||
() => {
|
||||
const json = this.adresRes.value();
|
||||
if (json === undefined) return null;
|
||||
const parsed = parseBrpAddress(json);
|
||||
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
|
||||
},
|
||||
);
|
||||
|
||||
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
|
||||
readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => {
|
||||
const rd = fromResource(this.diplomasRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseDuoLookup(rd.value);
|
||||
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
|
||||
return parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
/** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */
|
||||
|
||||
@@ -2,33 +2,57 @@ import { describe, it, expect } from 'vitest';
|
||||
import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
|
||||
import { Aanvraag } from './aanvraag';
|
||||
|
||||
const base = { id: '1', type: 'herregistratie' as const, documentIds: [], createdAt: '', updatedAt: '', submittedAt: '2024-05-12' };
|
||||
const base = {
|
||||
id: '1',
|
||||
type: 'herregistratie' as const,
|
||||
documentIds: [],
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
submittedAt: '2024-05-12',
|
||||
};
|
||||
|
||||
describe('submittedRow', () => {
|
||||
it('heading is the type, subtitle is the purpose', () => {
|
||||
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
|
||||
const row = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||
} as Aanvraag);
|
||||
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
|
||||
expect(row.subtitle).toBe(purposeLabel('herregistratie'));
|
||||
});
|
||||
|
||||
it('status line carries the status label, reference and submit date', () => {
|
||||
const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false } } as Aanvraag);
|
||||
expect(row.status).toContain(statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }));
|
||||
const row = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||
} as Aanvraag);
|
||||
expect(row.status).toContain(
|
||||
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
|
||||
);
|
||||
expect(row.status).toContain('R1');
|
||||
expect(row.status).toContain('12 mei 2024');
|
||||
});
|
||||
|
||||
it('manual review adds a note; rejection adds its reason', () => {
|
||||
const manual = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: true } } as Aanvraag);
|
||||
const manual = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'InBehandeling', referentie: 'R1', manual: true },
|
||||
} as Aanvraag);
|
||||
expect(manual.status).toContain('handmatig');
|
||||
const rejected = submittedRow({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
|
||||
const rejected = submittedRow({
|
||||
...base,
|
||||
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
|
||||
} as Aanvraag);
|
||||
expect(rejected.status).toContain('Onvoldoende uren');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detailRows', () => {
|
||||
it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => {
|
||||
const rows = detailRows({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' } } as Aanvraag);
|
||||
const rows = detailRows({
|
||||
...base,
|
||||
status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' },
|
||||
} as Aanvraag);
|
||||
const values = rows.map((r) => r.value);
|
||||
expect(values).toContain(TYPE_LABELS.herregistratie);
|
||||
expect(values).toContain('R2');
|
||||
@@ -37,7 +61,11 @@ describe('detailRows', () => {
|
||||
});
|
||||
|
||||
it('reference falls back to em dash for a Concept', () => {
|
||||
const rows = detailRows({ ...base, submittedAt: undefined, status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } } as Aanvraag);
|
||||
const rows = detailRows({
|
||||
...base,
|
||||
submittedAt: undefined,
|
||||
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
|
||||
} as Aanvraag);
|
||||
const ref = rows.find((r) => r.value === '—');
|
||||
expect(ref).toBeTruthy();
|
||||
expect(rows.length).toBe(5);
|
||||
|
||||
@@ -13,19 +13,26 @@ export const TYPE_LABELS: Record<AanvraagType, string> = {
|
||||
/** What the aanvraag is for (shown under the title). */
|
||||
export function purposeLabel(type: AanvraagType): string {
|
||||
switch (type) {
|
||||
case 'registratie': return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
|
||||
case 'herregistratie': return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
|
||||
case 'intake': return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
|
||||
case 'registratie':
|
||||
return $localize`:@@aanvraag.purpose.registratie:Inschrijving in het BIG-register`;
|
||||
case 'herregistratie':
|
||||
return $localize`:@@aanvraag.purpose.herregistratie:Verlenging van uw BIG-registratie`;
|
||||
case 'intake':
|
||||
return $localize`:@@aanvraag.purpose.intake:Intake-vragenlijst voor uw herregistratie`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The status as a plain label (what state the aanvraag is in). */
|
||||
export function statusLabel(status: AanvraagStatus): string {
|
||||
switch (status.tag) {
|
||||
case 'Concept': return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
|
||||
case 'InBehandeling': return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
|
||||
case 'Goedgekeurd': return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
|
||||
case 'Afgewezen': return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
|
||||
case 'Concept':
|
||||
return $localize`:@@aanvraag.status.concept:Concept (nog niet ingediend)`;
|
||||
case 'InBehandeling':
|
||||
return $localize`:@@aanvraag.status.inBehandeling:In behandeling`;
|
||||
case 'Goedgekeurd':
|
||||
return $localize`:@@aanvraag.status.goedgekeurd:Goedgekeurd`;
|
||||
case 'Afgewezen':
|
||||
return $localize`:@@aanvraag.status.afgewezen:Afgewezen`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +50,9 @@ export interface AanvraagRow {
|
||||
}
|
||||
|
||||
function formatNL(iso?: string): string {
|
||||
return iso ? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
return iso
|
||||
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
|
||||
@@ -53,10 +62,18 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
|
||||
const parts = [statusLabel(s)];
|
||||
const ref = referentie(s);
|
||||
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
|
||||
if (a.submittedAt) parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
|
||||
if (s.tag === 'InBehandeling' && s.manual) parts.push($localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`);
|
||||
if (a.submittedAt)
|
||||
parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
|
||||
if (s.tag === 'InBehandeling' && s.manual)
|
||||
parts.push(
|
||||
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
|
||||
);
|
||||
if (s.tag === 'Afgewezen') parts.push(s.reden);
|
||||
return { heading: TYPE_LABELS[a.type], subtitle: purposeLabel(a.type), status: parts.join(' · ') };
|
||||
return {
|
||||
heading: TYPE_LABELS[a.type],
|
||||
subtitle: purposeLabel(a.type),
|
||||
status: parts.join(' · '),
|
||||
};
|
||||
}
|
||||
|
||||
/** Key/value rows for the case-detail page (CIBG Datablock). */
|
||||
@@ -65,11 +82,20 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
|
||||
{ key: $localize`:@@aanvraag.detail.soort:Soort aanvraag`, value: TYPE_LABELS[a.type] },
|
||||
{ key: $localize`:@@aanvraag.detail.waarvoor:Waarvoor`, value: purposeLabel(a.type) },
|
||||
{ key: $localize`:@@aanvraag.detail.status:Status`, value: statusLabel(a.status) },
|
||||
{ key: $localize`:@@aanvraag.detail.referentie:Referentie`, value: referentie(a.status) || '—' },
|
||||
{ key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`, value: a.submittedAt ? formatNL(a.submittedAt) : '—' },
|
||||
{
|
||||
key: $localize`:@@aanvraag.detail.referentie:Referentie`,
|
||||
value: referentie(a.status) || '—',
|
||||
},
|
||||
{
|
||||
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
|
||||
value: a.submittedAt ? formatNL(a.submittedAt) : '—',
|
||||
},
|
||||
];
|
||||
if (a.status.tag === 'Afgewezen') {
|
||||
rows.push({ key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`, value: a.status.reden });
|
||||
rows.push({
|
||||
key: $localize`:@@aanvraag.detail.reden:Reden van afwijzing`,
|
||||
value: a.status.reden,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@ import { blockActions } from './block-actions';
|
||||
|
||||
describe('blockActions', () => {
|
||||
it('a Concept can be resumed or cancelled', () => {
|
||||
expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual(['resume', 'cancel']);
|
||||
expect(blockActions({ tag: 'Concept', stepIndex: 1, stepCount: 3 })).toEqual([
|
||||
'resume',
|
||||
'cancel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('an in-behandeling aanvraag only exposes its documents', () => {
|
||||
expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual(['viewDocuments']);
|
||||
expect(blockActions({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true })).toEqual([
|
||||
'viewDocuments',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolved aanvragen have no actions', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { State, reduce, initial } from './change-request.machine';
|
||||
|
||||
const editingWith = (draft: Partial<{ straat: string; postcode: string; woonplaats: string }>): State => ({
|
||||
const editingWith = (
|
||||
draft: Partial<{ straat: string; postcode: string; woonplaats: string }>,
|
||||
): State => ({
|
||||
tag: 'Editing',
|
||||
draft: { straat: '', postcode: '', woonplaats: '', ...draft },
|
||||
errors: {},
|
||||
@@ -23,13 +25,17 @@ describe('change-request reduce', () => {
|
||||
});
|
||||
|
||||
it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => {
|
||||
const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), { tag: 'Submit' });
|
||||
const s = reduce(editingWith({ straat: 'Lange Voorhout 9', postcode: '2514ea' }), {
|
||||
tag: 'Submit',
|
||||
});
|
||||
expect(s.tag).toBe('Submitting');
|
||||
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
|
||||
});
|
||||
|
||||
it('confirms and fails only from Submitting; Retry re-submits a failure', () => {
|
||||
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' });
|
||||
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
|
||||
tag: 'Submit',
|
||||
});
|
||||
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
|
||||
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
|
||||
|
||||
@@ -39,7 +45,9 @@ describe('change-request reduce', () => {
|
||||
});
|
||||
|
||||
it('Reset returns to the initial editing state', () => {
|
||||
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { tag: 'Submit' });
|
||||
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
|
||||
tag: 'Submit',
|
||||
});
|
||||
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,10 @@ function validate(draft: Draft): Result<Errors, Valid> {
|
||||
if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;
|
||||
if (!postcode.ok) errors.postcode = postcode.error;
|
||||
if (straat && postcode.ok) {
|
||||
return { ok: true, value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() } };
|
||||
return {
|
||||
ok: true,
|
||||
value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },
|
||||
};
|
||||
}
|
||||
return { ok: false, error: errors };
|
||||
}
|
||||
@@ -69,7 +72,9 @@ export function reduce(s: State, m: Msg): State {
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
|
||||
return s.tag === 'Submitting'
|
||||
? { tag: 'Submitted', data: s.data, referentie: m.referentie }
|
||||
: s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'Reset':
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine';
|
||||
|
||||
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) =>
|
||||
({ ...(initial as Extract<RegistratieState, { tag: 'Invullen' }>), ...over });
|
||||
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) => ({
|
||||
...(initial as Extract<RegistratieState, { tag: 'Invullen' }>),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('hasProgress', () => {
|
||||
it('is false for a fresh wizard', () => {
|
||||
@@ -10,13 +12,23 @@ describe('hasProgress', () => {
|
||||
});
|
||||
|
||||
it('ignores an auto-prefilled BRP address at step 0', () => {
|
||||
const s = invullen({ draft: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', antwoorden: {} } });
|
||||
const s = invullen({
|
||||
draft: {
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
adresHerkomst: 'brp',
|
||||
antwoorden: {},
|
||||
},
|
||||
});
|
||||
expect(hasProgress(s)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => {
|
||||
expect(hasProgress(invullen({ cursor: 1 }))).toBe(true);
|
||||
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(true);
|
||||
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,19 @@ const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
|
||||
upload: initialUpload,
|
||||
});
|
||||
|
||||
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
|
||||
const validDraft: Partial<Draft> = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' };
|
||||
const validAdres = {
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
correspondentie: 'post' as const,
|
||||
adresHerkomst: 'brp' as const,
|
||||
};
|
||||
const validDraft: Partial<Draft> = {
|
||||
...validAdres,
|
||||
diplomaId: 'd1',
|
||||
beroep: 'Arts',
|
||||
diplomaHerkomst: 'duo',
|
||||
};
|
||||
|
||||
describe('STEPS (fixed)', () => {
|
||||
it('always has the same three steps', () => {
|
||||
@@ -106,7 +117,18 @@ describe('adres origin (BRP vs handmatig)', () => {
|
||||
});
|
||||
|
||||
it('a manually entered address still submits (only manual diploma is gated)', () => {
|
||||
const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }));
|
||||
const s = submit(
|
||||
invullen({
|
||||
straat: 'Kerkstraat 1',
|
||||
postcode: '1234 AB',
|
||||
woonplaats: 'Utrecht',
|
||||
correspondentie: 'post',
|
||||
adresHerkomst: 'handmatig',
|
||||
diplomaId: 'd1',
|
||||
beroep: 'Arts',
|
||||
diplomaHerkomst: 'duo',
|
||||
}),
|
||||
);
|
||||
expect(s.tag).toBe('Indienen');
|
||||
expect((s as any).data.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
@@ -185,7 +207,12 @@ describe('submit', () => {
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives the full flow via messages', () => {
|
||||
let s: RegistratieState = initial;
|
||||
s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' });
|
||||
s = reduce(s, {
|
||||
tag: 'PrefillAdres',
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
});
|
||||
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
@@ -199,7 +226,10 @@ describe('reduce (message-driven happy path)', () => {
|
||||
});
|
||||
|
||||
it('SubmitFailed then Retry returns to Indienen with the same data', () => {
|
||||
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
|
||||
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
expect(s.tag).toBe('Mislukt');
|
||||
s = reduce(s, { tag: 'Retry' });
|
||||
expect(s.tag).toBe('Indienen');
|
||||
@@ -208,27 +238,51 @@ describe('reduce (message-driven happy path)', () => {
|
||||
});
|
||||
|
||||
describe('inline document upload (beroep step)', () => {
|
||||
const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true };
|
||||
const cat = {
|
||||
categoryId: 'diploma',
|
||||
label: 'Diploma',
|
||||
description: '',
|
||||
required: true,
|
||||
acceptedTypes: [],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
};
|
||||
|
||||
it('routes Upload messages through the upload reducer', () => {
|
||||
const s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
|
||||
const s = reduce(invullen(validDraft), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
expect((s as any).upload.categories).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('blocks the beroep step until a required category is satisfied', () => {
|
||||
let s = reduce(invullen(validDraft, 1), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
|
||||
let s = reduce(invullen(validDraft, 1), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
s = reduce(s, { tag: 'Next' }); // beroep → controle blocked
|
||||
expect(currentStep(s as any)).toBe('beroep');
|
||||
expect((s as any).errors.documenten).toBeTruthy();
|
||||
// choosing post delivery satisfies the requirement
|
||||
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
|
||||
s = reduce(s, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
|
||||
});
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('controle');
|
||||
});
|
||||
|
||||
it('includes delivery refs in the submitted data', () => {
|
||||
let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
|
||||
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
|
||||
let s = reduce(invullen(validDraft), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
s = reduce(s, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' },
|
||||
});
|
||||
const done = submit(s as any);
|
||||
expect(done.tag).toBe('Indienen');
|
||||
expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]);
|
||||
|
||||
@@ -84,7 +84,13 @@ export type RegistratieState =
|
||||
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
||||
|
||||
const emptyDraft: Draft = { antwoorden: {} };
|
||||
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {}, upload: initialUpload };
|
||||
export const initial: RegistratieState = {
|
||||
tag: 'Invullen',
|
||||
draft: emptyDraft,
|
||||
cursor: 0,
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
};
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
|
||||
@@ -112,11 +118,14 @@ function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Error
|
||||
const errors: Errors = {};
|
||||
switch (step) {
|
||||
case 'adres': {
|
||||
if (!d.straat || d.straat.trim() === '') errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
|
||||
if (!d.straat || d.straat.trim() === '')
|
||||
errors.straat = $localize`:@@validation.straat2:Vul een straat en huisnummer in.`;
|
||||
const pc = parsePostcode(d.postcode ?? '');
|
||||
if (!pc.ok) errors.postcode = pc.error;
|
||||
if (!d.woonplaats || d.woonplaats.trim() === '') errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
|
||||
if (!d.correspondentie) errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
if (!d.woonplaats || d.woonplaats.trim() === '')
|
||||
errors.woonplaats = $localize`:@@validation.woonplaats:Vul een woonplaats in.`;
|
||||
if (!d.correspondentie)
|
||||
errors.correspondentie = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
// E-mail is only required when 'email' is the chosen channel.
|
||||
if (d.correspondentie === 'email') {
|
||||
const e = parseEmail(d.email ?? '');
|
||||
@@ -135,7 +144,8 @@ function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Error
|
||||
// they're answered.
|
||||
const open: Record<string, string> = {};
|
||||
for (const id of d.vraagIds ?? []) {
|
||||
if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
|
||||
if (!(d.antwoorden[id] ?? '').trim())
|
||||
open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
|
||||
}
|
||||
if (Object.keys(open).length > 0) errors.antwoorden = open;
|
||||
// Required documents for this wizard attach to the beroep step (inline upload).
|
||||
@@ -186,7 +196,8 @@ export function setField(s: RegistratieState, key: DraftField, value: string): R
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
const draft: Draft = { ...s.draft, [key]: value };
|
||||
// Editing an address field means the user owns it now — not the BRP copy.
|
||||
if (key === 'straat' || key === 'postcode' || key === 'woonplaats') draft.adresHerkomst = 'handmatig';
|
||||
if (key === 'straat' || key === 'postcode' || key === 'woonplaats')
|
||||
draft.adresHerkomst = 'handmatig';
|
||||
return { ...s, draft };
|
||||
}
|
||||
|
||||
@@ -196,16 +207,30 @@ export function setCorrespondentie(s: RegistratieState, value: Correspondentie):
|
||||
}
|
||||
|
||||
/** Prefill the address from a BRP lookup and flag its origin (PRD §7). */
|
||||
export function prefillAdres(s: RegistratieState, straat: string, postcode: string, woonplaats: string): RegistratieState {
|
||||
export function prefillAdres(
|
||||
s: RegistratieState,
|
||||
straat: string,
|
||||
postcode: string,
|
||||
woonplaats: string,
|
||||
): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, straat, postcode, woonplaats, adresHerkomst: 'brp' } };
|
||||
}
|
||||
|
||||
/** Pick a DUO diploma; the beroep is derived from it and the applicable policy
|
||||
questions (`vraagIds`) come with it (both server-computed, passed in). */
|
||||
export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: string, vraagIds: string[]): RegistratieState {
|
||||
export function kiesDiploma(
|
||||
s: RegistratieState,
|
||||
diplomaId: string,
|
||||
beroep: string,
|
||||
vraagIds: string[],
|
||||
): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' }, errors: {} };
|
||||
return {
|
||||
...s,
|
||||
draft: { ...s.draft, diplomaId, beroep, vraagIds, diplomaHerkomst: 'duo' },
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Switch to manual diploma entry: the diploma isn't in DUO, so the MAXIMAL
|
||||
@@ -213,7 +238,17 @@ export function kiesDiploma(s: RegistratieState, diplomaId: string, beroep: stri
|
||||
beroep is declared separately (declareerBeroep). */
|
||||
export function kiesHandmatig(s: RegistratieState, vraagIds: string[]): RegistratieState {
|
||||
if (s.tag !== 'Invullen') return s;
|
||||
return { ...s, draft: { ...s.draft, diplomaId: 'handmatig', beroep: undefined, vraagIds, diplomaHerkomst: 'handmatig' }, errors: {} };
|
||||
return {
|
||||
...s,
|
||||
draft: {
|
||||
...s.draft,
|
||||
diplomaId: 'handmatig',
|
||||
beroep: undefined,
|
||||
vraagIds,
|
||||
diplomaHerkomst: 'handmatig',
|
||||
},
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Declare the beroep for a manually-entered diploma (chosen from a fixed list). */
|
||||
@@ -260,7 +295,9 @@ export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
|
||||
|
||||
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
|
||||
if (s.tag !== 'Indienen') return s;
|
||||
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };
|
||||
return r.ok
|
||||
? { tag: 'Ingediend', data: s.data, referentie: r.value }
|
||||
: { tag: 'Mislukt', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
export type RegistratieMsg =
|
||||
@@ -308,7 +345,9 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
|
||||
case 'Retry':
|
||||
return s.tag === 'Mislukt' ? { tag: 'Indienen', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
|
||||
return s.tag === 'Indienen'
|
||||
? { tag: 'Ingediend', data: s.data, referentie: m.referentie }
|
||||
: s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
||||
case 'Upload':
|
||||
|
||||
@@ -3,8 +3,12 @@ import { Registration } from './registration';
|
||||
import { isHerregistratieEligible, statusColor } from './registration.policy';
|
||||
|
||||
const reg = (status: Registration['status']): Registration => ({
|
||||
bigNummer: '19012345601', naam: 'Test', beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', status,
|
||||
bigNummer: '19012345601',
|
||||
naam: 'Test',
|
||||
beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01',
|
||||
geboortedatum: '1985-03-14',
|
||||
status,
|
||||
});
|
||||
|
||||
describe('registration.policy', () => {
|
||||
@@ -15,8 +19,18 @@ describe('registration.policy', () => {
|
||||
});
|
||||
|
||||
it('struck-off / suspended registrations are never eligible', () => {
|
||||
expect(isHerregistratieEligible(reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
|
||||
expect(isHerregistratieEligible(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }), new Date('2027-01-01'))).toBe(false);
|
||||
expect(
|
||||
isHerregistratieEligible(
|
||||
reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }),
|
||||
new Date('2027-01-01'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHerregistratieEligible(
|
||||
reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }),
|
||||
new Date('2027-01-01'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('statusColor is total over the union', () => {
|
||||
|
||||
@@ -38,7 +38,11 @@ export function herregistratieDeadline(reg: Registration): Date | null {
|
||||
SERVER-OWNED RULE: this now runs on the backend (BFF), which ships the result
|
||||
as `decisions.eligibleForHerregistratie` in the dashboard view. Kept here as
|
||||
the reference implementation + unit test; the frontend no longer calls it. */
|
||||
export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean {
|
||||
export function isHerregistratieEligible(
|
||||
reg: Registration,
|
||||
today: Date,
|
||||
windowMonths = 12,
|
||||
): boolean {
|
||||
const deadline = herregistratieDeadline(reg);
|
||||
if (!deadline) return false;
|
||||
const windowStart = new Date(deadline);
|
||||
|
||||
@@ -24,7 +24,10 @@ describe('tasksFromProfile', () => {
|
||||
});
|
||||
|
||||
it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
|
||||
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } };
|
||||
const reg: Registration = {
|
||||
...base,
|
||||
status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' },
|
||||
};
|
||||
const tasks = tasksFromProfile(reg, false);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toContain('geschorst');
|
||||
@@ -32,7 +35,10 @@ describe('tasksFromProfile', () => {
|
||||
});
|
||||
|
||||
it('surfaces a notice for a struck-off registration', () => {
|
||||
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } };
|
||||
const reg: Registration = {
|
||||
...base,
|
||||
status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' },
|
||||
};
|
||||
const tasks = tasksFromProfile(reg, false);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toContain('doorgehaald');
|
||||
|
||||
@@ -22,7 +22,10 @@ function formatNL(d: Date): string {
|
||||
* it does not recompute the rule (ADR-0001). The deadline is still formatted
|
||||
* client-side for the task copy (presentation, not a rule).
|
||||
*/
|
||||
export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] {
|
||||
export function tasksFromProfile(
|
||||
reg: Registration,
|
||||
eligibleForHerregistratie: boolean,
|
||||
): PortalTask[] {
|
||||
const tasks: PortalTask[] = [];
|
||||
|
||||
if (eligibleForHerregistratie) {
|
||||
|
||||
@@ -5,5 +5,7 @@ export type BigNummer = Brand<string, 'BigNummer'>;
|
||||
|
||||
export function parseBigNummer(raw: string): Result<string, BigNummer> {
|
||||
const t = raw.trim();
|
||||
return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`);
|
||||
return /^\d{11}$/.test(t)
|
||||
? ok(t as BigNummer)
|
||||
: err($localize`:@@validation.bigNummer:Een BIG-nummer bestaat uit 11 cijfers.`);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ export function parseEmail(raw: string): Result<string, Email> {
|
||||
// Deliberately lax: a single @ with non-empty, dot-bearing parts. Good enough
|
||||
// for instant feedback; the server re-validates.
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
|
||||
return err($localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`);
|
||||
return err(
|
||||
$localize`:@@validation.email:Voer een geldig e-mailadres in, bijv. naam@voorbeeld.nl.`,
|
||||
);
|
||||
}
|
||||
return ok(t as Email);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { parseUren } from './uren';
|
||||
|
||||
describe('parseUren', () => {
|
||||
it('accepts non-negative whole numbers, including 0', () => {
|
||||
for (const [raw, n] of [['0', 0], [' 40 ', 40], ['1000', 1000]] as const) {
|
||||
for (const [raw, n] of [
|
||||
['0', 0],
|
||||
[' 40 ', 40],
|
||||
['1000', 1000],
|
||||
] as const) {
|
||||
const r = parseUren(raw);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe(n);
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAanvraagStatus, parseApplicationSummary, parseApplications, parseApplicationDetail } from './applications.adapter';
|
||||
import {
|
||||
parseAanvraagStatus,
|
||||
parseApplicationSummary,
|
||||
parseApplications,
|
||||
parseApplicationDetail,
|
||||
} from './applications.adapter';
|
||||
|
||||
const concept = { id: 'a1', type: 'registratie', status: { tag: 'Concept', stepIndex: 1, stepCount: 4 }, documentIds: [], createdAt: '2026-07-01T10:00:00Z', updatedAt: '2026-07-01T10:05:00Z' };
|
||||
const concept = {
|
||||
id: 'a1',
|
||||
type: 'registratie',
|
||||
status: { tag: 'Concept', stepIndex: 1, stepCount: 4 },
|
||||
documentIds: [],
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
updatedAt: '2026-07-01T10:05:00Z',
|
||||
};
|
||||
|
||||
describe('parseAanvraagStatus', () => {
|
||||
it('parses each tag with its required fields', () => {
|
||||
expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({ ok: true, value: { tag: 'Concept', stepIndex: 2, stepCount: 4 } });
|
||||
expect(parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok).toBe(true);
|
||||
expect(parseAanvraagStatus({ tag: 'Concept', stepIndex: 2, stepCount: 4 })).toEqual({
|
||||
ok: true,
|
||||
value: { tag: 'Concept', stepIndex: 2, stepCount: 4 },
|
||||
});
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: true }).ok,
|
||||
).toBe(true);
|
||||
expect(parseAanvraagStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
|
||||
expect(parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok).toBe(true);
|
||||
expect(
|
||||
parseAanvraagStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'geen uren' }).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a missing status, unknown tag, and wrong-typed fields', () => {
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
SubmitApplicationRequest,
|
||||
SubmitApplicationResponse,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import { Aanvraag, AanvraagDetail, AanvraagStatus, AanvraagType } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
Aanvraag,
|
||||
AanvraagDetail,
|
||||
AanvraagStatus,
|
||||
AanvraagType,
|
||||
} from '@registratie/domain/aanvraag';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place
|
||||
@@ -54,20 +59,25 @@ export class ApplicationsAdapter {
|
||||
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
|
||||
|
||||
/** Trust-boundary parse of the status union — the tag drives which fields must exist. */
|
||||
export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result<string, AanvraagStatus> {
|
||||
export function parseAanvraagStatus(
|
||||
s: AanvraagStatusDto | undefined,
|
||||
): Result<string, AanvraagStatus> {
|
||||
if (!s || typeof s.tag !== 'string') return err('aanvraag: missing status');
|
||||
switch (s.tag) {
|
||||
case 'Concept':
|
||||
if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number') return err('aanvraag: bad Concept status');
|
||||
if (typeof s.stepIndex !== 'number' || typeof s.stepCount !== 'number')
|
||||
return err('aanvraag: bad Concept status');
|
||||
return ok({ tag: 'Concept', stepIndex: s.stepIndex, stepCount: s.stepCount });
|
||||
case 'InBehandeling':
|
||||
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean') return err('aanvraag: bad InBehandeling status');
|
||||
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
|
||||
return err('aanvraag: bad InBehandeling status');
|
||||
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
|
||||
case 'Goedgekeurd':
|
||||
if (typeof s.referentie !== 'string') return err('aanvraag: bad Goedgekeurd status');
|
||||
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
|
||||
case 'Afgewezen':
|
||||
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string') return err('aanvraag: bad Afgewezen status');
|
||||
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
|
||||
return err('aanvraag: bad Afgewezen status');
|
||||
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
|
||||
default:
|
||||
return err(`aanvraag: unknown status tag ${s.tag}`);
|
||||
@@ -76,8 +86,10 @@ export function parseAanvraagStatus(s: AanvraagStatusDto | undefined): Result<st
|
||||
|
||||
function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> {
|
||||
if (typeof dto.id !== 'string') return err('aanvraag: missing id');
|
||||
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) return err(`aanvraag: bad type ${dto.type}`);
|
||||
if (typeof dto.createdAt !== 'string' || typeof dto.updatedAt !== 'string') return err('aanvraag: missing timestamps');
|
||||
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
|
||||
return err(`aanvraag: bad type ${dto.type}`);
|
||||
if (typeof dto.createdAt !== 'string' || typeof dto.updatedAt !== 'string')
|
||||
return err('aanvraag: missing timestamps');
|
||||
const status = parseAanvraagStatus(dto.status);
|
||||
if (!status.ok) return status;
|
||||
return ok({
|
||||
|
||||
@@ -22,5 +22,9 @@ export class BigRegisterAdapter {
|
||||
|
||||
/** Map the wire DTO (all fields optional) onto our domain type. */
|
||||
function toAantekening(n: AantekeningDto): Aantekening {
|
||||
return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' };
|
||||
return {
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user