Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff5f96bb3 | ||
|
|
831940f1b9 | ||
|
|
a196a380ce | ||
|
|
8560746d15 | ||
|
|
dd11eafe50 | ||
|
|
3895588b9a | ||
|
|
fb2e58ab87 | ||
|
|
e221834f6e |
@@ -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
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
+2
-6
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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):
|
||||
#
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 <= StepIndex <= 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user