Compare commits

..
8 Commits
Author SHA1 Message Date
ehoandClaude Opus 5 dff5f96bb3 docs: correct RD-20's file count, and record the ninth miss
RD-20 asserted `git grep -l "toWizardErrors"` would find 5 files, but its own
Steps list regenerates `behaviour-spec.mdx`, and the generator publishes every
`describe` title. Naming a spec after the function it tests puts the name in
the generated document too, so the honest count is 6.

The agent refused to rename the describe block to satisfy the number, which is
the correct response and matches the precedent from RD-14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:01:01 +02:00
ehoandClaude Sonnet 5 831940f1b9 refactor: extract toWizardErrors, adopted by all 3 wizards (RD-20)
Each wizard flattened its per-field error record into the shell's
WizardError[] summary with its own copy of the same loop. Extract one
pure helper, wizard-errors.ts, next to naarStapLabel. Add a spec that
covers a flat record, an empty record, skipped undefined/empty-string
values, the idPrefix, and a skipped nested object.

registratie-wizard.machine.ts changes Errors from an interface to a
type alias, because only a type alias gets an implicit index
signature and is assignable to the helper's Record<string, unknown>
parameter. The other two machines already declare their error maps as
type aliases, so this also makes the three consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:00:04 +02:00
ehoandClaude Opus 5 a196a380ce docs: restore the two backlog paths RD-19's target of 0 removed
RD-18 established that a ticket reference inside a path to a document that
still exists is a pointer, not provenance, and exempted one. RD-19 did not
re-run that check against `backend/`, and asserted a target of 0. Two such
paths existed, so the agent described the two documents in prose instead. The
path no longer resolves and the reader must search.

Restore both paths, correct the ticket's decision 1 and acceptance target to
2, and record the miss as the eighth in the README's list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:50:06 +02:00
ehoandClaude Sonnet 5 8560746d15 refactor: strip WP-/RB- ticket refs from backend (RD-19)
The backend half of the sweep RD-18 did for the front end. git blame
holds the provenance and stays correct when the code moves; the
comment names a closed ticket and tells the reader nothing the
sentence around it does not.

public/letter.css and LetterHtml.golden.html change together, because
the renderer inlines the CSS and the golden file snapshots the
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:48:08 +02:00
ehoandClaude Sonnet 5 dd11eafe50 refactor: strip WP-/RB- ticket refs from apps and libs (RD-18)
204 WP-NN/RB-NN comments named a closed ticket instead of the code they
sit next to. git blame already records history and stays correct when
code moves; the comment does not. This sweep removes the reference and
keeps the sentence, across 95 files in apps/ and libs/ plus the
behaviour-spec generator's header text.

Eleven references stay: five story files justify an a11y disable per
the README's rule, and one line in a11y.mdx documents that convention.
Two sentences needed a rewrite, not a deletion, so the reference's
meaning survives its removal. behaviour-spec.mdx is regenerated, not
hand-edited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:23:07 +02:00
ehoandClaude Sonnet 5 3895588b9a refactor: story titles to Domein/<Context>/<Name>; add 2 missing stories (RD-04)
Three dashboard-section stories used a fourth title segment
(`Domein/Registratie/Dashboard/<Name>`) that the sidebar rule does not have.
Drop the `Dashboard/` segment so all story titles follow the one rule from
CLAUDE.md decision 5.

Add the two missing stories for sections that have more than one visual
state: `wat-moet-ik-regelen` (4 states) and `wat-wilt-u-doen` (2 states, the
first story in the new `Domein/Overzicht/` bucket). `beheer-links` gets no
story — it has one visual state and its other branch renders nothing.

The `MetTaken` story disables the a11y addon with the same reason and WP-11
reference already used on `task-list.stories.ts` and `choice-list.stories.ts`:
`app-choice-link`'s host sits between the keuzelijst `<ul>` and its `<li>`,
a pre-existing structural gap this ticket does not fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:56:17 +02:00
ehoandClaude Opus 5 fb2e58ab87 docs: record the concurrent-agent trap that RD-17 hit
`/clear` ends the supervisor's context. It does not stop the developer agent
that session spawned. A fresh supervisor read the half-finished tree as an
interrupted session and dispatched a second agent onto the same ticket. Both
agents then ran `dotnet test` in one checkout, which truncated the shared
SQLite file and produced the stale-database failure the Troubleshooting
section already records under a different cause.

