Compare commits

...

3 Commits

Author SHA1 Message Date
bf920696ac ci: harden workflow + add security scanning and format gates
Some checks failed
CI / storybook-a11y (push) Successful in 4m3s
CI / backend (push) Successful in 1m1s
CI / codeql (csharp) (push) Failing after 39m18s
CI / codeql (javascript-typescript) (push) Failing after 1m22s
CI / frontend (push) Successful in 1m25s
CI / api-client-drift (push) Successful in 1m34s
- permissions: contents:read (least privilege), concurrency cancel,
  scope push to main+tags (was: every branch, double-running with PRs),
  per-job timeout-minutes.
- security: npm audit --omit=dev, CodeQL SAST (TS + C#), Dependabot
  (npm/nuget/actions).
- format: npm run format:check + dotnet format --verify-no-changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
1137f59f7b style: format backend with dotnet format
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
e82309786d style: format frontend, docs and skills with prettier; add .prettierignore
One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
196 changed files with 6266 additions and 2600 deletions

View File

@@ -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).

View File

@@ -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).

View File

@@ -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 */
}
}
```

View File

@@ -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)

View File

@@ -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
View 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

View File

@@ -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
View 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/

View File

@@ -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;

View File

@@ -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

View File

@@ -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.)

View File

@@ -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"));
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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(); }
}
}

View File

@@ -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"),

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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;
}
}

View File

@@ -7,5 +7,5 @@ namespace BigRegister.Domain.Intake;
/// </summary>
public static class IntakePolicy
{
public const int ScholingThreshold = 1000;
public const int ScholingThreshold = 1000;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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));
}

View File

@@ -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

View File

@@ -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 | Lowmedium |
| 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 | Mediumhigh; 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 | Lowmedium |
| 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 | Mediumhigh; 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;

View File

@@ -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)

View File

@@ -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

View File

@@ -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 1015 (axe covers story churn);
03/04 before 0509 (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
```

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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`

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 50700), 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 50700), 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.16.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.16.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 |
---

View File

@@ -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`.)',
},
],
},

View File

@@ -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",

View File

@@ -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' },
]
],
};

View File

@@ -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' },
],
},

View File

@@ -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' });
}
}

View File

@@ -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>
`,
})

View File

@@ -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>
`,

View File

@@ -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 });

View File

@@ -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' });

View File

@@ -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 }));

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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']);
});

View File

@@ -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),
};
}
/**

View File

@@ -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);
});

View File

@@ -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),
};
}

View File

@@ -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 '';
}
});

View File

@@ -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>
}

View File

@@ -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>
`,
})

View File

@@ -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)';
}
});
}

View File

@@ -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'),
};

View File

@@ -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));
}

View File

@@ -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 });

View File

@@ -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 {

View File

@@ -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>
}
`,
})

View File

@@ -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,
);
}

View File

@@ -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');

View File

@@ -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. */

View File

@@ -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);

View File

@@ -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 =

View File

@@ -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() {

View File

@@ -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' } },
};

View File

@@ -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) {

View File

@@ -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).

View File

@@ -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' } },
};

View File

@@ -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 />

View File

@@ -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 });
}

View File

@@ -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[]>>(() => {

View File

@@ -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,
});
},
};
}

View File

@@ -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. */

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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', () => {

View File

@@ -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);
});
});

View File

@@ -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':

View File

@@ -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);
});
});

View File

@@ -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' }]);

View File

@@ -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':

View File

@@ -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', () => {

View File

@@ -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);

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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.`);
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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', () => {

View File

@@ -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({

View File

@@ -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