Also record that a bare `dotnet test` runs the OpenZaak integration test that
both CI scripts filter out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:41:32 +02:00
ehoandClaude Sonnet 5 e221834f6e refactor: add successOr, sweep remaining inline unwraps (RD-17)
Eight sites hand-rolled `rd.tag === 'Success' ? rd.value : fallback`. Six
take the new `successOr(rd, fallback)`, one takes the existing `successOf`,
and one (`big-profile.store.ts`) uses the existing `map`, since it returns a
RemoteData rather than an unwrapped value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:33:56 +02:00
206 changed files with 1720 additions and 641 deletions
+3 -3
View File
@@ -20,7 +20,7 @@ export const routes: Routes = [
},
{
path: 'aanvraag/:id',
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the
// Same capability the werkvoorraad list itself is gated by — the
// detail page is reachable only from a row already filtered to that capability.
canActivate: [capabilityGuard('aanvraag:beoordelen')],
loadComponent: () =>
@@ -36,14 +36,14 @@ export const routes: Routes = [
},
{
path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
},
{
path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
// Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')],
loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -4,7 +4,7 @@ import { MEDEWERKER_ID, currentRollen } from './medewerker';
/**
* Infrastructure: resolves the current medewerker identity into a `Principal`
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
* (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
* `Principal` directly rather than a `Result` with an error variant that can never
@@ -2,7 +2,7 @@ import { Component, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
/**
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and,
* Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
* BSN, and this app has no password of its own to check either way. There is
* nothing to compose beyond one button, which is itself evidence for the ADR — the
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined;
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`.
/** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
Keyed by id: navigating to a different case resets to Loading. */
@Injectable({ providedIn: 'root' })
export class BeoordelingStore {
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined;
/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp
/** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
@Injectable({ providedIn: 'root' })
export class WerkvoorraadStore {
@@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in
/** View-model mapping shared by the werkvoorraad list and the beoordeling
detail screen: type/status → labels. Pure, no Angular. Lives here (not in
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
@@ -1,8 +1,8 @@
import { AanvraagType } from './werkvoorraad-item';
/**
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65)
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open"
* A case's full status lifecycle as the beoordeling detail screen sees it —
* wider than `WerkvoorraadStatus`, which only ever sees the two "still open"
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
*/
@@ -1,6 +1,6 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the
/** The three actions the beoordeling screen offers a behandelaar — mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum — see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
@@ -1,5 +1,5 @@
/**
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view
* A queue entry as the behandelportal sees it — the parsed, domain-side view
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
*
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
@@ -13,7 +13,7 @@ import {
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/**
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its
* Infrastructure adapter for the beoordeling detail read — the only place its
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
* mapped to domain by the parse* boundary below.
*/
@@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine';
/**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single
* Infrastructure adapter for recording a behandelaar's decision — the single
* place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority).
@@ -8,7 +8,7 @@ import {
} from '@behandeling/domain/werkvoorraad-item';
/**
* Infrastructure adapter for the behandelportal's queue read (WP-64) — the only
* Infrastructure adapter for the behandelportal's queue read — the only
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
@@ -1,7 +1,7 @@
import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing
/** Organism: the documents linked to an aanvraag — plain links to the existing
(pre-existing, unauthenticated — same as ssp's own document previews) content
endpoint. No new shared atom: a context-local list, not a reusable building block. */
@Component({
@@ -1,5 +1,6 @@
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
@@ -13,8 +14,8 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/**
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b)
* Page: one aanvraag's beoordeling detail. The werkvoorraad list links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
* `decided`, and the page just reloads (the server is the authority on the new status).
*/
@@ -73,10 +74,7 @@ export class BeoordelingPage {
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
protected rows = detailRows;
protected readonly view = computed(() => {
const rd = this.store.view();
return rd.tag === 'Success' ? rd.value : undefined;
});
protected readonly view = computed(() => successOf(this.store.view()));
constructor() {
void this.store.load(this.id);
@@ -14,7 +14,7 @@ import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/b
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/**
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same
* Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the
@@ -4,9 +4,9 @@ import { ApplicationLinkComponent } from '@shared/ui/application-link/applicatio
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition
of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page (WP-65). */
beoordeling detail page. */
@Component({
selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent],
@@ -5,14 +5,15 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
/**
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page.
* Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
* a case's detail is out of scope here (WP-65).
* a case's detail is out of scope here.
*/
@Component({
selector: 'app-werkvoorraad-page',
@@ -56,10 +57,7 @@ export class WerkvoorraadPage {
protected access = inject(AccessStore);
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
protected items = computed(() => {
const rd = this.store.items();
return rd.tag === 'Success' ? rd.value : [];
});
protected items = computed(() => successOr(this.store.items(), []));
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
@@ -71,7 +69,7 @@ export class WerkvoorraadPage {
private loadRequested = false;
constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson).
// same guard-against-the-loop idiom as AdminCasesPage.
effect(() => {
if (this.canBeoordelen() && !this.loadRequested) {
this.loadRequested = true;
@@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
WP-61's bootstrap trim, not revisited by this migration. */
the bootstrap trim, not revisited by this migration. */
export const ADMIN_LINKS: readonly AdminLink[] = [
{
label: $localize`:@@header.nav.stamdata:Stamdata`,
+4 -4
View File
@@ -61,7 +61,7 @@ export const routes: Routes = [
},
{
path: 'brief/huisstijl',
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default
// Admin-only org-template editor: capabilityGuard denies-by-default
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')],
@@ -78,7 +78,7 @@ export const routes: Routes = [
},
{
path: 'beheer/zaken',
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default
// Admin-only cases overview + delete: capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
@@ -88,14 +88,14 @@ export const routes: Routes = [
},
{
path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
},
{
path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
// Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')],
loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -7,7 +7,7 @@ import { Principal } from '../domain/principal';
@Injectable({ providedIn: 'root' })
export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Principal>> {
const r = parseBsn(bsn);
@@ -54,7 +54,7 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() {
const opened: Blob[] = [];
@@ -158,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
});
});
// --- WP-27: undo/redo history + rejection diff ---
// --- Undo/redo history + rejection diff ---
function block(id: string, text: string): LetterBlock {
return {
@@ -308,7 +308,7 @@ describe('BriefStore rejection diff', () => {
describe('BriefStore.previewLetter', () => {
afterEach(() => vi.restoreAllMocks());
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => {
it('opens the composed letter via BLOB_PRESENTER on success', async () => {
const { presenter, opened } = fakeBlobPresenter();
const store = setup(
{
@@ -412,11 +412,11 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
});
});
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds
// that); this fake adapter is what exercises the branch until then. ---
// --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s yet;
// this fake adapter is what exercises the branch until then. ---
describe('BriefStore.load — 404 tolerance (RB-22)', () => {
describe('BriefStore.load — 404 tolerance', () => {
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { ok: true, value: view };
@@ -55,8 +55,8 @@ export class BriefStore implements PendingSave {
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
/** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
`Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded
(they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */
@@ -64,7 +64,7 @@ export class BriefStore implements PendingSave {
readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo;
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
/** The letter as it stood when it was REJECTED, captured shell-side. The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null);
@@ -81,7 +81,7 @@ export class BriefStore implements PendingSave {
);
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
/** The org template the letter renders with (WP-24). Server-owned appearance data,
/** The org template the letter renders with. Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null);
@@ -125,7 +125,7 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
});
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand
/** True once a 404-triggered recovery has been attempted (CQ-007's expand
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
a repeated 404 falls straight to the `error` branch below and can never reach
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
@@ -200,7 +200,7 @@ export class BriefStore implements PendingSave {
}
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
// the shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(),
flush: () => this.flushSave(),
@@ -223,7 +223,7 @@ export class BriefStore implements PendingSave {
}
}
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
/** Retry a failed autosave — reuses the existing flush path, no new state. */
retrySave() {
void this.flushSave();
}
@@ -311,7 +311,7 @@ export class BriefStore implements PendingSave {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break;
case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
// Capture the letter as-rejected for the resubmission diff. This is the
// "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief);
this.store.dispatch({
@@ -32,7 +32,7 @@ const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
];
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() {
const opened: Blob[] = [];
@@ -70,10 +70,10 @@ function setup(
return TestBed.inject(OrgTemplateStore);
}
// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw
// --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
describe('OrgTemplateStore.proefbrief (RB-28)', () => {
describe('OrgTemplateStore.proefbrief', () => {
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
// Given a loaded sub-org template.
const { presenter, opened } = fakeBlobPresenter();
@@ -28,7 +28,7 @@ const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
/**
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
* Root singleton for the admin org-template editor. The Elm machine owns the
* editable draft; commands here do the debounced save, publish (impact-confirm),
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
* logo upload reuses the shared upload transport; its completion mutates the draft
@@ -151,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
this.debouncedSave.schedule();
}
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the
// 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the
// shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({
canSave: () => this.loaded() !== null,
+1 -1
View File
@@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief';
/**
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
* teaching payload of WP-27: because state is one value, "what changed since the letter
* teaching payload here: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
*
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
@@ -3,7 +3,7 @@ import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from '
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
/**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
* The admin org-template editor as one Elm-style machine (PRD Brief v2 §5) —
* the same idiom as the wizards. The DRAFT org template is form state (edited in
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
@@ -1,9 +1,9 @@
/**
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
* The organization template (Brief v2 PRD §3): the SECOND template axis —
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
* Orthogonal to the case-type template (sections + placeholders); the two only meet
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26).
* never edits it here (the admin editor does).
*/
export interface Margins {
@@ -30,7 +30,7 @@ export interface OrgTemplate {
readonly version: number;
}
// --- admin editor (WP-26) ---
// --- admin editor ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion {
@@ -38,9 +38,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
* returned brief. `load` (the only read) does its own try/catch instead of the
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance.
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — CQ-007's
* expand half). Today's backend never 404s `GET /brief`, so the
* `notFound` branch is unreached until it does; this adapter is ready in advance.
*/
export interface BriefView {
@@ -66,7 +66,7 @@ export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is
/** True when the thrown value carries an HTTP 404 status — matches both the
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
RB-23 gives the endpoint a documented 404 response). */
the endpoint gets a documented 404 response). */
function isHttpNotFound(e: unknown): boolean {
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
}
@@ -14,19 +14,19 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 — without `X-Subject` this always previewed
* here explicitly (without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
*
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
* `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
* mutable resource served under one unversioned URL — independent of WP-74's
* identity work, and not a complete fix by itself: see the KNOWN GAP note below.
* mutable resource served under one unversioned URL — independent of the
* identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
*
* KNOWN GAP (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
* KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
* this repo's own e2e run against a real backend observed this endpoint's SENT
* response still carrying the draft watermark, even though (a) the outgoing request
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the
@@ -34,7 +34,7 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* did not change the outcome, so it is very unlikely a client-side caching artifact —
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
* `DemoOwner`, which needs backend-side investigation (out of WP-74's file scope —
* `DemoOwner`, which needs backend-side investigation (out of this file's scope —
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
* `zorgverlener` identity until this is root-caused).
*/
+2 -2
View File
@@ -167,7 +167,7 @@ export class BriefPage {
void this.store.resetDemo();
}
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
/** Typed narrowing for the `<app-async>` loaded slot: a structural
directive's context can't inherit a generic from a sibling host input, so the
Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => {
@@ -179,7 +179,7 @@ export class BriefPage {
void this.store.load();
}
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the
rich-text editor or a form control, so the browser's own text undo keeps working
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
protected onKey(e: KeyboardEvent) {
@@ -53,9 +53,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26).
`'template'` reserves the org-identity regions for the admin editor.
Letter typography/geometry come from the shared `public/letter.css` contract —
the same file the backend preview renderer inlines (WP-25). */
the same file the backend preview renderer inlines. */
@Component({
selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
@@ -82,7 +82,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums;
}
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
/* Rejection-diff badge: a small pill above a changed/added block. */
.diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm);
@@ -98,7 +98,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
background: var(--rhc-color-oranje-500);
}
.diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700);
}
@@ -345,12 +345,12 @@ export class LetterCanvasComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
view) or admin editor ('template'). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
/** Initial zoom; the in-canvas controls take over from here. */
zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
/** Blocks changed/added/removed since the letter was rejected; badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
exist in the letter — the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
@@ -445,7 +445,7 @@ export class LetterCanvasComponent {
constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
// falls mid-line; the caption says "±" and the server preview is authoritative.
const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
@@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = {
args: { editableRegions: 'none', diagnostics: [] },
};
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
/** Admin editor focus: body read-only, no "not yours" tint. */
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */
export const WithDiff: Story = {
args: {
editableRegions: 'none',
@@ -150,14 +150,14 @@ export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
};
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
// Inline SVG so the story needs no backend/upload round-trip (the logo upload).
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
/** Published org logo: the letterhead shows it above the org name. */
export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
};
@@ -137,7 +137,7 @@ export class LetterComposerComponent {
canReject = input(false);
canSend = input(false);
busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
/** Rejection diff: the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
removedCount = input(0);
@@ -181,7 +181,7 @@ export const Sent: Story = {
}),
};
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
/** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed,
added and removed since the last rejection. */
export const RejectionDiff: Story = {
render: () =>
@@ -70,7 +70,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
};
/**
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
* Organism: the admin org-template editor. The mirror of the drafter's
* composer — the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only
* sample. Margins, logo upload, version history and the publish bar sit around it.
@@ -87,12 +87,12 @@ const sampleLogo =
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
/** Published logo: the letterhead canvas shows it above the org name. */
export const MetLogo: Story = {
args: { logoUrl: sampleLogo },
};
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught
/** Client-side upload rejection (existing `rejectReason`) — type/size caught
before the file ever reaches the backend. */
export const LogoUploadFout: Story = {
args: {
@@ -7,7 +7,7 @@ import { AccessStore } from '@shared/application/access.store';
import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
/** Page: thin container for the admin org-template editor. Deny-by-default
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
for admins. Loads once the capability resolves; wires store commands to the organism. */
@Component({
@@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief';
inserts ALL checked passages at once (a single message upstream) — there is no
single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
Superseded by `besluit-panel`'s guided drafting: no consumer left in
`src/app` outside its own story. Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({
selector: 'app-passage-picker',
@@ -87,7 +87,7 @@ export class PassagePickerComponent {
protected checked = signal<Record<string, boolean>>({});
protected query = signal('');
/** Client-side filter on label + rendered content text — the library is small, so no
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
server search. Placeholder keys are searchable too (see `textOf`). */
protected filtered = computed(() => {
const q = this.query().trim().toLowerCase();
if (!q) return this.passages();
@@ -147,7 +147,7 @@ describe('intake acceptance journeys', () => {
});
});
it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => {
it('raising uren above the threshold after answering scholing drops both fields', () => {
// Given a journey that answered the scholing question while uren was low.
const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -170,7 +170,7 @@ describe('intake acceptance journeys', () => {
);
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
// exactly the crafted-POST-shaped payload WP-69's server rule rejects.
// exactly the crafted-POST-shaped payload the server rule rejects.
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
@@ -171,7 +171,7 @@ describe('submit', () => {
expect(withScholing.data.punten).toBe(200);
});
it('does not require punten for a hidden question (WP-69 §6)', () => {
it('does not require punten for a hidden question', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either.
const staleScholingNoPunten = givenIntake(
@@ -183,7 +183,7 @@ describe('submit', () => {
expect(good.data.aanvullendeScholing).toBeUndefined();
});
it('drops punten when raising uren hides the question (WP-69 §6)', () => {
it('drops punten when raising uren hides the question', () => {
// Same stale answer, but this time punten was also filled in while uren was low.
const staleScholingWithPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -116,7 +116,7 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
// visible (lageUren) AND scholing was followed — matching the template's
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
// raising uren above the threshold left an error on a field the template no longer
// renders (WP-69 §6).
// renders.
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error;
@@ -149,8 +149,8 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
const aanvullendeScholing = lageUren(a, scholingThreshold)
? a.scholingGevolgd === 'ja'
: undefined;
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer (WP-69
// §6) — a stale 'ja' left over from when uren was low, after uren was raised above the
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer
// a stale 'ja' left over from when uren was low, after uren was raised above the
// threshold, must not leak a punten value into the parsed, submitted ValidIntake.
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
return ok({
@@ -9,6 +9,7 @@ import {
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
@@ -251,12 +252,7 @@ export class HerregistratieWizardComponent {
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.editing()?.errors ?? {};
return (Object.keys(e) as (keyof typeof e)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -15,6 +15,7 @@ import {
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -273,7 +274,7 @@ export class IntakeWizardComponent {
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
// WP-69: the scholing answer rides along so the server can re-validate it as the
// The scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({
@@ -360,12 +361,9 @@ export class IntakeWizardComponent {
});
/** Current step's field errors, flattened for the shell's error summary. The
field ids match the answer keys, so the summary anchors jump to the field. */
protected errorList = computed<WizardError[]>(() => {
const e = this.answering()?.errors ?? {};
return (Object.keys(e) as (keyof Answers)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
protected errorList = computed<WizardError[]>(() =>
toWizardErrors(this.answering()?.errors ?? {}),
);
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) =>
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { WatWiltUDoenSection } from './wat-wilt-u-doen.section';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
const meta: Meta<WatWiltUDoenSection> = {
title: 'Domein/Overzicht/Wat Wilt U Doen',
component: WatWiltUDoenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<WatWiltUDoenSection>;
export const InschrijvingOpen: Story = {
decorators: [
applicationConfig({
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => true } }],
}),
],
};
export const InschrijvingDicht: Story = {
decorators: [
applicationConfig({
providers: [{ provide: FeatureFlagStore, useValue: { enabled: () => false } }],
}),
],
};
@@ -46,8 +46,8 @@ describe('AanvragenStore', () => {
expect(store.lastError()).toBeNull();
});
// RB-20: a failed cancel must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back
// A failed cancel must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the cancel fails', async () => {
const cancel = vi.fn().mockRejectedValue(new Error('boom'));
@@ -14,7 +14,7 @@ type Err = Error | undefined;
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
* surfaces `lastError` on failure (RB-20).
* surfaces `lastError` on failure.
*/
@Injectable({ providedIn: 'root' })
export class AanvragenStore {
@@ -23,7 +23,7 @@ export class AanvragenStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly aanvragen = this.state.asReadonly();
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by
/** Set on a failed cancel: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly();
@@ -55,7 +55,7 @@ export class AanvragenStore {
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
No resync — the delete succeeded, so the optimistic removal is authoritative. On
failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the
failure, roll back AND surface the error — a silent reappearance leaves the
user guessing why the block came back. */
async cancel(id: string) {
const before = this.state();
@@ -44,8 +44,8 @@ describe('AdminCasesStore', () => {
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
});
// RB-20: a failed delete must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back
// A failed delete must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before this fix it only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the delete fails', async () => {
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
@@ -7,11 +7,11 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
type Err = Error | undefined;
/**
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
* Admin view of ALL cases across owners (`cases:manage`) — the back-office
* counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the
* failure. Admin delete removes any case (any owner, submitted or not — the
* server enforces the capability).
*/
@Injectable({ providedIn: 'root' })
@@ -21,7 +21,7 @@ export class AdminCasesStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly();
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by
/** Set on a failed delete: the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly();
@@ -47,7 +47,7 @@ export class AdminCasesStore {
}
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */
AND surface it — a silent reappearance leaves the admin guessing why. */
async delete(id: string) {
const before = this.state();
if (before.tag === 'Success') {
@@ -52,10 +52,12 @@ export class BigProfileStore {
);
/** Specialisms/notes stay a separate stream (they have their own empty state). */
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0);
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd;
});
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
map(
fromResource(this.aantekeningenRes, (v) => !v || v.length === 0),
(v) => v ?? [],
),
);
// --- Optimistic herregistratie state, shared with the dashboard -----------
private pending = signal(false);
@@ -96,7 +96,7 @@ describe('createDraftSync', () => {
expect(r.ok).toBe(false);
});
it('recovers from a create conflict by adopting the existing Concept (WP-35)', async () => {
it('recovers from a create conflict by adopting the existing Concept', async () => {
// Server enforces one Concept per type: a stale/cross-tab create is rejected (409),
// and ensureId adopts the existing Concept from the list instead of erroring.
const create = vi.fn().mockRejectedValue({ status: 409 });
@@ -63,7 +63,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
if (id) return id;
ensuring ??= adapter
.create(deps.type)
// WP-35: one Concept per type is server-enforced. Within a tab the resumeGate
// One Concept per type is server-enforced. Within a tab the resumeGate
// already prevents a second create, but a cross-tab/stale race can still hit the
// server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure.
@@ -3,7 +3,7 @@ import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aa
/**
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
* before it can start writing (RB-21 / CQ-001). Free functions that take the adapter
* before it can start writing (CQ-001). Free functions that take the adapter
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
* these two functions only read.
@@ -10,8 +10,8 @@
*/
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
// Ingediend/MeerInfoGevraagd (ADR-0002/WP-63) are widened into the union so the parse
// boundary + renderers are ready, but no backend path emits them yet — that's WP-65's
// Ingediend/MeerInfoGevraagd (ADR-0002) are widened into the union so the parse
// boundary + renderers are ready, but no backend path emits them yet — that's the
// behandelaar-facing transition endpoint.
export type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number }
@@ -29,7 +29,7 @@ export interface Aanvraag {
createdAt: string;
updatedAt: string;
submittedAt?: string;
/** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36);
/** The case owner (a BSN). Only populated by the admin cross-owner list;
the user's own list leaves it undefined. */
owner?: string;
}
@@ -5,7 +5,7 @@ import {
} from '@registratie/domain/value-objects/telefoonnummer';
/** What the user is typing (raw, possibly invalid). The BRP address is NOT part of
the form — it is authoritative and shown read-only (WP-34); only the phone number
the form — it is authoritative and shown read-only; only the phone number
is editable here. */
export interface Draft {
telefoon: string;
@@ -66,7 +66,7 @@ export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
question id (a step can show several questions). */
export interface Errors {
export type Errors = {
straat?: string;
postcode?: string;
woonplaats?: string;
@@ -75,7 +75,7 @@ export interface Errors {
diploma?: string;
documenten?: string;
antwoorden?: Record<string, string>;
}
};
export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
@@ -32,12 +32,12 @@ export class AanvragenAdapter {
return this.client.aanvragenAll();
}
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */
/** Admin: every case across all owners (`cases:manage`). Parsed at the boundary. */
listAll(): Promise<AanvraagSummaryDto[]> {
return this.client.casesAll();
}
/** Admin: delete ANY case (any owner, submitted or not — WP-36). */
/** Admin: delete ANY case (any owner, submitted or not). */
deleteAny(id: string): Promise<void> {
return this.client.cases(id);
}
@@ -117,7 +117,7 @@ function parseCommon(dto: AanvraagSummaryDto): Result<string, Aanvraag> {
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
submittedAt: dto.submittedAt,
owner: dto.owner, // only present on the admin cross-owner list (WP-36)
owner: dto.owner, // only present on the admin cross-owner list
});
}
@@ -6,7 +6,7 @@ import { Valid } from '@registratie/domain/change-request.machine';
* Infrastructure adapter for the telefoonwijziging POST (`/api/v1/change-requests`) —
* the single place the network client lives for contact changes, so the command
* and the UI never touch `ApiClient`. The BRP address is authoritative and not
* submitted (WP-34); only the phone number is. Returns the server reference; the
* submitted; only the phone number is. Returns the server reference; the
* server re-validates and is the authority.
*/
@Injectable({ providedIn: 'root' })
@@ -6,13 +6,14 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { formatDatumNl } from '@shared/kernel/datum';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view';
import { AdminCasesStore } from '@registratie/application/admin-cases.store';
/**
* Admin page: every case across all owners, with an admin delete (WP-36). Lives in
* Admin page: every case across all owners, with an admin delete. Lives in
* `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the
* user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default
* capability gate (`cases:manage`): a denial alert for non-admins, the list for admins.
@@ -78,10 +79,7 @@ export class AdminCasesPage {
protected access = inject(AccessStore);
protected canManage = computed(() => this.access.can('cases:manage'));
protected cases = computed(() => {
const rd = this.store.cases();
return rd.tag === 'Success' ? rd.value : [];
});
protected cases = computed(() => successOr(this.store.cases(), []));
protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`;
protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`;
@@ -111,7 +109,7 @@ export class AdminCasesPage {
private loadRequested = false;
constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise).
// Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson).
// Depends only on canManage() + a plain flag — never the store model (the loop lesson).
effect(() => {
if (this.canManage() && !this.loadRequested) {
this.loadRequested = true;
@@ -20,7 +20,7 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
/**
* Organism: contact-change (telefoonwijziging) form. The BRP address is authoritative
* and shown READ-ONLY (WP-34) — you change your address at the gemeente, not here — so
* and shown READ-ONLY — you change your address at the gemeente, not here — so
* only the phone number is editable. Uses the SAME idiom as the wizards: all state in
* one signal driven by the pure `reduce` (change-request.machine.ts), submitted via a
* `submit-*` command returning `Result`. The server re-validates.
@@ -34,7 +34,7 @@ function storeStub(aanvragen: RemoteData<Error | undefined, Aanvraag[]>, lastErr
}
const meta: Meta<MijnAanvragenSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Aanvragen',
title: 'Domein/Registratie/Mijn Aanvragen',
component: MijnAanvragenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
@@ -6,6 +6,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { successOr } from '@shared/application/remote-data';
import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag';
import {
@@ -88,10 +89,9 @@ export class MijnAanvragenSection {
protected submittedRow = submittedRow;
protected aanvragen = computed<Aanvraag[]>(() => {
const rd = this.store.aanvragen();
return rd.tag === 'Success' ? sortForDashboard(rd.value) : [];
});
protected aanvragen = computed<Aanvraag[]>(() =>
sortForDashboard(successOr(this.store.aanvragen(), [])),
);
protected concepten_ = computed(() => concepten(this.aanvragen()));
protected ingediend_ = computed(() => ingediend(this.aanvragen()));
@@ -28,7 +28,7 @@ function storeStub(profileRd: RemoteData<Error | undefined, BigProfile>) {
}
const meta: Meta<MijnRegistratieSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Registratie',
title: 'Domein/Registratie/Mijn Registratie',
component: MijnRegistratieSection,
};
export default meta;
@@ -17,7 +17,7 @@ function storeStub(aantekeningen: RemoteData<Error | undefined, Aantekening[]>)
}
const meta: Meta<SpecialismenSection> = {
title: 'Domein/Registratie/Dashboard/Specialismen',
title: 'Domein/Registratie/Specialismen',
component: SpecialismenSection,
};
export default meta;
@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { WatMoetIkRegelenSection } from './wat-moet-ik-regelen.section';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { BigProfile } from '@registratie/domain/big-profile';
import { HerregistratieDecisions } from '@registratie/domain/registration';
import { RemoteData } from '@shared/application/remote-data';
import { loading, success } from '@shared/testing/remote-data';
const profile: BigProfile = {
registration: {
bigNummer: '19012345601',
naam: 'Dr. A. (Anna) de Vries',
beroep: 'Arts',
registratiedatum: '2012-09-01',
geboortedatum: '1985-03-14',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-09-01' },
},
person: {
naam: 'Dr. A. (Anna) de Vries',
geboortedatum: '1985-03-14',
adres: { straat: 'Rijksweg 1', postcode: '2514 EA', woonplaats: 'Den Haag' },
},
};
/** Minimal store stand-in — only the members the section's template and class read. */
function storeStub(
profileRd: RemoteData<Error | undefined, BigProfile>,
decisionsRd: RemoteData<Error | undefined, HerregistratieDecisions>,
pendingHerregistratie: boolean,
) {
return {
profile: () => profileRd,
decisions: () => decisionsRd,
pendingHerregistratie: () => pendingHerregistratie,
reloadProfile: () => {},
};
}
const meta: Meta<WatMoetIkRegelenSection> = {
title: 'Domein/Registratie/Wat Moet Ik Regelen',
component: WatMoetIkRegelenSection,
};
export default meta;
type Story = StoryObj<WatMoetIkRegelenSection>;
export const Loading: Story = {
decorators: [
applicationConfig({
providers: [{ provide: BigProfileStore, useValue: storeStub(loading(), loading(), false) }],
}),
],
};
export const MetTaken: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: true }),
false,
),
},
],
}),
],
parameters: {
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
// Same pre-existing gap as task-list.stories.ts and choice-list.stories.ts. WP-11
// (CIBG markup fidelity) reworks this markup; see
// docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export const NietsOpenstaand: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
false,
),
},
],
}),
],
};
export const InBehandeling: Story = {
decorators: [
applicationConfig({
providers: [
{
provide: BigProfileStore,
useValue: storeStub(
success(profile),
success({ eligibleForHerregistratie: false }),
true,
),
},
],
}),
],
};
@@ -17,11 +17,12 @@ import {
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { RemoteData } from '@shared/application/remote-data';
import { RemoteData, successOr } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import {
@@ -468,14 +469,7 @@ export class RegistratieWizardComponent {
/** Current step's errors (incl. per-question), flattened for the error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.invullen()?.errors ?? {};
const out: WizardError[] = [];
for (const [k, v] of Object.entries(e)) {
if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v });
}
for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) {
if (msg) out.push({ id: 'vraag-' + qid, message: msg });
}
return out;
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
});
protected adresSamenvatting = computed(() => {
const d = this.draft();
@@ -516,10 +510,7 @@ export class RegistratieWizardComponent {
inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
generic from the sibling [data] input (Angular only infers a structural
directive's type parameter from an input on that same node). */
protected duoData = computed<DuoLookupDto | null>(() => {
const rd = this.lookupRd();
return rd.tag === 'Success' ? rd.value : null;
});
protected duoData = computed<DuoLookupDto | null>(() => successOr(this.lookupRd(), null));
readonly jaNee = JA_NEE;
@@ -136,7 +136,7 @@ export class DebugStateComponent {
pendingHerregistratie: this.profileStore?.pendingHerregistratie(),
}));
// Dev switchers (WP-33): flip role/scenario without hand-editing the URL. Both are
// Dev switchers flip role/scenario without hand-editing the URL. Both are
// read per-request in interceptors, so a reload re-runs them and re-fetches decisions.
protected readonly roles = ROLES;
protected readonly scenarios = SCENARIOS;
@@ -158,7 +158,7 @@ export class DebugStateComponent {
this.applyAndReload();
}
// Strip the dev params from the URL before reloading (WP-37) so a stale ?scenario=/?role=
// Strip the dev params from the URL before reloading so a stale ?scenario=/?role=
// in the address bar can't override the value the switcher just stored (currentScenario/
// currentRole read the URL first) — otherwise a switch to "default"/"drafter" gets stuck.
private applyAndReload(): void {
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Tiny, dependency-free TS highlighter for the teaching showcase (WP-39). Escapes HTML,
* Tiny, dependency-free TS highlighter for the teaching showcase. Escapes HTML,
* then wraps line-comments, strings, and a fixed keyword set in `.c`/`.s`/`.k` spans (the
* classes `concepts.page` styles). Deliberately naive — good enough for the short, curated
* snippets shown here; not a real tokenizer. Input is always our OWN source (extracted by
+2 -2
View File
@@ -10,8 +10,8 @@
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
<!-- The letter-rendering contract (WP-24): shared verbatim with the backend's
HTML preview renderer (WP-25 inlines this same file) — keep it self-contained. -->
<!-- The letter-rendering contract: shared verbatim with the backend's
HTML preview renderer (the backend inlines this same file) — keep it self-contained. -->
<link rel="stylesheet" href="letter.css" />
</head>
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
+1 -1
View File
@@ -1,7 +1,7 @@
bin/
obj/
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
# Runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
bigregister.db
bigregister.db-shm
bigregister.db-wal
+3 -3
View File
@@ -1,4 +1,4 @@
# WP-30: lean deployable image (optional — not used by the dev demo, which keeps the SDK
# Lean deployable image (optional — not used by the dev demo, which keeps the SDK
# image in the root docker-compose.yml for `dotnet run` hot-reload). Build from the repo
# root: `docker build -f backend/Dockerfile .`
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
@@ -9,10 +9,10 @@ RUN dotnet publish backend/src/BigRegister.Api -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --chown=$APP_UID:$APP_UID --from=build /app .
# LetterHtml.Render (WP-25) walks up from AppContext.BaseDirectory looking for a sibling
# LetterHtml.Render walks up from AppContext.BaseDirectory looking for a sibling
# public/letter.css (the FE⇄BE letter contract) — this keeps that lookup working here too.
COPY --chown=$APP_UID:$APP_UID public ./public
# $APP_UID (uid/gid 1654, "app") is baked into this base image for exactly this purpose —
# non-root, and chown'd above so it can still create/write bigregister.db (WP-22) at /app.
# non-root, and chown'd above so it can still create/write bigregister.db at /app.
USER $APP_UID
ENTRYPOINT ["dotnet", "BigRegister.Api.dll"]
+1 -1
View File
@@ -76,7 +76,7 @@ semantics) is introduced as **`/api/v2`** served alongside v1 until clients migr
- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply.
- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant.
- `Intake/IntakePolicy.cs` — scholing threshold + completeness re-validation on submit
(`RejectIncompleteScholing`, WP-69).
(`RejectIncompleteScholing`).
- `Submissions/SubmissionRules.cs` — submit rejections + reference generation.
## Typed client (NSwag)
+17 -17
View File
@@ -1,4 +1,4 @@
# OpenZaak integration harness (WP-54)
# OpenZaak integration harness
A real OpenZaak, for developing/testing the ZGW seam (`backend/src/BigRegister.Api/Zgw/`)
against something that isn't a fixture or a stub `HttpMessageHandler`. Deliberately **not**
@@ -22,7 +22,7 @@ published), and one zaak (`BIG-2026-000123`) with an initiator rol for the seede
(`111222333` — the same fixture BSN `OpenZaakZaakSourceTests.cs` uses). It writes what it
seeded to `seeded.env` (gitignored) and prints a summary.
**Idempotent (WP-56)** — every resource is looked up by its natural key (the same field(s)
**Idempotent** — every resource is looked up by its natural key (the same field(s)
OpenZaak enforces identity on: catalogus by `domein`+`rsin`, zaaktype by `catalogus`+
`identificatie`, statustype by `zaaktype`+`volgnummer`, roltype by `zaaktype`+
`omschrijvingGeneriek`, zaak by `identificatie`) before creating it, so re-running against an
@@ -81,7 +81,7 @@ and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`,
`scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness
up, so it never runs where the harness doesn't exist.
## Notifications-enabled profile (WP-58)
## Notifications-enabled profile
The base harness above never delivers a real notification (`NOTIFICATIONS_DISABLED: 'true'`,
no celery worker) — fine for the read/write ZGW seam, not for proving a live webhook round-trip.
@@ -137,7 +137,7 @@ already high going in; `ZGW_DEBUG_HTTP=1` on `api`, see `docker-compose.openzaak
diagnostics to help nail the cause next time it reproduces). Prints the URLs to check
afterward and the teardown commands.
Two caveats, both non-fatal (WP-60 catches and flags rather than surfacing an error):
Two caveats, both non-fatal (the BFF catches and flags rather than surfacing an error):
**only `herregistratie` has a seeded zaaktype** here, so submit that wizard to prove a real
write; and **no Documenten content is seeded**, so a document upload's ZGW half no-ops (pick
"per post" in the wizard's document step, or ignore it).
@@ -148,7 +148,7 @@ write; and **no Documenten content is seeded**, so a document upload's ZGW half
docker compose -f docker-compose.openzaak.yml down -v
```
## Production (WP-55)
## Production
This dev harness stays dev-only: hardcoded `SECRET_KEY`, `POSTGRES_HOST_AUTH_METHOD=trust`,
`IS_HTTPS: 'no'`, a client secret checked into `setup_configuration/data.yaml`. A real
@@ -188,7 +188,7 @@ app change.
Django migrations then `setup_configuration` against `setup_configuration/data.yaml`), and
`web` (the OpenZaak API on `:8000`). Pinned to `openzaak/open-zaak:1.29.1`. No
celery/celery-beat/celery-flower/nginx — trimmed for a lean, fast-booting harness; layer
`docker-compose.openzaak.notificaties.yml` (WP-58) on top for a real async notification
`docker-compose.openzaak.notificaties.yml` on top for a real async notification
delivery round-trip.
`NOTIFICATIONS_DISABLED=true` is required, not optional: without it, OpenZaak 500s (and
**rolls back the whole create**) on any notified resource — see the compose file's comment.
@@ -197,14 +197,14 @@ app change.
one `bigregister-test` client with `heeft_alle_autorisaties: false` — this YAML mechanism
(`vng_api_common`'s `ApplicatieConfigurationModel`) has no field for granular scopes at all,
so the client starts with zero Autorisaties; `bootstrap-catalogus.sh` grants the exact ones
it needs (WP-57).
it needs.
- `bootstrap-catalogus.sh` — the business content (catalogus/zaaktype/zaak/…) `setup_configuration`
has no YAML for; every field value here was checked against OpenZaak's own OpenAPI spec and a
live run of this exact script, not guessed (two OpenZaak quirks it works around: a zaaktype
needs ≥1 resultaattype and 2 statustypen before it can be published, and its
`selectielijstklasse` and the zaaktype's `selectielijstProcestype` must reference the same
`procesType` on the public VNG selectielijst API). Idempotent (WP-56) — see "Bring it up" above.
Also grants `bigregister-test`'s Autorisaties via `manage.py shell` (WP-57, see the script's
`procesType` on the public VNG selectielijst API). Idempotent — see "Bring it up" above.
Also grants `bigregister-test`'s Autorisaties via `manage.py shell` (see the script's
top comment): `ztc` scopes (`catalogi.lezen`/`catalogi.schrijven`, this script's own
content-creation needs) up front, `zrc` scopes (`zaken.aanmaken`/`zaken.bijwerken`/
`zaken.lezen`, scoped to the one zaaktype the BFF and this script both use) once that
@@ -213,26 +213,26 @@ app change.
grant (scoped to a real `informatieobjecttype`, which this script would also need to seed)
when a later WP wires DRC content into this harness.
- **Not here**: Documenten (DRC) content, or a real Notificaties API (NRC) — add DRC content if a
later WP needs to prove that round-trip against a live instance too (WP-51 is fixture-tested
later change needs to prove that round-trip against a live instance too (fixture-tested
today). A real NRC is a separate application (`open-notificaties`) this harness deliberately
doesn't stand up — WP-58's notifications-enabled profile (below) proves live delivery without
doesn't stand up — the notifications-enabled profile (below) proves live delivery without
one, since this harness only ever has one subscriber.
- `docker-compose.openzaak.notificaties.yml` (WP-58) — opt-in overlay: one celery worker for
- `docker-compose.openzaak.notificaties.yml` — opt-in overlay: one celery worker for
OpenZaak (async notification delivery needs it) + `NOTIFICATIONS_DISABLED: 'false'`, joined to
the repo root's own compose network so it can reach the `api` container by name (tried
`host.docker.internal:host-gateway` first; this environment's rootless Podman doesn't route
container→host-port traffic through it). See "Notifications-enabled profile" below.
- `bootstrap-notificaties.sh` (WP-58) — points OpenZaak's `NotificationsConfig` at the BFF's
- `bootstrap-notificaties.sh` — points OpenZaak's `NotificationsConfig` at the BFF's
webhook via a `zgw_consumers.Service` (`update_or_create`, idempotent) instead of provisioning
a real NRC `abonnement`; preflights that the BFF is reachable with the right secret first
(a misconfigured target here means every write to a notified resource 500s and rolls back).
- `verify-notificatie.sh` (WP-58) — the runnable end-to-end check: PATCHes the seeded zaak, polls
the BFF's own `/admin/audit` (WP-41) for the resulting `zgw:notificatie`/`allow` row.
- `docker-compose.openzaak.prod.yml` (WP-55) — production overrides layered on top of
- `verify-notificatie.sh` — the runnable end-to-end check: PATCHes the seeded zaak, polls
the BFF's own `/admin/audit` for the resulting `zgw:notificatie`/`allow` row.
- `docker-compose.openzaak.prod.yml` — production overrides layered on top of
`docker-compose.openzaak.yml`: real `SECRET_KEY`/DB password/site domain/allowed-hosts from
required env vars (fails fast if unset), password DB auth instead of `trust`, `IS_HTTPS: 'yes'`.
Adds no image/service of its own — see "Production" above for the full flow.
- `setup_configuration/data.prod.yaml.template` (WP-55) — the prod counterpart of `data.yaml`
- `setup_configuration/data.prod.yaml.template` — the prod counterpart of `data.yaml`
with no secret in it (`${OPENZAAK_CLIENT_SECRET}` etc. as placeholders); `render-prod-secrets.sh`
fills it in to the gitignored `data.prod.yaml`, which the prod compose override mounts over
the container's `data.yaml`.
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# WP-54 (seeding) / WP-56 (idempotency) — seeds business content
# Seeds business content
# (catalogus/zaaktype/statustype/roltype/zaak/status/rol) into the OpenZaak harness started
# by docker-compose.openzaak.yml. `setup_configuration/data.yaml` only covers infra config
# (JWTSecret + Applicatie) — confirmed by reading the installed `django_setup_configuration`
@@ -17,7 +17,7 @@
# zaak's `identificatie` + `url` on success; also writes them to seeded.env (repo-ignored) for
# OpenZaakIntegrationTests.cs to assert against.
#
# WP-57: `bigregister-test` starts with ZERO Autorisaties (data.yaml sets
# `bigregister-test` starts with ZERO Autorisaties (data.yaml sets
# heeft_alle_autorisaties: false) — the setup_configuration YAML has no field for granular
# scopes at all (confirmed from vng_api_common's own ApplicatieConfigurationModel), so this
# script grants them itself via `manage.py shell` (Django ORM, inside the `web` container) at
@@ -71,7 +71,7 @@ oz() {
echo "$json"
}
# Grant (replace) an Autorisatie for $CLIENT_ID directly via the ORM (see the WP-57 note up
# Grant (replace) an Autorisatie for $CLIENT_ID directly via the ORM (see the note up
# top for why this bypasses the REST Autorisaties API). $1 = component, $2 = python list
# literal of scopes, $3.. = extra `Autorisatie(...)` kwargs as `name=value` (value already a
# valid Python literal, e.g. a quoted URL).
@@ -168,7 +168,7 @@ print(json.dumps({
echo " created: $zaaktype_url"
fi
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for WP-66's besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..."
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for the besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..."
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"]' \
"zaaktype=\"$zaaktype_url\"" \
'max_vertrouwelijkheidaanduiding="openbaar"'
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# WP-58 — points OpenZaak's own NotificationsConfig straight at this repo's BFF webhook
# (POST /api/v1/zgw/notificaties, WP-52) instead of standing up a real Notificaties API (NRC)
# Points OpenZaak's own NotificationsConfig straight at this repo's BFF webhook
# (POST /api/v1/zgw/notificaties) instead of standing up a real Notificaties API (NRC)
# + abonnement — see docker-compose.openzaak.notificaties.yml's ponytail note for why. Requires
# that overlay running (adds the celery worker + flips NOTIFICATIONS_DISABLED) AND the repo
# root's own `docker compose up` running (the overlay joins its `api` container's network —
@@ -41,7 +41,7 @@ from zgw_consumers.models import Service
service, _ = Service.objects.update_or_create(
slug="bff-webhook",
defaults=dict(
label="BIG-register BFF webhook (WP-58)",
label="BIG-register BFF webhook",
api_type=APITypes.orc,
api_root="$BFF_API_ROOT",
auth_type=AuthTypes.api_key,
@@ -13,7 +13,7 @@
#
# 1. Why container-to-container instead of `http://localhost:8000`: this dev environment's
# rootless Podman drops container→host-port traffic through `host.docker.internal`
# (confirmed for the WP-58 notifications overlay's celery worker — DNS resolves it, every
# (confirmed for the notifications overlay's celery worker — DNS resolves it, every
# TCP connect times out).
#
# 2. Why the ROOT project's `api` joins INTO this project's network (below), not the other way
@@ -1,9 +1,9 @@
# WP-58 — notifications-enabled overlay, layered ON TOP of docker-compose.openzaak.yml
# Notifications-enabled overlay, layered ON TOP of docker-compose.openzaak.yml
# (never alone):
#
# docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml up -d
#
# The base file stays the WP-54 fast-iteration default (NOTIFICATIONS_DISABLED=true, no
# The base file stays the fast-iteration default (NOTIFICATIONS_DISABLED=true, no
# worker) so nobody testing the read/write seam has to pull/boot this. This overlay flips
# NOTIFICATIONS_DISABLED off and adds the one celery worker needed to actually deliver a
# notification (see base file's ponytail note).
@@ -14,7 +14,7 @@
# bootstrap-notificaties.sh points OpenZaak's NotificationsConfig straight at the BFF's webhook
# instead — same delivery proof (a real write → a real HTTP POST → the BFF's audit trail), far
# less harness to stand up and keep alive. Add a real NRC (+ abonnement/kanaal routing) if a
# later WP needs more than one subscriber or real kanaal-filtered fan-out.
# later change needs more than one subscriber or real kanaal-filtered fan-out.
#
# No celery-beat here: send_notification is a plain async task (client.post on save), not a
# scheduled one — beat only matters on a real NRC's polling side, which this harness doesn't have.
@@ -1,4 +1,4 @@
# WP-55 — production overrides for docker-compose.openzaak.yml: real secrets, real DB auth,
# Production overrides for docker-compose.openzaak.yml: real secrets, real DB auth,
# HTTPS-aware settings. Use ON TOP of the base file, never alone (it has no image/ports of its
# own to add — see backend/openzaak/README.md for the required env vars and full flow):
#
+3 -3
View File
@@ -1,12 +1,12 @@
# WP-54 — a real OpenZaak to develop/test the ZGW seam against, kept OUT of the root
# A real OpenZaak to develop/test the ZGW seam against, kept OUT of the root
# docker-compose.yml on purpose (see backend/openzaak/README.md): OpenZaak is a full Django
# stack (postgres + redis), heavy compared to this repo's own FE+BFF, and nobody who isn't
# touching the ZGW slice should have to pull/boot it.
#
# ponytail: trimmed vs. open-zaak's own published compose — no celery/celery-beat/celery-flower
# (async notification delivery, never asserted by the integration test) and no nginx (the test
# hits web's port directly). Add them back only if a later WP needs an actual notification
# round-trip against this harness (NRC delivery is already covered by fixture tests, WP-52).
# hits web's port directly). Add them back only if a later change needs an actual notification
# round-trip against this harness (NRC delivery is already covered by fixture tests).
services:
db:
image: postgis/postgis:17-3.5
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# WP-55 — renders setup_configuration/data.prod.yaml.template into the gitignored
# Renders setup_configuration/data.prod.yaml.template into the gitignored
# data.prod.yaml docker-compose.openzaak.prod.yml mounts over the container's data.yaml.
# Run this once before `docker compose ... up` in a production deploy; re-run whenever the
# secrets rotate. Fails fast (no output file) if a required env var is missing — never
@@ -1,10 +1,10 @@
# Prod counterpart of data.yaml (WP-54's dev-only version, kept as-is for local iteration —
# Prod counterpart of data.yaml (the dev-only version, kept as-is for local iteration —
# see docker-compose.openzaak.yml's own comment on why it hardcodes a client secret). This
# template has no secret in it; render-prod-secrets.sh substitutes OPENZAAK_CLIENT_SECRET
# into it to produce the gitignored data.prod.yaml that docker-compose.openzaak.prod.yml
# mounts over the container's data.yaml.
#
# Least-privilege client scopes (WP-57): heeft_alle_autorisaties is false, matching the dev
# Least-privilege client scopes: heeft_alle_autorisaties is false, matching the dev
# harness (setup_configuration has no YAML field for granular `autorisaties` — see
# data.yaml's comment). This template only covers infra config; a real deploy must grant this
# client's Autorisaties the same way bootstrap-catalogus.sh does for the dev harness — via
@@ -2,7 +2,7 @@
# documented CLI config mechanism — see docker-compose.openzaak.yml) instead of the Django
# admin. Creates the ONE application the bootstrap script + integration test authenticate as.
#
# heeft_alle_autorisaties is false (WP-57, least privilege) — but
# heeft_alle_autorisaties is false (least privilege) — but
# `ApplicatieConfigurationModel` (vng_api_common's setup_configuration step) has no field for
# granular `autorisaties` at all, only this boolean. So this client starts with ZERO scopes;
# bootstrap-catalogus.sh grants the exact ones it needs via `manage.py shell` (Django ORM,
@@ -12,7 +12,7 @@ sites_config_enable: true
sites_config:
items:
- domain: localhost:8000
name: OpenZaak (WP-54 harness)
name: OpenZaak (harness)
vng_api_common_credentials_config_enable: true
vng_api_common_credentials:
@@ -26,5 +26,5 @@ vng_api_common_applicaties:
- uuid: 5a09b3c9-6a54-4b2b-8f3c-1f9b6b6a3a01
client_ids:
- bigregister-test
label: BIG-register BFF (WP-54 test harness)
label: BIG-register BFF (test harness)
heeft_alle_autorisaties: false
+3 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# WP-58 — proves the "real write -> real webhook delivery" round-trip end-to-end: PATCHes the
# Proves the "real write -> real webhook delivery" round-trip end-to-end: PATCHes the
# zaak bootstrap-catalogus.sh seeded (a notified ZRC resource), then polls the BFF's own audit
# trail (WP-41) for the resulting `zgw:notificatie` row. Requires bootstrap-catalogus.sh and
# trail for the resulting `zgw:notificatie` row. Requires bootstrap-catalogus.sh and
# bootstrap-notificaties.sh to have already run.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -29,7 +29,7 @@ jwt() {
printf '%s.%s' "$signing_input" "$sig"
}
echo "Triggering a real write: PATCH $ZAAK_URL (bijwerken — WP-57 granted zaken.aanmaken"
echo "Triggering a real write: PATCH $ZAAK_URL (bijwerken — the client is granted zaken.aanmaken"
echo "for exactly ONE status, so a second status create 403s; a zaak update is the write this"
echo "client's narrowed scope can repeat)..."
response=$(curl -sS -X PATCH -H "Authorization: Bearer $(jwt)" -H 'Content-Type: application/json' \
@@ -77,11 +77,11 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D
public sealed record ChangeRequestRequest(string Telefoon);
// Authz/PII-reveal audit row (WP-41) — data-minimised, no PII (see AuthzAuditEntry).
// Authz/PII-reveal audit row — data-minimised, no PII (see AuthzAuditEntry).
public sealed record AuthzAuditDto(
string At, string Action, string Resource, string Decision, string Role, string CorrelationId);
// Feature flags (WP-47): the resolved flag set + the admin toggle body.
// Feature flags: the resolved flag set + the admin toggle body.
public sealed record FeatureFlagDto(string Key, string Description, bool Enabled);
public sealed record SetFeatureFlagRequest(bool Enabled);
@@ -103,7 +103,7 @@ public sealed record AanvraagSummaryDto(
string Id, string Type, AanvraagStatusDto Status,
IReadOnlyList<string> DocumentIds,
string CreatedAt, string UpdatedAt, string? SubmittedAt,
string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it
string? Owner = null); // populated for the admin cross-owner list; the user's own list ignores it
public sealed record AanvraagDetailDto(
string Id, string Type, AanvraagStatusDto Status,
@@ -118,7 +118,7 @@ public sealed record DraftSyncRequest(
IReadOnlyList<string>? DocumentIds = null);
// Submit carries only the fields the server re-validates per wizard type.
// AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by
// AanvullendeScholing/ScholingPunten — intake-typed aanvragen only (gated by
// IntakePolicy.RejectIncompleteScholing's caller), null for the others.
public sealed record AanvraagIndienenRequest(
string? DiplomaHerkomst = null, int? Uren = null,
@@ -127,7 +127,7 @@ public sealed record AanvraagIndienenRequest(
public sealed record AanvraagIndienenResponse(string Referentie, AanvraagStatusDto Status);
// --- Beoordeling (WP-65): the behandelportal's case-detail screen. ---
// --- Beoordeling: the behandelportal's case-detail screen. ---
public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId, string FileName);
@@ -141,7 +141,7 @@ public sealed record BeoordelingViewDto(
IReadOnlyList<BeoordelingDocumentDto> Documenten,
BeoordelingDecisionsDto Decisions);
/// Recording a decision (WP-65b). `Besluit` is the enum member name as a string — same
/// Recording a decision. `Besluit` is the enum member name as a string — same
/// wire convention as `AanvraagStatusDto.Tag` (this backend never ships a raw C# enum,
/// it round-trips names via Enum.Parse/.ToString() at the Contracts boundary, no
/// JsonStringEnumConverter configured). The endpoint 400s an unknown name. Toelichting
@@ -199,7 +199,7 @@ public sealed record BriefDto(
// BIG-nummer the case screen ships masked. Status-independent, unlike the action gates.
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend, bool CanRevealBigNummer);
// The brief's screen DTO also carries the org template it renders with (WP-23):
// The brief's screen DTO also carries the org template it renders with:
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at
// send time (sent letters are immutable; a republish never re-renders them).
// The case this letter is about — the zorgverlener + aanvraag the behandelaar is
@@ -220,7 +220,7 @@ public sealed record RevealBigNummerResponse(string BigNummer);
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
public sealed record MeDto(IReadOnlyList<string> Capabilities);
// --- Organization templates (WP-23, Brief v2 PRD §3) ---
// --- Organization templates (Brief v2 PRD §3) ---
// The second template axis: appearance/identity per sub-organization (letterhead,
// footer, signature, margins). Orthogonal to the case-type template (sections +
// placeholders); the two only meet at render time.
@@ -43,15 +43,15 @@ public static class Mappers
/// is the one place a status has no <see cref="AanvraagStatusTag"/>, so it becomes the wire
/// convention's magic string here at the boundary rather than living inside the domain type.
/// Shared by <see cref="ToStatusDto"/> and <c>ZgwZaakMapper</c>, so both status producers
/// agree on the projection (WP-68 F3).</summary>
/// agree on the projection.</summary>
public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
// Aanvraag status is COMPUTED ON READ (see the StatusAt extension, Data/AanvraagMapper.cs) —
// this is now a one-line projection of that onto the wire DTO (WP-68 F3, WP-73).
// this is now a one-line projection of that onto the wire DTO.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
/// <summary>SubmittedAt only exists once Submitted/Decided (WP-73) — null for a Concept,
/// <summary>SubmittedAt only exists once Submitted/Decided — null for a Concept,
/// same as the wire DTO's own nullable field.</summary>
private static string? SubmittedAtOf(Aanvraag a) => a switch
{
@@ -61,7 +61,7 @@ public static class Mappers
_ => null,
};
/// <summary>Draft only exists pre-submission (WP-73) — null once Submitted/Decided (nothing
/// <summary>Draft only exists pre-submission — null once Submitted/Decided (nothing
/// reads it past that point; see <c>AanvraagMapper.ApplyTo</c>'s Submitted branch).</summary>
private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null;
@@ -69,10 +69,10 @@ public static class Mappers
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
/// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null).
/// Admin summary — same shape plus the owner (the user-facing list leaves Owner null).
/// The owner is a BSN, and both consumers of this mapper are cross-owner lists read by
/// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked
/// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner
/// (BIO-003). Masking here rather than at each endpoint means a third cross-owner
/// list cannot be added that forgets to.
public static AanvraagSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) };
@@ -5,7 +5,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// The two-way seam between <see cref="AanvraagEntity"/> (the EF-mapped persistence row —
/// mutable, no invariants of its own, exactly the shape SQLite needs) and <see cref="Aanvraag"/>
/// (the closed Concept/Submitted/Decided domain union, WP-73). <see cref="ToDomain"/> is the
/// (the closed Concept/Submitted/Decided domain union). <see cref="ToDomain"/> is the
/// read half: it reconstructs whichever variant a row's stored fields describe, going through
/// that variant's own constructor/required members, so a row that doesn't actually describe a
/// legal aanvraag throws here rather than downstream. <see cref="ApplyTo"/>/<see cref="ToEntity"/>
@@ -36,7 +36,7 @@ public static class AanvraagMapper
var submittedAt = row.SubmittedAt
?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no SubmittedAt.");
// Reden wins over BesluitStatus — matches the pre-WP-73 StatusAt's own priority. In
// Reden wins over BesluitStatus — matches StatusAt's own established priority. In
// practice a row never carries both (BeoordelingRules.CanDecide already refuses a besluit
// once Reden's auto-reject makes the projected status Afgewezen), but if it somehow did,
// the auto-reject at submission time is authoritative.
@@ -133,7 +133,7 @@ public static class AanvraagMapper
break;
case Aanvraag.Submitted s:
// Submitted ⇒ !Draft (WP-73's Draft decision) — nothing reads a submitted aanvraag's
// Submitted ⇒ !Draft (the Draft-clearing decision) — nothing reads a submitted aanvraag's
// draft (registratie/application/draft-sync.ts only ever resumes a still-Concept
// wizard), so this is now actually true rather than the aspirational doc-comment it
// used to be.
@@ -186,11 +186,11 @@ public static class AanvraagMapper
return row;
}
/// <summary>The status at a point in time (WP-68 F3, WP-73) — pattern matching over the
/// <summary>The status at a point in time — pattern matching over the
/// closed <see cref="Aanvraag"/> union, replacing the null-forgiving derefs the old flat
/// mutable row needed (Referentie/SubmittedAt are simply non-nullable on Submitted/Decided
/// now, so there's nothing left to force). A recorded decision wins over the auto-approve
/// computation, matching the pre-WP-73 priority.</summary>
/// computation, matching the established priority.</summary>
public static AanvraagStatus StatusAt(this Aanvraag a, DateTimeOffset now) => a switch
{
Aanvraag.Concept c => AanvraagStatus.Concept(c.StepIndex, c.StepCount),
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// EF Core/SQLite persistence for the three stores that used to be static
/// in-memory dictionaries (WP-22): <see cref="AanvraagEntity"/>, <see cref="StoredDocument"/>
/// in-memory dictionaries: <see cref="AanvraagEntity"/>, <see cref="StoredDocument"/>
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes
/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
/// JSON text columns rather than redesigned into relational tables — the backend
@@ -6,7 +6,7 @@ using BigRegister.Domain.Submissions;
namespace BigRegister.Api.Data;
/// <summary>
/// The EF-mapped persistence row for an application (aanvraag) — WP-73 demoted this to
/// The EF-mapped persistence row for an application (aanvraag) — demoted to
/// exactly that: a flat, mutable bag with no invariants of its own (SQLite needs precisely
/// this shape), never read or written directly outside this file. Everywhere else, production
/// code reads and writes <see cref="Aanvraag"/> (the closed Concept/Submitted/Decided domain
@@ -32,21 +32,21 @@ public sealed class AanvraagEntity
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
/// <summary>The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under
/// the local source. Persisted so later steps (WP-51's document→zaak link) can find it
/// <summary>The OpenZaak zaak's URL, set once CreateZaak registers one — null under
/// the local source. Persisted so later steps (the document→zaak link) can find it
/// without a network round-trip; IZaakSource.CreateZaak itself doesn't write here (the
/// endpoint does, via <see cref="ApplicationStore.SetZaakUrl"/>) to keep the seam's write
/// surface at "return data", not "reach into another store".</summary>
public string? ZaakUrl { get; set; }
/// <summary>WP-60: non-null means the ZGW side of this submit (or its document link) did not
/// <summary>Non-null means the ZGW side of this submit (or its document link) did not
/// complete — the local aanvraag is authoritative and is NOT rolled back (that risks an
/// orphan zaak if the failure landed after the zaak POST succeeded). The zaak, if it exists,
/// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path;
/// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary>
public string? ZgwError { get; set; }
/// <summary>WP-65b: a behandelaar's recorded decision, if any. Non-null wins over the
/// <summary>A behandelaar's recorded decision, if any. Non-null wins over the
/// auto-approve computation in <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/> —
/// "a recorded decision wins". Mutable across <see cref="AanvraagStatusTag.MeerInfoGevraagd"/>
/// (a behandelaar may decide again later); frozen once Goedgekeurd/Afgewezen (terminal, per
@@ -59,7 +59,7 @@ public sealed class AanvraagEntity
}
/// <summary>
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
/// EF Core/SQLite-backed application store (was a static Dictionary),
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
/// tolerates only one writer at a time anyway, and this was already a single
/// coarse gate before the DB existed.
@@ -72,7 +72,7 @@ public static class ApplicationStore
private static readonly object _gate = new();
/// Create a Concept for <paramref name="owner"/> — UNLESS one of this
/// <paramref name="type"/> already exists unsubmitted. WP-35: at most one Concept per
/// <paramref name="type"/> already exists unsubmitted. At most one Concept per
/// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort;
/// this stays procedural here — it's an AGGREGATE-SET rule over every (Owner, Type), not
/// something a single Aanvraag value's own shape could ever encode, and there is no unique
@@ -121,7 +121,7 @@ public static class ApplicationStore
}
}
/// Cross-owner single read (WP-65b) — the behandelaar decision endpoint's counterpart of
/// Cross-owner single read — the behandelaar decision endpoint's counterpart of
/// <see cref="Get"/>, same "any owner" shape as <see cref="DeleteAny"/>.
public static Aanvraag? GetAny(string id)
{
@@ -132,7 +132,7 @@ public static class ApplicationStore
}
}
/// Cross-owner lookup by Referentie — real bug fix (WP-66): the behandelaar besluit
/// Cross-owner lookup by Referentie — a real bug fix: the behandelaar besluit
/// endpoint receives the FE-facing case id from <c>IZaakSource.ListCases</c>, which under
/// <c>OpenZaakZaakSource</c> is the ZGW zaak's own uuid, NOT this store's primary key (only
/// <c>LocalZaakSource</c>'s id happens to already be the Aanvraag.Id — every besluit 404'd
@@ -147,7 +147,7 @@ public static class ApplicationStore
}
}
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
/// Admin: every case across all owners. The per-owner List is the norm; this
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
public static IReadOnlyList<Aanvraag> ListAll()
{
@@ -163,7 +163,7 @@ public static class ApplicationStore
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable — the
/// domain reconstruction below is what enforces "0 &lt;= StepIndex &lt;= StepCount"
/// (<see cref="Aanvraag.Concept"/>'s own constructor throws on an out-of-range pair instead
/// of this silently writing one onto the row, the way the pre-WP-73 code did).
/// of this silently writing one onto the row, the way the earlier code did).
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{
lock (_gate)
@@ -207,7 +207,7 @@ public static class ApplicationStore
return true;
}
/// Admin: delete ANY case regardless of owner or submitted state (WP-36). The
/// Admin: delete ANY case regardless of owner or submitted state. The
/// user-facing Delete refuses a submitted aanvraag and is owner-scoped; an admin
/// managing the register may remove any case. Cascades to the case's documents
/// using its own owner. Returns false only when the id doesn't exist.
@@ -231,7 +231,7 @@ public static class ApplicationStore
/// 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). WP-73: the returned
/// if the aanvraag is gone or already submitted (idempotency guard). The returned
/// <see cref="Aanvraag.Submitted"/> is constructed with a non-null Referentie/SubmittedAt by
/// its own required members — there is no longer a null-forgiving deref anywhere down the
/// line reading them back (<c>StatusAt</c>, <c>IZaakSource.CreateZaak</c>). Submitting also
@@ -267,7 +267,7 @@ public static class ApplicationStore
}
}
/// <summary>Persist the zaak URL CreateZaak (WP-50) registered for this aanvraag. No-op if
/// <summary>Persist the zaak URL CreateZaak registered for this aanvraag. No-op if
/// the aanvraag is gone (shouldn't happen — this runs right after Submit found it).</summary>
public static void SetZaakUrl(string id, string zaakUrl)
{
@@ -297,13 +297,13 @@ public static class ApplicationStore
public enum RecordBesluitOutcome { Ok, NotFound, Conflict }
/// <summary>Record a behandelaar's decision (WP-65b) — cross-owner like
/// <summary>Record a behandelaar's decision — cross-owner like
/// <see cref="DeleteAny"/>, since a behandelaar decides on any citizen's case.
/// WP-68 (F2): the transition-legality check (<see cref="BeoordelingRules.CanDecide"/>)
/// The transition-legality check (<see cref="BeoordelingRules.CanDecide"/>)
/// now runs INSIDE this lock, against a status read fresh under the lock, rather than in
/// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's
/// check before either wrote, letting the second silently overwrite a terminal decision.
/// WP-73: <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
/// <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
/// require a non-null Toelichting by their own shape — the endpoint already 400s a missing
/// one (<c>BeoordelingRules.RequiresToelichting</c>), and this is the defense-in-depth
/// backstop for any other caller (this method is public, and e.g.
@@ -1,7 +1,7 @@
namespace BigRegister.Api.Data;
/// <summary>
/// A persisted, DATA-MINIMISED authorization/PII-reveal audit entry (WP-41, PRD-0002 §8):
/// A persisted, DATA-MINIMISED authorization/PII-reveal audit entry (PRD-0002 §8):
/// who (acting role, not identity), what action, on which resource ref, allow or deny, and
/// the correlation id — **never** a name, BSN, or the value that was (or wasn't) revealed.
/// Id is EF Core's auto-increment key (not positional), mirroring <see cref="AuditEntry"/>.
@@ -37,7 +37,7 @@ public static class AuthzAuditStore
}
}
/// Newest first. Ordered client-side: SQLite can't ORDER BY a DateTimeOffset (WP-36).
/// Newest first. Ordered client-side: SQLite can't ORDER BY a DateTimeOffset.
public static IReadOnlyList<AuthzAuditEntry> List()
{
lock (_gate)
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// The letter (brief) — one demo brief per owner, created from a template on first
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
/// read. EF Core/SQLite-backed (was a static Dictionary), mirrors
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not
@@ -23,11 +23,11 @@ public sealed class BriefEntity
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft");
/// Which sub-organization's org template themes this letter (WP-23).
/// Which sub-organization's org template themes this letter.
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
public int? SentOrgTemplateVersion { get; set; }
/// The composed HTML archived at send (WP-25) — from here on the preview endpoint
/// The composed HTML archived at send — from here on the preview endpoint
/// serves this verbatim, so a later org-template republish never re-renders it.
public string? ArchivedHtml { get; set; }
@@ -47,7 +47,7 @@ public static class BriefStore
private static readonly object _gate = new();
/// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null —
/// Pure query (CQ-007): no write. `GET /brief` 404s when this returns null —
/// the owner's first-ever draft is created only through the explicit `ResetAndCreate`
/// command (`POST /brief/reset`), never as a side effect of a read.
public static BriefEntity? Get(string owner)
@@ -110,10 +110,10 @@ public static class BriefStore
var outcome = BriefRules.CanSend(e.Status);
if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on
// Pin the org-template version the letter was sent with: from here on
// its appearance is frozen — republishing the template touches unsent briefs only.
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint
// Archive the composed HTML at this exact instant: the preview endpoint
// serves this verbatim once sent, so a later republish never re-renders it.
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
+1 -1
View File
@@ -6,7 +6,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// Factory for short-lived <see cref="AppDbContext"/> instances. The three stores
/// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape
/// predates WP-22 and this WP keeps it — so they can't take a constructor-injected
/// predates this persistence layer, which keeps that shape — so they can't take a constructor-injected
/// DbContext; each store method opens one here, uses it, and disposes it under its
/// own lock instead.
/// </summary>
@@ -4,7 +4,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a
/// purely so a re-opened wizard can preview/download what was uploaded — a
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
/// serialized into a JSON response; only the dedicated content endpoint streams them.
/// </summary>
@@ -14,7 +14,7 @@ public sealed record StoredDocument(
{
public bool Linked { get; set; }
/// <summary>The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload (WP-51)
/// <summary>The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload
/// registers one — null under the local source. Persisted so the later zaak-link step can
/// find it without re-uploading; not part of the positional constructor, same reasoning as
/// <see cref="Linked"/> (every existing `new StoredDocument(...)` call site keeps working).</summary>
@@ -30,7 +30,7 @@ public sealed record AuditEntry(DateTimeOffset At, string Action, string Documen
}
/// <summary>
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
/// EF Core/SQLite-backed document store + audit log (was a static
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
/// one writer at a time anyway, and this process already serialized all access
/// through a single gate, so it now doubles as a coarse single-writer guard for
@@ -85,7 +85,7 @@ public static class DocumentStore
}
}
/// <summary>Documents by DocumentId (WP-65's beoordeling detail reads an aanvraag's already-
/// <summary>Documents by DocumentId (the beoordeling detail reads an aanvraag's already-
/// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is
/// keyed by the wizard's own LocalId instead.</summary>
public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds)
@@ -114,7 +114,7 @@ public static class DocumentStore
}
}
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
/// <summary>Persist the DRC url an OpenZaak upload registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl)
{
lock (_gate)
@@ -181,7 +181,7 @@ public static class DocumentStore
}
/// <summary>Append one metadata-only audit row. <paramref name="actor"/> must arrive
/// **already redacted** (RB-04/BIO-005) — the two citizen call sites pass
/// **already redacted** (BIO-005) — the two citizen call sites pass
/// <see cref="Pii.MaskTail"/> of the owner BSN, `delete-admin` passes the literal
/// `"admin"`. The unmasked BSN lives only in <see cref="StoredDocument.Owner"/>, which is
/// the authorization key and stays untouched. Masking here instead would have to guess
@@ -14,7 +14,7 @@ public sealed class FeatureFlagEntity
public sealed record ResolvedFlag(string Key, string Description, bool Enabled);
/// <summary>
/// Runtime feature-flag state (WP-47). SQLite-backed like <see cref="OrgTemplateStore"/>, same
/// Runtime feature-flag state. SQLite-backed like <see cref="OrgTemplateStore"/>, same
/// single-gate idiom. The CATALOG (which flags exist + their defaults) is code
/// (<see cref="FeatureFlags"/>); this store only holds the admin's on/off overrides. An unknown
/// key is never writable/enabled — the code catalog is the authority.
@@ -4,10 +4,10 @@ using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
/// <summary>
/// The documents seam (WP-51), sibling of <see cref="IZaakSource"/>: uploads always land
/// The documents seam, sibling of <see cref="IZaakSource"/>: uploads always land
/// locally first (<see cref="DocumentStore"/> stays the record of truth for preview/download/
/// audit regardless of config, exactly like <c>ApplicationStore.Submit</c> for aanvragen,
/// WP-50) — this interface is only the OpenZaak integration side-effect, selected the same way
/// audit regardless of config, exactly like <c>ApplicationStore.Submit</c> for aanvragen)
/// — this interface is only the OpenZaak integration side-effect, selected the same way
/// (<c>Zgw:Enabled</c>). Default binding is <see cref="LocalDocumentSource"/> (offline);
/// <c>OpenZaakDocumentSource</c> also registers each upload as a DRC
/// enkelvoudiginformatieobject and links it to a zaak once one exists.
@@ -16,17 +16,17 @@ public interface IDocumentSource
{
/// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the
/// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.
/// <paramref name="caller"/> (WP-53) is both the document's owner (<c>DocumentStore</c>'s
/// <paramref name="caller"/> is both the document's owner (<c>DocumentStore</c>'s
/// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary>
UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, ZorgverlenerCaller caller);
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag:
/// local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
/// source additionally links each document (that has a DRC url) to the zaak, once
/// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in
/// which case there is nothing extra to link) — minted with <paramref name="caller"/>'s
/// identity (WP-53).</summary>
/// identity.</summary>
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller);
}
+14 -14
View File
@@ -5,25 +5,25 @@ using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
/// <summary>
/// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an <see cref="Aanvraag"/>
/// The cases (zaken) READ seam. A "zaak" in ZGW terms is an <see cref="Aanvraag"/>
/// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak)
/// replace the local SQLite store <em>behind the same <see cref="AanvraagSummaryDto"/>
/// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001).
///
/// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c>
/// swaps in <c>OpenZaakZaakSource</c>. Slice 1 (WP-49) was read-only; <see cref="CreateZaak"/>
/// (WP-50) is the first write. The interface returns the wire DTO (not the domain
/// swaps in <c>OpenZaakZaakSource</c>. The first slice was read-only; <see cref="CreateZaak"/>
/// is the first write. The interface returns the wire DTO (not the domain
/// <see cref="Aanvraag"/>) precisely so each source owns its own mapping — the OpenZaak
/// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag.
/// </summary>
public interface IZaakSource
{
/// <summary>Every case across every owner, newest-first (the admin cross-owner list,
/// WP-36) — cases:manage only, deliberately NOT citizen-scoped.</summary>
/// <summary>Every case across every owner, newest-first (the admin cross-owner list) —
/// cases:manage only, deliberately NOT citizen-scoped.</summary>
IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now);
/// <summary>
/// Only <paramref name="caller"/>'s own cases (WP-53) — the citizen-scoped counterpart of
/// Only <paramref name="caller"/>'s own cases — the citizen-scoped counterpart of
/// <see cref="ListCases"/>, backing the citizen's own dashboard. The local source filters
/// <c>ApplicationStore</c> by owner (unchanged behaviour); the OpenZaak source adds ZGW's
/// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
@@ -32,9 +32,9 @@ public interface IZaakSource
IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
/// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak. The aanvraag is
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran, hence the
/// <see cref="Aanvraag.Submitted"/> parameter type — WP-73: a freshly submitted aanvraag
/// <see cref="Aanvraag.Submitted"/> parameter type — a freshly submitted aanvraag
/// always has a Referentie, so neither implementation needs a null-forgiving deref for it
/// any more) — this is the integration side-effect, and (Referentie, Status) is what the
/// submit endpoint hands back to the FE (ADR-0001: route the create through the existing
@@ -42,19 +42,19 @@ public interface IZaakSource
/// the already-computed local reference/status (ZaakUrl null — nothing to persist); the
/// OpenZaak source creates a Zaak (+ status + rol) and maps the result back into the same
/// shape, returning the zaak's URL so the endpoint can persist it
/// (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it to later link documents to this
/// zaak). <paramref name="caller"/> (WP-53) is the acting citizen — the ZGW JWT's audit
/// (<see cref="ApplicationStore.SetZaakUrl"/>; linking documents to this
/// zaak later needs it). <paramref name="caller"/> is the acting citizen — the ZGW JWT's audit
/// claims reflect them, not a static config identity.
/// </summary>
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller);
/// <summary>
/// Extend a behandelaar's already-locally-recorded decision (WP-65b's
/// <c>ApplicationStore.RecordBesluit</c> already ran) with a ZGW-side status transition
/// (WP-66) — the write counterpart to <see cref="CreateZaak"/>'s initial status. The local
/// Extend a behandelaar's already-locally-recorded decision (
/// <c>ApplicationStore.RecordBesluit</c> already ran) with a ZGW-side status transition
/// the write counterpart to <see cref="CreateZaak"/>'s initial status. The local
/// source is a no-op (the decision IS the record of truth there, unchanged from before this
/// seam existed); the OpenZaak source POSTs a new Statussen entry to
/// <paramref name="aanvraag"/>'s zaak. <paramref name="caller"/> (WP-53/62) is the acting
/// <paramref name="aanvraag"/>'s zaak. <paramref name="caller"/> is the acting
/// medewerker.
/// </summary>
void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller);
@@ -5,7 +5,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// The default <see cref="IDocumentSource"/> — uploads go only to the local SQLite
/// <see cref="DocumentStore"/>, exactly as before this seam existed (WP-51). Zero behaviour
/// <see cref="DocumentStore"/>, exactly as before this seam existed. Zero behaviour
/// change: this is the same <c>DocumentStore.Add</c>/<c>DocumentStore.Link</c> the upload/
/// submit endpoints used to call inline.
/// </summary>
@@ -6,7 +6,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// The default <see cref="IZaakSource"/> — the cases come from the local SQLite
/// <see cref="ApplicationStore"/>, exactly as before the seam existed (WP-49). Zero
/// <see cref="ApplicationStore"/>, exactly as before the seam existed. Zero
/// behaviour change: this is the same <c>ListAll().ToAdminSummaryDto(now)</c> the
/// <c>/admin/cases</c> endpoint used to call inline.
/// </summary>
@@ -15,7 +15,7 @@ public sealed class LocalZaakSource : IZaakSource
public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
/// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /aanvragen</c> used to compute
/// <summary>Citizen-scoped — exactly what <c>GET /aanvragen</c> used to compute
/// inline before it was routed through this seam.</summary>
public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
ApplicationStore.List(caller.Bsn)
@@ -23,11 +23,11 @@ public sealed class LocalZaakSource : IZaakSource
.Select(a => a.ToSummaryDto(now)).ToList();
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
/// of truth, exactly as before this seam existed. Zero behaviour change.</summary>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie, aanvraag.ToStatusDto(now), null);
/// <summary>No external zaak to update — the recorded decision already IS the record of
/// truth locally (WP-66). Zero behaviour change.</summary>
/// truth locally. Zero behaviour change.</summary>
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) { }
}
@@ -3,12 +3,12 @@ using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// Organization template per sub-organization (WP-23, Brief v2 PRD §3): one row per
/// Organization template per sub-organization (Brief v2 PRD §3): one row per
/// sub-org. `Draft` is the work-in-progress payload (Version 0), `History` the
/// append-only list of published snapshots, `PublishedVersion` points into it.
/// Rollback copies an old snapshot back into the draft — it never rewrites history.
/// Mirrors <see cref="BriefStore"/>: static class, short-lived context per call,
/// nested DTO shapes stored as JSON text columns (WP-22 posture).
/// nested DTO shapes stored as JSON text columns.
/// </summary>
public sealed class OrgTemplateEntity
{
@@ -3,7 +3,7 @@ using System.Text.Json;
namespace BigRegister.Domain.Applications;
/// <summary>
/// The aanvraag lifecycle as a closed union (WP-73): <see cref="Concept"/> (the pre-submission
/// The aanvraag lifecycle as a closed union: <see cref="Concept"/> (the pre-submission
/// wizard draft) → <see cref="Submitted"/> (awaiting a behandelaar's decision, or already
/// auto-rejected at submission time — see <see cref="Submitted.Reden"/>) → <see cref="Decided"/>
/// (a behandelaar's outcome recorded). Each variant carries only the fields that make sense for
@@ -31,11 +31,11 @@ public abstract record Aanvraag
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under
/// <summary>The OpenZaak zaak's URL, set once CreateZaak registers one — null under
/// the local source, or before a zaak has been registered at all.</summary>
public string? ZaakUrl { get; init; }
/// <summary>WP-60: non-null means the ZGW side of this aanvraag's last write did not
/// <summary>Non-null means the ZGW side of this aanvraag's last write did not
/// complete — see <c>Api.Data.ApplicationStore.SetZgwError</c>.</summary>
public string? ZgwError { get; init; }
@@ -76,7 +76,7 @@ public abstract record Aanvraag
public string? Reden { get; init; }
}
/// <summary>A behandelaar's decision (WP-65b/68) — closed by besluit: only
/// <summary>A behandelaar's decision — closed by besluit: only
/// <see cref="Afgewezen"/>/<see cref="MeerInfoGevraagd"/> require a toelichting
/// (<c>BeoordelingRules.RequiresToelichting</c>'s rule, now also a type, not just an endpoint
/// check) — omitting it is a compile error, not merely a 400 the type happens to also let
@@ -1,11 +1,11 @@
namespace BigRegister.Domain.Applications;
/// <summary>
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In
/// The post-submission aanvraag status lifecycle (ADR-0002): Ingediend → In
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
/// the wizard draft) is deliberately NOT a member here — see <see cref="AanvraagStatus.Tag"/>,
/// which is null exactly when the aanvraag hasn't been submitted yet, instead of a sixth
/// "magic string" tag with no enum member to match it (WP-68 F3).
/// "magic string" tag with no enum member to match it.
/// <see cref="Ingediend"/> is reserved: no endpoint sets it yet (there is no state between
/// "just submitted" and "in behandeling" in this POC) — kept because the FE's status union
/// and $localize catalogue already declare it, and removing it would ripple into both.
@@ -13,13 +13,13 @@ namespace BigRegister.Domain.Applications;
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
/// <summary>
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
/// A behandelaar's recorded decision — the three actions the beoordeling screen
/// offers, each advancing an aanvraag's <see cref="AanvraagStatus"/>.
/// </summary>
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
/// <summary>
/// The domain projection of an aanvraag's status at a point in time (WP-68 F3) — the type
/// The domain projection of an aanvraag's status at a point in time — the type
/// <c>Aanvraag.StatusAt(now)</c> returns, replacing the logic that used to live directly in
/// <c>Contracts.Mappers.ToStatusDto</c>. Constructible only via the factories below, so a
/// caller can never build e.g. a Referentie-less Goedgekeurd. <see cref="Tag"/> is null only
@@ -24,7 +24,7 @@ public enum BriefAction { Approve, Reject, Send }
/// </summary>
public static class Authz
{
// WP-53: role now comes from the per-request CallerIdentity the identity middleware
// Role now comes from the per-request CallerIdentity the identity middleware
// resolved (StubIdentityProvider reads the same X-Role header this used to read directly) —
// one source of "who", so a real IIdentityProvider swap carries this over unchanged.
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Caller().Role);
@@ -49,7 +49,7 @@ public static class Authz
/// BriefStore.Review enforces before its status guard; kept separate from
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
/// today's behavior exactly. The explicit Approver condition keeps the new Admin
/// role out of the review flow (WP-23) — SoD alone would have let it through.
/// role out of the review flow — SoD alone would have let it through.
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
{
BriefAction.Approve or BriefAction.Reject =>
@@ -58,7 +58,7 @@ public static class Authz
_ => false,
};
/// Org-template management (WP-23): admin-only, resource-independent — templates
/// Org-template management: admin-only, resource-independent — templates
/// have no per-resource state to weigh, so role IS the whole decision here.
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
@@ -67,18 +67,18 @@ public static class Authz
/// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here.
public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin;
/// Case management (WP-36): admin-only, resource-independent — same shape as
/// Case management: admin-only, resource-independent — same shape as
/// org-template / stamdata (role IS the decision). Gates the cross-owner /admin/cases
/// list + admin delete.
public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin;
/// Feature-flag management (WP-47): admin-only, resource-independent — role IS the decision.
/// Feature-flag management: admin-only, resource-independent — role IS the decision.
public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin;
// --- Medewerker (backoffice) capabilities (WP-62, ADR-0002 §3) ------------------------------
// --- Medewerker (backoffice) capabilities (ADR-0002 §3) ------------------------------
/// May this caller assess/decide an aanvraag (the behandelportal's werkvoorraad + beoordeling,
/// WP-64/65)? Rol-based, deliberately NOT derived from PrincipalRole — a zorgverlener is false
/// May this caller assess/decide an aanvraag (the behandelportal's werkvoorraad + beoordeling)?
/// Rol-based, deliberately NOT derived from PrincipalRole — a zorgverlener is false
/// regardless of X-Role, because the capability belongs to the medewerker actor kind, not to
/// the dev role stand-in. Shipped to a frontend only as a decision flag, never as a rollen
/// matrix (ADR-0001).

Some files were not shown because too many files have changed in this diff Show More