From 194cccfd02515a3aab1721dd98b7cfbe3ab9ffb2 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 14:33:16 +0200 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20rename=20Application=20?= =?UTF-8?q?=E2=86=92=20Aanvraag=20across=20the=20wire=20(Step=201/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire said Application, the domain said Aanvraag — one aggregate with two names at every hop. Rename the backend DTOs and the /applications route to /aanvragen, regenerate the typed client, and rename the frontend adapter/store to match. Renamed: ApplicationSummaryDto/DetailDto, CreateApplicationRequest, SubmitApplicationRequest/Response → Aanvraag* equivalents; ApplicationsAdapter/Store → AanvragenAdapter/Store; applications.adapter.ts/applications.store.ts → aanvragen.*. Left untouched: the admin Case/Zaak vocabulary (/admin/cases, AdminCasesStore) — a separate read model, not part of this rename; the internal BigRegister.Domain.Applications namespace and the Applications EF table (renaming those needs a new EF migration, out of scope here). Part of the dashboard-readability refactor (see the approved plan). Co-Authored-By: Claude Opus 5 --- .../infrastructure/werkvoorraad.adapter.ts | 8 +- ....store.spec.ts => aanvragen.store.spec.ts} | 18 +- ...plications.store.ts => aanvragen.store.ts} | 15 +- .../application/admin-cases.store.spec.ts | 6 +- .../application/admin-cases.store.ts | 11 +- .../application/draft-sync.spec.ts | 6 +- .../app/registratie/application/draft-sync.ts | 10 +- .../application/find-concept.spec.ts | 6 +- .../registratie/application/find-concept.ts | 14 +- ...pter.spec.ts => aanvragen.adapter.spec.ts} | 28 +-- ...ations.adapter.ts => aanvragen.adapter.ts} | 48 ++-- .../registratie/ui/aanvraag-detail.page.ts | 12 +- .../src/app/registratie/ui/dashboard.page.ts | 8 +- backend/src/BigRegister.Api/Contracts/Dtos.cs | 12 +- .../src/BigRegister.Api/Contracts/Mappers.cs | 6 +- .../src/BigRegister.Api/Data/DocumentStore.cs | 2 +- .../src/BigRegister.Api/Data/IZaakSource.cs | 6 +- .../BigRegister.Api/Data/LocalZaakSource.cs | 6 +- .../Domain/Features/FeatureFlags.cs | 2 +- .../Domain/Intake/IntakePolicy.cs | 2 +- backend/src/BigRegister.Api/Program.cs | 30 +-- .../BigRegister.Api/Zgw/OpenZaakZaakSource.cs | 12 +- .../src/BigRegister.Api/Zgw/ZgwZaakMapper.cs | 8 +- backend/swagger.json | 218 +++++++++--------- .../{ApplicationTests.cs => AanvraagTests.cs} | 70 +++--- .../Acceptance/IntakeSubmissionTests.cs | 6 +- .../BigRegister.Tests/AdminCasesTests.cs | 12 +- .../BeoordelingIdMismatchTests.cs | 14 +- .../BigRegister.Tests/BeoordelingTests.cs | 14 +- .../tests/BigRegister.Tests/EndpointTests.cs | 6 +- .../BigRegister.Tests/FeatureFlagTests.cs | 6 +- .../OpenZaakIntegrationTests.cs | 2 +- .../BigRegister.Tests/RouteInventoryTests.cs | 14 +- .../BigRegister.Tests/WerkvoorraadTests.cs | 18 +- .../BigRegister.Tests/ZgwDivergenceTests.cs | 14 +- libs/shared/src/infrastructure/api-client.ts | 142 ++++++------ 36 files changed, 400 insertions(+), 412 deletions(-) rename apps/ssp/src/app/registratie/application/{applications.store.spec.ts => aanvragen.store.spec.ts} (83%) rename apps/ssp/src/app/registratie/application/{applications.store.ts => aanvragen.store.ts} (86%) rename apps/ssp/src/app/registratie/infrastructure/{applications.adapter.spec.ts => aanvragen.adapter.spec.ts} (72%) rename apps/ssp/src/app/registratie/infrastructure/{applications.adapter.ts => aanvragen.adapter.ts} (77%) rename backend/tests/BigRegister.Tests/{ApplicationTests.cs => AanvraagTests.cs} (72%) diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts index c06f4a3..c48346b 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client'; +import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client'; import { WerkvoorraadItem, WerkvoorraadStatus, @@ -18,7 +18,7 @@ import { export class WerkvoorraadAdapter { private client = inject(ApiClient); - list(): Promise { + list(): Promise { return this.client.werkvoorraad(); } } @@ -26,7 +26,7 @@ export class WerkvoorraadAdapter { const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake']; function parseWerkvoorraadStatus( - s: ApplicationSummaryDto['status'] | undefined, + s: AanvraagSummaryDto['status'] | undefined, ): Result { if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status'); switch (s.tag) { @@ -44,7 +44,7 @@ function parseWerkvoorraadStatus( export function parseWerkvoorraadItem(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object'); - const dto = json as ApplicationSummaryDto; + const dto = json as AanvraagSummaryDto; if (typeof dto.id !== 'string') return err('werkvoorraad: missing id'); if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) return err(`werkvoorraad: bad type ${dto.type}`); diff --git a/apps/ssp/src/app/registratie/application/applications.store.spec.ts b/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts similarity index 83% rename from apps/ssp/src/app/registratie/application/applications.store.spec.ts rename to apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts index f8860af..8147917 100644 --- a/apps/ssp/src/app/registratie/application/applications.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts @@ -1,8 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi } from 'vitest'; import { SUBMIT_FAILED } from '@shared/application/submit'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; -import { ApplicationsStore } from './applications.store'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; +import { AanvragenStore } from './aanvragen.store'; const summary = (id: string) => ({ id, @@ -13,20 +13,20 @@ const summary = (id: string) => ({ updatedAt: '2026-07-23T10:00:00Z', }); -function setup(adapter: Partial): ApplicationsStore { +function setup(adapter: Partial): AanvragenStore { TestBed.configureTestingModule({ - providers: [{ provide: ApplicationsAdapter, useValue: adapter }], + providers: [{ provide: AanvragenAdapter, useValue: adapter }], }); // The store's own constructor kicks off `load()` (dashboard revisit refresh) — // give every test a `list` so that initial call has something to resolve. - return TestBed.inject(ApplicationsStore); + return TestBed.inject(AanvragenStore); } -describe('ApplicationsStore', () => { +describe('AanvragenStore', () => { it('loads and parses the list', async () => { const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) }); await store.load(); - const s = store.applications(); + const s = store.aanvragen(); expect(s.tag).toBe('Success'); expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']); }); @@ -41,7 +41,7 @@ describe('ApplicationsStore', () => { await store.cancel('a'); expect(cancel).toHaveBeenCalledWith('a'); - const s = store.applications(); + const s = store.aanvragen(); expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']); expect(store.lastError()).toBeNull(); }); @@ -55,7 +55,7 @@ describe('ApplicationsStore', () => { await store.load(); await store.cancel('a'); - const s = store.applications(); + const s = store.aanvragen(); expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears expect(store.lastError()).toBe(SUBMIT_FAILED); }); diff --git a/apps/ssp/src/app/registratie/application/applications.store.ts b/apps/ssp/src/app/registratie/application/aanvragen.store.ts similarity index 86% rename from apps/ssp/src/app/registratie/application/applications.store.ts rename to apps/ssp/src/app/registratie/application/aanvragen.store.ts index 5157dad..0095cc7 100644 --- a/apps/ssp/src/app/registratie/application/applications.store.ts +++ b/apps/ssp/src/app/registratie/application/aanvragen.store.ts @@ -2,15 +2,12 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter'; type Err = Error | undefined; /** - * The dashboard's view of the user's applications (aanvragen) — the backend is the + * The dashboard's view of the user's aanvragen — the backend is the * system of record (PRD 0001). One root singleton OWNS the list as a writable * RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes * the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on @@ -20,11 +17,11 @@ type Err = Error | undefined; * surfaces `lastError` on failure (RB-20). */ @Injectable({ providedIn: 'root' }) -export class ApplicationsStore { - private adapter = inject(ApplicationsAdapter); +export class AanvragenStore { + private adapter = inject(AanvragenAdapter); private state = signal>({ tag: 'Loading' }); - readonly applications = this.state.asReadonly(); + readonly aanvragen = this.state.asReadonly(); /** Set on a failed cancel (RB-20): the optimistic removal already rolled back by then, this is only the message for the alert the page renders above the list. */ @@ -40,7 +37,7 @@ export class ApplicationsStore { async load() { if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); try { - const parsed = parseApplications(await this.adapter.list()); + const parsed = parseAanvragen(await this.adapter.list()); this.state.set( parsed.ok ? { tag: 'Success', value: parsed.value } diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts index ffab1b2..851368d 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi } from 'vitest'; import { SUBMIT_FAILED } from '@shared/application/submit'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { AdminCasesStore } from './admin-cases.store'; const summary = (id: string) => ({ @@ -14,9 +14,9 @@ const summary = (id: string) => ({ owner: '19012345601', }); -function setup(adapter: Partial): AdminCasesStore { +function setup(adapter: Partial): AdminCasesStore { TestBed.configureTestingModule({ - providers: [{ provide: ApplicationsAdapter, useValue: adapter }], + providers: [{ provide: AanvragenAdapter, useValue: adapter }], }); return TestBed.inject(AdminCasesStore); } diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.ts index b4580b9..a3b0ad5 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.ts @@ -2,16 +2,13 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter'; type Err = Error | undefined; /** * Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office - * counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton + * 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 @@ -19,7 +16,7 @@ type Err = Error | undefined; */ @Injectable({ providedIn: 'root' }) export class AdminCasesStore { - private adapter = inject(ApplicationsAdapter); + private adapter = inject(AanvragenAdapter); private state = signal>({ tag: 'Loading' }); readonly cases = this.state.asReadonly(); @@ -34,7 +31,7 @@ export class AdminCasesStore { async load() { if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); try { - const parsed = parseApplications(await this.adapter.listAll()); + const parsed = parseAanvragen(await this.adapter.listAll()); this.state.set( parsed.ok ? { tag: 'Success', value: parsed.value } diff --git a/apps/ssp/src/app/registratie/application/draft-sync.spec.ts b/apps/ssp/src/app/registratie/application/draft-sync.spec.ts index 3b16cf5..c69ee82 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.spec.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.spec.ts @@ -2,14 +2,14 @@ import { ApplicationRef, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { createDraftSync, DraftSnapshot } from './draft-sync'; -function setup(adapter: Partial) { +function setup(adapter: Partial) { const navigate = vi.fn().mockResolvedValue(true); TestBed.configureTestingModule({ providers: [ - { provide: ApplicationsAdapter, useValue: adapter }, + { provide: AanvragenAdapter, useValue: adapter }, { provide: Router, useValue: { navigate } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } }, ], diff --git a/apps/ssp/src/app/registratie/application/draft-sync.ts b/apps/ssp/src/app/registratie/application/draft-sync.ts index eec4426..07b5b18 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.ts @@ -4,11 +4,11 @@ import { Result } from '@shared/kernel/fp'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { registerPendingSave } from '@shared/application/pending-saves'; import type { - SubmitApplicationRequest, - SubmitApplicationResponse, + AanvraagIndienenRequest, + AanvraagIndienenResponse, } from '@shared/infrastructure/api-client'; import { AanvraagType } from '@registratie/domain/aanvraag'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { findConcept, loadConcept } from './find-concept'; /** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ @@ -46,7 +46,7 @@ const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels lag * Inert without a Router (stories) or when `enabled()` is false — no network, no resume. */ export function createDraftSync(deps: DraftSyncDeps) { - const adapter = inject(ApplicationsAdapter); + const adapter = inject(AanvragenAdapter); const router = inject(Router, { optional: true }); const route = inject(ActivatedRoute, { optional: true }); const active = () => deps.enabled() && !!router && !!route; @@ -190,7 +190,7 @@ export function createDraftSync(deps: DraftSyncDeps) { /** Submit through the aanvraag lifecycle: ensure the Concept exists, then `POST /applications/{id}/submit` (server sets autoApprovable + transitions). Folded into a Result like the old submit-* commands. */ - submit(body: SubmitApplicationRequest): Promise> { + submit(body: AanvraagIndienenRequest): Promise> { return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED); }, diff --git a/apps/ssp/src/app/registratie/application/find-concept.spec.ts b/apps/ssp/src/app/registratie/application/find-concept.spec.ts index c617ad0..d29363a 100644 --- a/apps/ssp/src/app/registratie/application/find-concept.spec.ts +++ b/apps/ssp/src/app/registratie/application/find-concept.spec.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { findConcept, loadConcept } from './find-concept'; // Free functions taking the adapter as a parameter (no inject()) — a plain fake // object is enough, no Angular TestBed needed. -function fakeAdapter(overrides: Partial): ApplicationsAdapter { - return overrides as ApplicationsAdapter; +function fakeAdapter(overrides: Partial): AanvragenAdapter { + return overrides as AanvragenAdapter; } describe('findConcept', () => { diff --git a/apps/ssp/src/app/registratie/application/find-concept.ts b/apps/ssp/src/app/registratie/application/find-concept.ts index 6b0cf75..dfbe364 100644 --- a/apps/ssp/src/app/registratie/application/find-concept.ts +++ b/apps/ssp/src/app/registratie/application/find-concept.ts @@ -1,8 +1,5 @@ import { AanvraagType } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter'; /** * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs @@ -14,11 +11,11 @@ import { /** Find the user's existing Concept of a given type (at most one), if any. */ export async function findConcept( - adapter: ApplicationsAdapter, + adapter: AanvragenAdapter, type: AanvraagType, ): Promise { try { - const parsed = parseApplications(await adapter.list()); + const parsed = parseAanvragen(await adapter.list()); return parsed.ok ? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id : undefined; @@ -34,10 +31,7 @@ export async function findConcept( export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' }; /** Load a specific Concept by id and report whether it is still editable. */ -export async function loadConcept( - adapter: ApplicationsAdapter, - id: string, -): Promise { +export async function loadConcept(adapter: AanvragenAdapter, id: string): Promise { try { const dto = await adapter.detail(id); if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' }; diff --git a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.spec.ts b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.spec.ts similarity index 72% rename from apps/ssp/src/app/registratie/infrastructure/applications.adapter.spec.ts rename to apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.spec.ts index 29b7683..905daf0 100644 --- a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.spec.ts +++ b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.spec.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { parseAanvraagStatus, - parseApplicationSummary, - parseApplications, - parseApplicationDetail, -} from './applications.adapter'; + parseAanvraagSummary, + parseAanvragen, + parseAanvraagDetail, +} from './aanvragen.adapter'; const concept = { id: 'a1', @@ -42,29 +42,29 @@ describe('parseAanvraagStatus', () => { }); }); -describe('parseApplicationSummary', () => { +describe('parseAanvraagSummary', () => { it('maps a valid DTO to domain', () => { - const r = parseApplicationSummary(concept); + const r = parseAanvraagSummary(concept); expect(r.ok && r.value.type).toBe('registratie'); expect(r.ok && r.value.status.tag).toBe('Concept'); }); it('rejects a bad type and non-objects', () => { - expect(parseApplicationSummary({ ...concept, type: 'onbekend' }).ok).toBe(false); - expect(parseApplicationSummary(null).ok).toBe(false); - expect(parseApplicationSummary({ ...concept, id: 42 }).ok).toBe(false); + expect(parseAanvraagSummary({ ...concept, type: 'onbekend' }).ok).toBe(false); + expect(parseAanvraagSummary(null).ok).toBe(false); + expect(parseAanvraagSummary({ ...concept, id: 42 }).ok).toBe(false); }); }); -describe('parseApplications / parseApplicationDetail', () => { +describe('parseAanvragen / parseAanvraagDetail', () => { it('parses a list and fails fast on a bad element', () => { - expect(parseApplications([concept, concept]).ok).toBe(true); - expect(parseApplications([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false); - expect(parseApplications({}).ok).toBe(false); + expect(parseAanvragen([concept, concept]).ok).toBe(true); + expect(parseAanvragen([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false); + expect(parseAanvragen({}).ok).toBe(false); }); it('carries the opaque draft through detail', () => { - const r = parseApplicationDetail({ ...concept, draft: { beroep: 'arts' } }); + const r = parseAanvraagDetail({ ...concept, draft: { beroep: 'arts' } }); expect(r.ok && (r.value.draft as { beroep: string }).beroep).toBe('arts'); }); }); diff --git a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts similarity index 77% rename from apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts rename to apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts index 2e8d9d8..a0fa92f 100644 --- a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts @@ -3,11 +3,11 @@ import { Result, ok, err } from '@shared/kernel/fp'; import { ApiClient, AanvraagStatusDto, - ApplicationSummaryDto, - ApplicationDetailDto, + AanvraagSummaryDto, + AanvraagDetailDto, DraftSyncRequest, - SubmitApplicationRequest, - SubmitApplicationResponse, + AanvraagIndienenRequest, + AanvraagIndienenResponse, } from '@shared/infrastructure/api-client'; import { Aanvraag, @@ -19,21 +19,21 @@ import { /** * Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place * its HTTP lives (ADR-0001 anti-corruption boundary). The list is a resource; the - * mutations (create/sync/cancel/submit) are thin commands the ApplicationsStore + * mutations (create/sync/cancel/submit) are thin commands the AanvragenStore * orchestrates optimistically. The untrusted response is validated + mapped to * domain by the hand-written parse* boundary below. */ @Injectable({ providedIn: 'root' }) -export class ApplicationsAdapter { +export class AanvragenAdapter { private client = inject(ApiClient); - /** The dashboard's application list (raw DTOs; the store parses at the boundary). */ - list(): Promise { - return this.client.applicationsAll(); + /** The dashboard's aanvraag list (raw DTOs; the store parses at the boundary). */ + list(): Promise { + return this.client.aanvragenAll(); } /** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ - listAll(): Promise { + listAll(): Promise { return this.client.casesAll(); } @@ -42,26 +42,26 @@ export class ApplicationsAdapter { return this.client.cases(id); } - detail(id: string): Promise { - return this.client.applicationsGET(id); + detail(id: string): Promise { + return this.client.aanvragenGET(id); } /** Create a Concept for a wizard type; resolves to the new aanvraag id. */ create(type: AanvraagType): Promise { - return this.client.applicationsPOST({ type }).then((d) => d.id ?? ''); + return this.client.aanvragenPOST({ type }).then((d) => d.id ?? ''); } /** Draft sync per step (idempotent). Keep it debounced at the call site — it is chatty. */ syncDraft(id: string, body: DraftSyncRequest): Promise { - return this.client.applicationsPUT(id, body); + return this.client.aanvragenPUT(id, body); } /** Cancel a Concept (cascades to its unlinked documents server-side). */ cancel(id: string): Promise { - return this.client.applicationsDELETE(id); + return this.client.aanvragenDELETE(id); } - submit(id: string, body: SubmitApplicationRequest): Promise { + submit(id: string, body: AanvraagIndienenRequest): Promise { return this.client.submit(id, body); } } @@ -101,7 +101,7 @@ export function parseAanvraagStatus( } } -function parseCommon(dto: ApplicationSummaryDto): Result { +function parseCommon(dto: AanvraagSummaryDto): Result { if (typeof dto.id !== 'string') return err('aanvraag: missing id'); if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) return err(`aanvraag: bad type ${dto.type}`); @@ -121,25 +121,25 @@ function parseCommon(dto: ApplicationSummaryDto): Result { }); } -export function parseApplicationSummary(json: unknown): Result { +export function parseAanvraagSummary(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('aanvraag: not an object'); - return parseCommon(json as ApplicationSummaryDto); + return parseCommon(json as AanvraagSummaryDto); } -export function parseApplications(json: unknown): Result { +export function parseAanvragen(json: unknown): Result { if (!Array.isArray(json)) return err('aanvragen: not an array'); const out: Aanvraag[] = []; for (const item of json) { - const parsed = parseApplicationSummary(item); + const parsed = parseAanvraagSummary(item); if (!parsed.ok) return parsed; out.push(parsed.value); } return ok(out); } -export function parseApplicationDetail(json: unknown): Result { +export function parseAanvraagDetail(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('aanvraag: not an object'); - const base = parseCommon(json as ApplicationDetailDto); + const base = parseCommon(json as AanvraagDetailDto); if (!base.ok) return base; - return ok({ ...base.value, draft: (json as ApplicationDetailDto).draft ?? null }); + return ok({ ...base.value, draft: (json as AanvraagDetailDto).draft ?? null }); } diff --git a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts index 59c8efe..fdf7d00 100644 --- a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts +++ b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts @@ -6,7 +6,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; 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 { ApplicationsStore } from '@registratie/application/applications.store'; +import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { Aanvraag } from '@registratie/domain/aanvraag'; import { detailRows } from '@registratie/domain/aanvraag-view'; @@ -29,9 +29,9 @@ import { detailRows } from '@registratie/domain/aanvraag-view'; heading="Aanvraag" backLink="/dashboard" > - + - @if (applications(); as list) { + @if (aanvragen(); as list) { @let a = find(list); @if (a) { list.find((a) => a.id === this.id); protected rows = detailRows; /** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */ - protected readonly applications = computed(() => { - const rd = this.store.applications(); + protected readonly aanvragen = computed(() => { + const rd = this.store.aanvragen(); return rd.tag === 'Success' ? rd.value : undefined; }); } diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/registratie/ui/dashboard.page.ts index 86c5a35..21a3426 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard.page.ts @@ -18,7 +18,7 @@ import { RegistrationSummaryComponent } from '@registratie/ui/registration-summa import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; import { AanvraagBlockComponent } from '@registratie/ui/aanvraag-block/aanvraag-block.component'; import { BigProfileStore } from '@registratie/application/big-profile.store'; -import { ApplicationsStore } from '@registratie/application/applications.store'; +import { AanvragenStore } from '@registratie/application/aanvragen.store'; import { Registration } from '@registratie/domain/registration'; import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; import { submittedRow } from '@registratie/domain/aanvraag-view'; @@ -214,7 +214,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks'; }) export class DashboardPage { protected store = inject(BigProfileStore); - private apps = inject(ApplicationsStore); + private apps = inject(AanvragenStore); private access = inject(AccessStore); private flags = inject(FeatureFlagStore); private router = inject(Router); @@ -232,10 +232,10 @@ export class DashboardPage { this.apps.reload(); } - /** The user's applications, sorted Concept → In behandeling → resolved. Empty → + /** The user's aanvragen, sorted Concept → In behandeling → resolved. Empty → the"Mijn aanvragen" section is hidden (see template). */ protected aanvragen = computed(() => { - const rd = this.apps.applications(); + const rd = this.apps.aanvragen(); if (rd.tag !== 'Success') return []; const order: Record = { Concept: 0, diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 211b5c0..953e999 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -99,19 +99,19 @@ public sealed record AanvraagStatusDto( bool? Manual = null, string? Reden = null); -public sealed record ApplicationSummaryDto( +public sealed record AanvraagSummaryDto( string Id, string Type, AanvraagStatusDto Status, IReadOnlyList 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 -public sealed record ApplicationDetailDto( +public sealed record AanvraagDetailDto( string Id, string Type, AanvraagStatusDto Status, System.Text.Json.JsonElement? Draft, IReadOnlyList DocumentIds, string CreatedAt, string UpdatedAt, string? SubmittedAt); -public sealed record CreateApplicationRequest(string Type); +public sealed record CreateAanvraagRequest(string Type); public sealed record DraftSyncRequest( System.Text.Json.JsonElement Draft, int StepIndex, int StepCount, @@ -120,12 +120,12 @@ public sealed record DraftSyncRequest( // Submit carries only the fields the server re-validates per wizard type. // AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by // IntakePolicy.RejectIncompleteScholing's caller), null for the others. -public sealed record SubmitApplicationRequest( +public sealed record AanvraagIndienenRequest( string? DiplomaHerkomst = null, int? Uren = null, IReadOnlyList? Documents = null, bool? AanvullendeScholing = null, int? ScholingPunten = null); -public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status); +public sealed record AanvraagIndienenResponse(string Referentie, AanvraagStatusDto Status); // --- Beoordeling (WP-65): the behandelportal's case-detail screen. --- @@ -137,7 +137,7 @@ public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId public sealed record BeoordelingDecisionsDto(bool CanBesluiten); public sealed record BeoordelingViewDto( - ApplicationSummaryDto Aanvraag, + AanvraagSummaryDto Aanvraag, IReadOnlyList Documenten, BeoordelingDecisionsDto Decisions); diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index ae32125..ce020c9 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -65,7 +65,7 @@ public static class Mappers /// reads it past that point; see AanvraagMapper.ApplyTo's Submitted branch). private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null; - public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( + public static AanvraagSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); @@ -74,10 +74,10 @@ public static class Mappers /// 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 /// list cannot be added that forgets to. - public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => + public static AanvraagSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) }; - public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( + public static AanvraagDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds, a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); } diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 82054f1..96c6c0b 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -43,7 +43,7 @@ public static class DocumentStore /// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer, /// a different Dutch identifier scheme). Previously this constant reused that BigNummer value /// as a stand-in BSN, which is invalid Dutch-BSN shape: harmless against the local store, but - /// a real OpenZaak instance rejects it outright — GET /api/v1/applications 500s (`inpBsn` query + /// a real OpenZaak instance rejects it outright — GET /api/v1/aanvragen 500s (`inpBsn` query /// filter validation) and every submit's rol-creation POST fails (`inpBsn` max_length) once /// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture /// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen"). diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index 22da698..71403da 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -7,7 +7,7 @@ namespace BigRegister.Api.Data; /// /// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an /// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak) -/// replace the local SQLite store behind the same +/// replace the local SQLite store behind the same /// contract — so the frontend never changes (BFF-lite anti-corruption, ADR-0001). /// /// Default binding is (offline). Setting Zgw:Enabled=true @@ -20,7 +20,7 @@ public interface IZaakSource { /// Every case across every owner, newest-first (the admin cross-owner list, /// WP-36) — cases:manage only, deliberately NOT citizen-scoped. - IReadOnlyList ListCases(DateTimeOffset now); + IReadOnlyList ListCases(DateTimeOffset now); /// /// Only 's own cases (WP-53) — the citizen-scoped counterpart of @@ -29,7 +29,7 @@ public interface IZaakSource /// rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn query filter so a citizen /// can never see another citizen's zaken. /// - IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now); + IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now); /// /// Register a just-submitted as a zaak (WP-50). The aanvraag is diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index bce8453..13f59a0 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -12,12 +12,12 @@ namespace BigRegister.Api.Data; /// public sealed class LocalZaakSource : IZaakSource { - public IReadOnlyList ListCases(DateTimeOffset now) => + public IReadOnlyList ListCases(DateTimeOffset now) => ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); - /// Citizen-scoped (WP-53) — exactly what GET /applications used to compute + /// Citizen-scoped (WP-53) — exactly what GET /aanvragen used to compute /// inline before it was routed through this seam. - public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => ApplicationStore.List(caller.Bsn) .OrderByDescending(a => a.UpdatedAt) .Select(a => a.ToSummaryDto(now)).ToList(); diff --git a/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs b/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs index 4132c03..4a517a2 100644 --- a/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs +++ b/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs @@ -9,7 +9,7 @@ public sealed record FeatureFlagDef(string Key, string Description, bool Default public static class FeatureFlags { /// Whether self-service registration (inschrijving) is open. When off, the FE hides the - /// "Inschrijven" action and POST /applications for a `registratie` is refused (server-enforced). + /// "Inschrijven" action and POST /aanvragen for a `registratie` is refused (server-enforced). public const string InschrijvingOpen = "inschrijving-open"; public static readonly IReadOnlyList Catalog = new[] diff --git a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs index 3d1a37d..c058af9 100644 --- a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs +++ b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs @@ -6,7 +6,7 @@ namespace BigRegister.Domain.Intake; /// (GET /intake/policy) and applies it for instant UX feedback /// (intake.machine.ts's lageUren); is the /// backend re-validating it as the authority on submit (WP-69) — -/// POST /applications/{id}/submit (intake-typed aanvragen only) calls it before +/// POST /aanvragen/{id}/submit (intake-typed aanvragen only) calls it before /// writing anything, and a violation 400s (ProblemDetails), never silently accepts /// an incomplete answer. /// diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index f0f6ae8..b59f6ff 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -342,19 +342,19 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx // ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from // OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap // openzaak-integration.md's ACL caveat used to flag for this endpoint. -api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) => +api.MapGet("/aanvragen", (HttpContext ctx, IZaakSource zaken) => zaken.ListMyCases(ctx.Zorgverlener(), DateTimeOffset.UtcNow)); -api.MapGet("/applications/{id}", (string id, HttpContext ctx) => +api.MapGet("/aanvragen/{id}", (string id, HttpContext ctx) => ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) : Results.NotFound()) -.Produces() +.Produces() .Produces(StatusCodes.Status404NotFound); // --- writes --- -api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => +api.MapPost("/aanvragen", (CreateAanvraagRequest req, HttpContext ctx) => { // Feature flag (WP-47): self-service registration can be closed by an admin. if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen)) @@ -364,13 +364,13 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => return Results.Problem( detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", statusCode: StatusCodes.Status409Conflict); - return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); + return Results.Created($"/api/v1/aanvragen/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); }) -.Produces(StatusCodes.Status201Created) +.Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status409Conflict); // Draft sync per step — idempotent; keep it debounced on the client (it is chatty). -api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => +api.MapPut("/aanvragen/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => { var owner = ctx.Zorgverlener().Bsn; // A citizen may only reference their own uploads in a draft — reject before the sync @@ -388,7 +388,7 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext c // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot // be withdrawn (out of scope — no "intrekken"). -api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => +api.MapDelete("/aanvragen/{id}", (string id, HttpContext ctx) => { var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (a is null) return Results.NotFound(); @@ -403,7 +403,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => // Submit runs the server-owned rules, sets autoApprovable, and transitions the // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. -api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => +api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => { var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (existing is null) return Results.NotFound(); @@ -480,9 +480,9 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re } } - return Results.Ok(new SubmitApplicationResponse(referentie, status)); + return Results.Ok(new AanvraagIndienenResponse(referentie, status)); }) -.Produces() +.Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); @@ -494,7 +494,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) .Gate("CasesAdmin") -.Produces>() +.Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated @@ -510,7 +510,7 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => // --- writes --- // Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing -// DELETE /applications/{id}. A missing id is a 404. +// DELETE /aanvragen/{id}. A missing id is a 404. api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => { if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); @@ -531,14 +531,14 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") .ToList()))) .Gate("Beoordelen") -.Produces>() +.Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording // a decision is WP-65's second half). Reads through IZaakSource.ListCases (no new seam method: // adding one now would force an OpenZaak get-by-id + mapper, which is WP-66's surface) — O(n) // over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here -// same as an unknown id (only /applications/{id}, citizen-scoped, shows a Concept). +// same as an unknown id (only /aanvragen/{id}, citizen-scoped, shows a Concept). api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, $"aanvraag/{id}", () => { diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index 2780998..2e9b260 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -18,9 +18,9 @@ public sealed record ZgwPage( /// The backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50 /// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the /// internal aanvraag-type key via Zgw:ZaaktypeUrls (a local lookup — NOT OpenZaak's -/// human zaaktype label, which isn't a value 's +/// human zaaktype label, which isn't a value 's /// contract accepts; see ), and maps into -/// via . Creates a zaak + +/// via . Creates a zaak + /// status + rol for a just-submitted aanvraag. Selected only when Zgw:Enabled=true; /// the default stays . /// @@ -36,16 +36,16 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, // sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the // default and this blocking call shows up under load. - public IReadOnlyList ListCases(DateTimeOffset now) => + public IReadOnlyList ListCases(DateTimeOffset now) => ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult(); /// WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param /// (see ) — and minted with that citizen's identity, not the /// system-level one uses. - public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult(); - private async Task> ListCasesAsync(string? bsn, CallerIdentity? caller) + private async Task> ListCasesAsync(string? bsn, CallerIdentity? caller) { var url = $"{options.ZrcBaseUrl}/zaken"; if (bsn is not null) @@ -55,7 +55,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, } /// Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse): - /// ApplicationSummaryDto.Type's contract is the internal aanvraag-type key (e.g. + /// AanvraagSummaryDto.Type's contract is the internal aanvraag-type key (e.g. /// "herregistratie" — what /Mappers.ToSummaryDto send, /// and what the FE's AANVRAAG_TYPES trust boundary accepts), NOT OpenZaak's human /// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round diff --git a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs index e54d478..430ebb5 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs @@ -6,7 +6,7 @@ namespace BigRegister.Api.Zgw; /// /// The subset of a ZGW Zaak (Zaken API / ZRC) the read slice needs. The full resource has -/// dozens of fields; we bind only what maps to . Note the +/// dozens of fields; we bind only what maps to . Note the /// two ZGW traits that force an anti-corruption layer: is the resource's /// identity (not a bare id), and is a URL into another service /// (Catalogi/ZTC) that must be resolved to a human label. @@ -20,7 +20,7 @@ public sealed record ZgwZaak( [property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum); /// -/// Anti-corruption map: ZGW Zaak → the existing the FE +/// Anti-corruption map: ZGW Zaak → the existing the FE /// already renders (WP-49). This is where "URL as identity" and the cross-service zaaktype /// join get flattened away, so nothing downstream (the FE) sees ZGW shapes. /// @@ -29,7 +29,7 @@ public static class ZgwZaakMapper /// Last path segment of a ZGW resource URL — the uuid that identifies it. public static string Uuid(string url) => url.TrimEnd('/').Split('/').Last(); - public static ApplicationSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel) + public static AanvraagSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel) { // ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed // one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the @@ -41,7 +41,7 @@ public static class ZgwZaakMapper var created = Iso(z.Registratiedatum ?? z.Startdatum); var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum); - return new ApplicationSummaryDto( + return new AanvraagSummaryDto( Id: Uuid(z.Url), Type: zaaktypeLabel, Status: status, diff --git a/backend/swagger.json b/backend/swagger.json index e0ccac2..7145b22 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -425,7 +425,7 @@ } } }, - "/api/v1/applications": { + "/api/v1/aanvragen": { "get": { "tags": [ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" @@ -438,7 +438,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" } } } @@ -454,7 +454,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApplicationRequest" + "$ref": "#/components/schemas/CreateAanvraagRequest" } } }, @@ -466,7 +466,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApplicationDetailDto" + "$ref": "#/components/schemas/AanvraagDetailDto" } } } @@ -484,7 +484,7 @@ } } }, - "/api/v1/applications/{id}": { + "/api/v1/aanvragen/{id}": { "get": { "tags": [ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" @@ -505,7 +505,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApplicationDetailDto" + "$ref": "#/components/schemas/AanvraagDetailDto" } } } @@ -592,7 +592,7 @@ } } }, - "/api/v1/applications/{id}/submit": { + "/api/v1/aanvragen/{id}/submit": { "post": { "tags": [ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" @@ -611,7 +611,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubmitApplicationRequest" + "$ref": "#/components/schemas/AanvraagIndienenRequest" } } }, @@ -623,7 +623,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubmitApplicationResponse" + "$ref": "#/components/schemas/AanvraagIndienenResponse" } } } @@ -667,7 +667,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" } } } @@ -766,7 +766,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" } } } @@ -1484,57 +1484,7 @@ }, "additionalProperties": false }, - "AanvraagStatusDto": { - "type": "object", - "properties": { - "tag": { - "type": "string", - "nullable": true - }, - "stepIndex": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "stepCount": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "referentie": { - "type": "string", - "nullable": true - }, - "manual": { - "type": "boolean", - "nullable": true - }, - "reden": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "AdresDto": { - "type": "object", - "properties": { - "straat": { - "type": "string", - "nullable": true - }, - "postcode": { - "type": "string", - "nullable": true - }, - "woonplaats": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "ApplicationDetailDto": { + "AanvraagDetailDto": { "type": "object", "properties": { "id": { @@ -1573,7 +1523,83 @@ }, "additionalProperties": false }, - "ApplicationSummaryDto": { + "AanvraagIndienenRequest": { + "type": "object", + "properties": { + "diplomaHerkomst": { + "type": "string", + "nullable": true + }, + "uren": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentRefDto" + }, + "nullable": true + }, + "aanvullendeScholing": { + "type": "boolean", + "nullable": true + }, + "scholingPunten": { + "type": "integer", + "format": "int32", + "nullable": true + } + }, + "additionalProperties": false + }, + "AanvraagIndienenResponse": { + "type": "object", + "properties": { + "referentie": { + "type": "string", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/AanvraagStatusDto" + } + }, + "additionalProperties": false + }, + "AanvraagStatusDto": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "nullable": true + }, + "stepIndex": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "stepCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "referentie": { + "type": "string", + "nullable": true + }, + "manual": { + "type": "boolean", + "nullable": true + }, + "reden": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "AanvraagSummaryDto": { "type": "object", "properties": { "id": { @@ -1613,6 +1639,24 @@ }, "additionalProperties": false }, + "AdresDto": { + "type": "object", + "properties": { + "straat": { + "type": "string", + "nullable": true + }, + "postcode": { + "type": "string", + "nullable": true + }, + "woonplaats": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "AuthzAuditDto": { "type": "object", "properties": { @@ -1674,7 +1718,7 @@ "type": "object", "properties": { "aanvraag": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" }, "documenten": { "type": "array", @@ -1860,7 +1904,7 @@ }, "additionalProperties": false }, - "CreateApplicationRequest": { + "CreateAanvraagRequest": { "type": "object", "properties": { "type": { @@ -2678,50 +2722,6 @@ }, "additionalProperties": false }, - "SubmitApplicationRequest": { - "type": "object", - "properties": { - "diplomaHerkomst": { - "type": "string", - "nullable": true - }, - "uren": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DocumentRefDto" - }, - "nullable": true - }, - "aanvullendeScholing": { - "type": "boolean", - "nullable": true - }, - "scholingPunten": { - "type": "integer", - "format": "int32", - "nullable": true - } - }, - "additionalProperties": false - }, - "SubmitApplicationResponse": { - "type": "object", - "properties": { - "referentie": { - "type": "string", - "nullable": true - }, - "status": { - "$ref": "#/components/schemas/AanvraagStatusDto" - } - }, - "additionalProperties": false - }, "UploadCategoriesDto": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/AanvraagTests.cs similarity index 72% rename from backend/tests/BigRegister.Tests/ApplicationTests.cs rename to backend/tests/BigRegister.Tests/AanvraagTests.cs index 551a6ca..551abdc 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/AanvraagTests.cs @@ -8,27 +8,27 @@ using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; -public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture +public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); - private async Task Create(string type = "registratie") + private async Task Create(string type = "registratie") { // WP-35: one Concept per type is now server-enforced, and these tests share one DB // (IClassFixture). Clear any leftover Concept so each test starts from a clean slate. var existing = await List(); Assert.NotNull(existing); foreach (var s in existing.Where(x => x.Status.Tag == "Concept")) - await _client.DeleteAsync($"/api/v1/applications/{s.Id}"); - var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); + await _client.DeleteAsync($"/api/v1/aanvragen/{s.Id}"); + var res = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type }); Assert.Equal(HttpStatusCode.Created, res.StatusCode); - var created = await res.Content.ReadFromJsonAsync(); + var created = await res.Content.ReadFromJsonAsync(); Assert.NotNull(created); return created; } - private Task?> List() => - _client.GetFromJsonAsync>("/api/v1/applications"); + private Task?> List() => + _client.GetFromJsonAsync>("/api/v1/aanvragen"); // --- Lifecycle over HTTP --- @@ -36,7 +36,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Create_then_list_shows_a_concept_with_step_progress() { var a = await Create(); - await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}", new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 }); var list = await List(); @@ -51,10 +51,10 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Draft_sync_is_readable_back_from_detail() { var a = await Create(); - await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}", new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 }); - var detail = await _client.GetFromJsonAsync($"/api/v1/applications/{a.Id}"); + var detail = await _client.GetFromJsonAsync($"/api/v1/aanvragen/{a.Id}"); Assert.NotNull(detail); Assert.NotNull(detail.Draft); Assert.Equal("verpleegkundige", detail.Draft.Value.GetProperty("beroep").GetString()); @@ -64,9 +64,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submit_duo_registratie_is_in_behandeling_and_auto() { var a = await Create("registratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" }); res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.StartsWith("BIG-2026-", body.Referentie); Assert.Equal("InBehandeling", body.Status.Tag); Assert.False(body.Status.Manual); // auto-approvable → not a manual case @@ -76,9 +76,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submit_handmatig_registratie_succeeds_as_manual_case() { var a = await Create("registratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" }); + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "handmatig" }); res.EnsureSuccessStatusCode(); // no longer a 422 - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("InBehandeling", body.Status.Tag); Assert.True(body.Status.Manual); } @@ -87,9 +87,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submit_herregistratie_with_zero_uren_is_afgewezen() { var a = await Create("herregistratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 }); + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 0 }); res.EnsureSuccessStatusCode(); // the submission is accepted... - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected Assert.NotNull(body.Status.Reden); } @@ -98,8 +98,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submitting_twice_conflicts() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); - var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + var again = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" }); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); } @@ -109,7 +109,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Creating_a_second_concept_of_the_same_type_conflicts() { await Create("herregistratie"); - var dup = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); + var dup = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode); } @@ -117,7 +117,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task A_concept_of_a_different_type_is_allowed() { await Create("registratie"); - var other = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); + var other = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); Assert.Equal(HttpStatusCode.Created, other.StatusCode); } @@ -125,8 +125,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task A_new_concept_is_allowed_once_the_previous_one_is_submitted() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); - var next = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + var next = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); Assert.Equal(HttpStatusCode.Created, next.StatusCode); } @@ -134,38 +134,38 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Cancel_concept_removes_it() { var a = await Create(); - Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); } [Fact] public async Task Cancel_submitted_aanvraag_conflicts() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); - Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); } - // --- WP-53: citizen-scoping — GET /applications must never leak across identities. --- + // --- WP-53: citizen-scoping — GET /aanvragen must never leak across identities. --- [Fact] public async Task Applications_are_scoped_to_the_caller_bsn() { var mine = await Create("intake"); - var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/applications") + var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/aanvragen") { Content = JsonContent.Create(new { type = "intake" }), Headers = { { "X-Subject", "999888777" } }, }; var otherRes = await _client.SendAsync(createOther); Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode); - var other = (await otherRes.Content.ReadFromJsonAsync())!; + var other = (await otherRes.Content.ReadFromJsonAsync())!; try { - var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } }; - var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync>())!; + var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/aanvragen") { Headers = { { "X-Subject", "999888777" } } }; + var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync>())!; Assert.Contains(theirCases, c => c.Id == other.Id); Assert.DoesNotContain(theirCases, c => c.Id == mine.Id); @@ -175,9 +175,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture } finally { - var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/applications/{other.Id}") { Headers = { { "X-Subject", "999888777" } } }; + var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/aanvragen/{other.Id}") { Headers = { { "X-Subject", "999888777" } } }; await _client.SendAsync(deleteOther); - await _client.DeleteAsync($"/api/v1/applications/{mine.Id}"); + await _client.DeleteAsync($"/api/v1/aanvragen/{mine.Id}"); } } @@ -205,7 +205,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString()); var a = await Create("registratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } }); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); @@ -221,7 +221,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString()); var a = await Create("registratie"); - var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + var res = await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}", new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } }); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); } diff --git a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs index d70ee6c..bdb0bc7 100644 --- a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs +++ b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs @@ -9,7 +9,7 @@ namespace BigRegister.Tests.Acceptance; /// /// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over -/// POST /applications/{id}/submit (the wizard's real path — WP-72 deleted the legacy +/// POST /aanvragen/{id}/submit (the wizard's real path — WP-72 deleted the legacy /// POST /intakes endpoint this once also covered). Built through the type-state builder, mirroring rather /// than the full wizard/upload dance — the builder's default owner IS Submit(string id, object body) => - _client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", body); + _client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", body); [Fact] public async Task Below_threshold_without_an_answer_is_rejected_and_stays_a_concept() @@ -117,7 +117,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi // Then the submission is accepted and resolves to Afgewezen — not a 400. res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("Afgewezen", body.Status.Tag); } } diff --git a/backend/tests/BigRegister.Tests/AdminCasesTests.cs b/backend/tests/BigRegister.Tests/AdminCasesTests.cs index 475778a..afd1816 100644 --- a/backend/tests/BigRegister.Tests/AdminCasesTests.cs +++ b/backend/tests/BigRegister.Tests/AdminCasesTests.cs @@ -18,11 +18,11 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< return req; } - private async Task Create(string type) + private async Task Create(string type) { - var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); + var res = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type }); Assert.Equal(HttpStatusCode.Created, res.StatusCode); - return (await res.Content.ReadFromJsonAsync())!; + return (await res.Content.ReadFromJsonAsync())!; } [Fact] @@ -33,7 +33,7 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< { var list = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases")); list.EnsureSuccessStatusCode(); - var cases = (await list.Content.ReadFromJsonAsync>())!; + var cases = (await list.Content.ReadFromJsonAsync>())!; var mine = cases.Single(x => x.Id == a.Id); // RB-03/BIO-003: the owner is carried, but masked — it is a BSN, and this list is // read by someone who is not the subject. @@ -57,13 +57,13 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< public async Task Admin_can_delete_a_submitted_case() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })) + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })) .EnsureSuccessStatusCode(); // The user-facing DELETE refuses a submitted case (409); admin delete removes it. var del = await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}")); Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); } [Fact] diff --git a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs index 812c86a..378258c 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs @@ -17,12 +17,12 @@ namespace BigRegister.Tests; file sealed class IdMismatchZaakSource : IZaakSource { private readonly LocalZaakSource inner = new(); - private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" }; + private static AanvraagSummaryDto Rekey(AanvraagSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" }; - public IReadOnlyList ListCases(DateTimeOffset now) => + public IReadOnlyList ListCases(DateTimeOffset now) => inner.ListCases(now).Select(Rekey).ToList(); - public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => inner.ListMyCases(caller, now).Select(Rekey).ToList(); public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak( @@ -67,13 +67,13 @@ public class BeoordelingIdMismatchTests using var factory = Factory(); using var client = factory.CreateClient(); - var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); - var app = (await created.Content.ReadFromJsonAsync())!; - var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" }); + var created = await client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var app = (await created.Content.ReadFromJsonAsync())!; + var submit = await client.PostAsJsonAsync($"/api/v1/aanvragen/{app.Id}/submit", new { diplomaHerkomst = "handmatig" }); submit.EnsureSuccessStatusCode(); var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad")); - var items = (await werkvoorraad.Content.ReadFromJsonAsync>())!; + var items = (await werkvoorraad.Content.ReadFromJsonAsync>())!; var caseId = Assert.Single(items).Id; // Sanity: the id divergence this test exists for is real, not accidentally absent. Assert.NotEqual(app.Id, caseId); diff --git a/backend/tests/BigRegister.Tests/BeoordelingTests.cs b/backend/tests/BigRegister.Tests/BeoordelingTests.cs index f79ffbb..23334f9 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingTests.cs @@ -35,17 +35,17 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture /// A manual (never auto-approved) case with one linked document, so it stays /// InBehandeling/decidable regardless of test timing (the 8s auto-approval window /// would otherwise make a duo-registratie/herregistratie fixture flaky). - private async Task<(ApplicationDetailDto App, string DocumentId)> CreateManualCaseWithDocument() + private async Task<(AanvraagDetailDto App, string DocumentId)> CreateManualCaseWithDocument() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); - var a = (await created.Content.ReadFromJsonAsync())!; + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var a = (await created.Content.ReadFromJsonAsync())!; var localId = Guid.NewGuid().ToString(); var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf")); upload.EnsureSuccessStatusCode(); var doc = (await upload.Content.ReadFromJsonAsync())!; - var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new + var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "handmatig", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } }, @@ -88,8 +88,8 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture [Fact] public async Task Concept_and_unknown_id_are_not_found() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); - var concept = (await created.Content.ReadFromJsonAsync())!; + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var concept = (await created.Content.ReadFromJsonAsync())!; try { var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}")); @@ -100,7 +100,7 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture } finally { - await _client.DeleteAsync($"/api/v1/applications/{concept.Id}"); + await _client.DeleteAsync($"/api/v1/aanvragen/{concept.Id}"); } } diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index b8fb2d7..cd74ca3 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -202,9 +202,9 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture())!; - var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit", + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var aanvraag = (await created.Content.ReadFromJsonAsync())!; + var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{aanvraag.Id}/submit", new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } }); submit.EnsureSuccessStatusCode(); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); diff --git a/backend/tests/BigRegister.Tests/FeatureFlagTests.cs b/backend/tests/BigRegister.Tests/FeatureFlagTests.cs index 6502e78..af3b68b 100644 --- a/backend/tests/BigRegister.Tests/FeatureFlagTests.cs +++ b/backend/tests/BigRegister.Tests/FeatureFlagTests.cs @@ -51,16 +51,16 @@ public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture { try { - // Off → POST /applications for a registratie is refused. + // Off → POST /aanvragen for a registratie is refused. (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false }))) .EnsureSuccessStatusCode(); - var blocked = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + var blocked = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode); // On → allowed again. (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true }))) .EnsureSuccessStatusCode(); - var ok = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + var ok = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); Assert.Equal(HttpStatusCode.Created, ok.StatusCode); } finally diff --git a/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs b/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs index 84017e5..8989a29 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs @@ -63,7 +63,7 @@ public class OpenZaakIntegrationTests using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage) - var cases = await client.GetFromJsonAsync>("/api/v1/admin/cases"); + var cases = await client.GetFromJsonAsync>("/api/v1/admin/cases"); Assert.NotNull(cases); // bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123. diff --git a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs index a010096..f4cff09 100644 --- a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs +++ b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs @@ -17,7 +17,7 @@ namespace BigRegister.Tests; /// - it is named, with a reason, in below. /// /// The allow-list is deliberately not "public routes" — most of its entries are NOT public. -/// `GET /applications/{id}` requires a caller identity and is scoped to that caller's own BSN +/// `GET /aanvragen/{id}` requires a caller identity and is scoped to that caller's own BSN /// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the /// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point /// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down") @@ -58,12 +58,12 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."), new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."), new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."), - new("GET", "/api/v1/applications", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), - new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), - new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), - new("PUT", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."), - new("DELETE", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."), - new("POST", "/api/v1/applications/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."), + new("GET", "/api/v1/aanvragen", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), + new("GET", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), + new("POST", "/api/v1/aanvragen", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), + new("PUT", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."), + new("DELETE", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."), + new("POST", "/api/v1/aanvragen/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."), // --- External caller, not a Principal at all. --- new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."), diff --git a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs index c665f08..022ed5f 100644 --- a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs +++ b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs @@ -18,11 +18,11 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur return req; } - private async Task CreateAndSubmitHerregistratie() + private async Task CreateAndSubmitHerregistratie() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); - var a = (await created.Content.ReadFromJsonAsync())!; - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 200 })) + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); + var a = (await created.Content.ReadFromJsonAsync())!; + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 200 })) .EnsureSuccessStatusCode(); return a; } @@ -35,7 +35,7 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur { var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad")); res.EnsureSuccessStatusCode(); - var queue = (await res.Content.ReadFromJsonAsync>())!; + var queue = (await res.Content.ReadFromJsonAsync>())!; var mine = queue.Single(x => x.Id == a.Id); Assert.Equal("InBehandeling", mine.Status.Tag); // RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto. @@ -53,18 +53,18 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur [Fact] public async Task Queue_excludes_concepts() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); - var a = (await created.Content.ReadFromJsonAsync())!; + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); + var a = (await created.Content.ReadFromJsonAsync())!; try { var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad")); res.EnsureSuccessStatusCode(); - var queue = (await res.Content.ReadFromJsonAsync>())!; + var queue = (await res.Content.ReadFromJsonAsync>())!; Assert.DoesNotContain(queue, x => x.Id == a.Id); } finally { - await _client.DeleteAsync($"/api/v1/applications/{a.Id}"); + await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}"); } } diff --git a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs index be845bb..42353b6 100644 --- a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs +++ b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs @@ -39,14 +39,14 @@ public class ZgwDivergenceTests b.ConfigurePrimaryHttpMessageHandler(() => stub)))); } - /// Doesn't call GET /applications first (unlike ApplicationTests.Create) — under + /// Doesn't call GET /aanvragen first (unlike ApplicationTests.Create) — under /// Zgw:Enabled=true that route goes through IZaakSource too, which this test's stub doesn't /// need to answer since every test here uses a fresh db and creates exactly one aanvraag. private static async Task CreateConcept(HttpClient client, string type = "registratie") { - var res = await client.PostAsJsonAsync("/api/v1/applications", new { type }); + var res = await client.PostAsJsonAsync("/api/v1/aanvragen", new { type }); res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; return body.Id; } @@ -78,11 +78,11 @@ public class ZgwDivergenceTests using var client = factory.CreateClient(); var id = await CreateConcept(client); - var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }); + var res = await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" }); // The local write is still authoritative: 200 with a real reference, not a 500. res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.NotEmpty(body.Referentie); var stored = ApplicationStore.ListAll().Single(a => a.Id == id); @@ -103,7 +103,7 @@ public class ZgwDivergenceTests using var client = factory.CreateClient(); var id = await CreateConcept(client); - var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }); + var res = await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" }); res.EnsureSuccessStatusCode(); var stored = ApplicationStore.ListAll().Single(a => a.Id == id); @@ -126,7 +126,7 @@ public class ZgwDivergenceTests using var client = factory.CreateClient(); var id = await CreateConcept(client); - (await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" })) + (await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" })) .EnsureSuccessStatusCode(); var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError; diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 835788c..268d9c3 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -637,8 +637,8 @@ export class ApiClient { /** * @return OK */ - applicationsAll(): Promise { - let url_ = this.baseUrl + "/api/v1/applications"; + aanvragenAll(): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen"; url_ = url_.replace(/[?&]$/, ""); let options_: RequestInit = { @@ -649,17 +649,17 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsAll(_response); + return this.processAanvragenAll(_response); }); } - protected processApplicationsAll(response: Response): Promise { + protected processAanvragenAll(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[]; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[]; return result200; }); } else if (status !== 200 && status !== 204) { @@ -667,14 +667,14 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return Created */ - applicationsPOST(body: CreateApplicationRequest): Promise { - let url_ = this.baseUrl + "/api/v1/applications"; + aanvragenPOST(body: CreateAanvraagRequest): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen"; url_ = url_.replace(/[?&]$/, ""); const content_ = JSON.stringify(body); @@ -689,17 +689,17 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsPOST(_response); + return this.processAanvragenPOST(_response); }); } - protected processApplicationsPOST(response: Response): Promise { + protected processAanvragenPOST(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 201) { return response.text().then((_responseText) => { let result201: any = null; - result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationDetailDto; + result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagDetailDto; return result201; }); } else if (status === 409) { @@ -713,14 +713,14 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return OK */ - applicationsGET(id: string): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}"; + aanvragenGET(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -734,17 +734,17 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsGET(_response); + return this.processAanvragenGET(_response); }); } - protected processApplicationsGET(response: Response): Promise { + protected processAanvragenGET(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationDetailDto; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagDetailDto; return result200; }); } else if (status === 404) { @@ -756,14 +756,14 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return No Content */ - applicationsPUT(id: string, body: DraftSyncRequest): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}"; + aanvragenPUT(id: string, body: DraftSyncRequest): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -780,11 +780,11 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsPUT(_response); + return this.processAanvragenPUT(_response); }); } - protected processApplicationsPUT(response: Response): Promise { + protected processAanvragenPUT(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 204) { @@ -812,8 +812,8 @@ export class ApiClient { /** * @return No Content */ - applicationsDELETE(id: string): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}"; + aanvragenDELETE(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -826,11 +826,11 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsDELETE(_response); + return this.processAanvragenDELETE(_response); }); } - protected processApplicationsDELETE(response: Response): Promise { + protected processAanvragenDELETE(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 204) { @@ -858,8 +858,8 @@ export class ApiClient { /** * @return OK */ - submit(id: string, body: SubmitApplicationRequest): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}/submit"; + submit(id: string, body: AanvraagIndienenRequest): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}/submit"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -881,13 +881,13 @@ export class ApiClient { }); } - protected processSubmit(response: Response): Promise { + protected processSubmit(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagIndienenResponse; return result200; }); } else if (status === 400) { @@ -911,13 +911,13 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return OK */ - casesAll(): Promise { + casesAll(): Promise { let url_ = this.baseUrl + "/api/v1/admin/cases"; url_ = url_.replace(/[?&]$/, ""); @@ -933,13 +933,13 @@ export class ApiClient { }); } - protected processCasesAll(response: Response): Promise { + protected processCasesAll(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[]; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[]; return result200; }); } else if (status === 403) { @@ -953,7 +953,7 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -1047,7 +1047,7 @@ export class ApiClient { /** * @return OK */ - werkvoorraad(): Promise { + werkvoorraad(): Promise { let url_ = this.baseUrl + "/api/v1/werkvoorraad"; url_ = url_.replace(/[?&]$/, ""); @@ -1063,13 +1063,13 @@ export class ApiClient { }); } - protected processWerkvoorraad(response: Response): Promise { + protected processWerkvoorraad(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[]; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[]; return result200; }); } else if (status === 403) { @@ -1083,7 +1083,7 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -1898,22 +1898,7 @@ export interface AantekeningDto { datum?: string | undefined; } -export interface AanvraagStatusDto { - tag?: string | undefined; - stepIndex?: number | undefined; - stepCount?: number | undefined; - referentie?: string | undefined; - manual?: boolean | undefined; - reden?: string | undefined; -} - -export interface AdresDto { - straat?: string | undefined; - postcode?: string | undefined; - woonplaats?: string | undefined; -} - -export interface ApplicationDetailDto { +export interface AanvraagDetailDto { id?: string | undefined; type?: string | undefined; status?: AanvraagStatusDto; @@ -1924,7 +1909,29 @@ export interface ApplicationDetailDto { submittedAt?: string | undefined; } -export interface ApplicationSummaryDto { +export interface AanvraagIndienenRequest { + diplomaHerkomst?: string | undefined; + uren?: number | undefined; + documents?: DocumentRefDto[] | undefined; + aanvullendeScholing?: boolean | undefined; + scholingPunten?: number | undefined; +} + +export interface AanvraagIndienenResponse { + referentie?: string | undefined; + status?: AanvraagStatusDto; +} + +export interface AanvraagStatusDto { + tag?: string | undefined; + stepIndex?: number | undefined; + stepCount?: number | undefined; + referentie?: string | undefined; + manual?: boolean | undefined; + reden?: string | undefined; +} + +export interface AanvraagSummaryDto { id?: string | undefined; type?: string | undefined; status?: AanvraagStatusDto; @@ -1935,6 +1942,12 @@ export interface ApplicationSummaryDto { owner?: string | undefined; } +export interface AdresDto { + straat?: string | undefined; + postcode?: string | undefined; + woonplaats?: string | undefined; +} + export interface AuthzAuditDto { at?: string | undefined; action?: string | undefined; @@ -1955,7 +1968,7 @@ export interface BeoordelingDocumentDto { } export interface BeoordelingViewDto { - aanvraag?: ApplicationSummaryDto; + aanvraag?: AanvraagSummaryDto; documenten?: BeoordelingDocumentDto[] | undefined; decisions?: BeoordelingDecisionsDto; } @@ -2014,7 +2027,7 @@ export interface ChangeRequestRequest { telefoon?: string | undefined; } -export interface CreateApplicationRequest { +export interface CreateAanvraagRequest { type?: string | undefined; } @@ -2275,19 +2288,6 @@ export interface SubOrgSummaryDto { publishedVersion?: number; } -export interface SubmitApplicationRequest { - diplomaHerkomst?: string | undefined; - uren?: number | undefined; - documents?: DocumentRefDto[] | undefined; - aanvullendeScholing?: boolean | undefined; - scholingPunten?: number | undefined; -} - -export interface SubmitApplicationResponse { - referentie?: string | undefined; - status?: AanvraagStatusDto; -} - export interface UploadCategoriesDto { categories?: DocumentCategoryDto[] | undefined; } -- 2.54.0 From 42e7a1e927e64b7e0c98162cc9ab0d3545fcdd41 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 14:36:30 +0200 Subject: [PATCH 2/5] refactor: delete shadow contracts DTOs, parse the generated shape for real (Step 2/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashboard-view.dto.ts and brp-address.dto.ts each shadowed a generated type: DashboardViewDto was declared twice (hand-written with required fields, generated with everything optional), reconciled only by structural typing. Both are gone. dashboard-view.adapter.ts now imports the generated DashboardViewDto/ RegistrationDto/PersonDto/RegistrationStatusDto directly. Its parse does real work now instead of an identity copy: parseRegistrationStatus validates each status variant's required fields per-tag (the generated type flattens the union, so a Geregistreerd row missing herregistratieDatum previously passed the boundary unnoticed — it no longer does). HerregistratieDecisions moves from contracts/ to domain/registration.ts, so no contracts-typed value reaches a page. brp.adapter.ts drops its own BrpAddressDto shadow the same way. Hand-written contracts/*.dto.ts count: 4 -> 2 (duo-diplomas.dto.ts and stamdata.dto.ts remain — both parse fields codegen can't type at all). Part of the dashboard-readability refactor (see the approved plan). Co-Authored-By: Claude Opus 5 --- .../application/big-profile.store.ts | 2 +- .../registratie/contracts/brp-address.dto.ts | 15 --- .../contracts/dashboard-view.dto.ts | 59 -------- .../app/registratie/domain/registration.ts | 8 ++ .../registratie/infrastructure/brp.adapter.ts | 2 +- .../dashboard-view.adapter.spec.ts | 39 ++++++ .../infrastructure/dashboard-view.adapter.ts | 127 ++++++++++++------ 7 files changed, 134 insertions(+), 118 deletions(-) delete mode 100644 apps/ssp/src/app/registratie/contracts/brp-address.dto.ts delete mode 100644 apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts diff --git a/apps/ssp/src/app/registratie/application/big-profile.store.ts b/apps/ssp/src/app/registratie/application/big-profile.store.ts index 704429c..c590e96 100644 --- a/apps/ssp/src/app/registratie/application/big-profile.store.ts +++ b/apps/ssp/src/app/registratie/application/big-profile.store.ts @@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { RemoteData, fromResource, map } from '@shared/application/remote-data'; import { Aantekening } from '../domain/registration'; import { BigProfile } from '../domain/big-profile'; -import { HerregistratieDecisions } from '../contracts/dashboard-view.dto'; +import { HerregistratieDecisions } from '../domain/registration'; import { BigRegisterAdapter } from '../infrastructure/big-register.adapter'; import { DashboardView, diff --git a/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts b/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts deleted file mode 100644 index d681a7d..0000000 --- a/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call). - * - * In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our - * own backend, which talks to the BRP behind an adapter. The frontend never sees - * the BRP's own wire format. See docs/reference/architecture/0001-bff-lite-decision-dtos.md. - * - * "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error — - * the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy - * path (gevonden: true). - */ -export interface BrpAddressDto { - gevonden: boolean; - adres?: { straat: string; postcode: string; woonplaats: string }; -} diff --git a/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts b/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts deleted file mode 100644 index d156ec7..0000000 --- a/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * WIRE CONTRACT for the dashboard screen — the "BFF-lite" response. - * - * PURE wire shapes: this file imports NOTHING (CLAUDE.md §1, ADR-0001). Enums are - * inlined string-literal unions that describe the wire, not the domain. The - * adapter's `parseDashboardView` validates this untrusted shape and MAPS it onto - * the FE domain model (Registration/Person/BigProfile) — that map is the - * decoupling seam: the wire can change without the domain following. - * - * In production these types are GENERATED from the OpenAPI/TypeSpec spec (one - * source of truth for both sides), and the `decisions` block is computed BY THE - * BACKEND — never recomputed on the client. The frontend renders decisions; it - * does not own the rules. See docs/reference/architecture/0001-bff-lite-decision-dtos.md. - * - * One screen-shaped call replaces the previous three (BIG-register + BRP + …), - * so the page always sees one consistent snapshot instead of three independently - * loading/erroring resources. - */ - -/** Registration status on the wire: the discriminant tags as they arrive. */ -export type RegistrationStatusDto = - | { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date - | { tag: 'Geschorst'; geschorstTot: string; reden: string } - | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string }; - -export interface RegistrationDto { - bigNummer: string; - naam: string; - beroep: string; - registratiedatum: string; // ISO date - geboortedatum: string; - status: RegistrationStatusDto; -} - -export interface AdresDto { - straat: string; - postcode: string; - woonplaats: string; -} - -export interface PersonDto { - naam: string; - geboortedatum: string; // ISO date - adres: AdresDto; -} - -/** Server-computed decisions. Rendered by the FE as-is (decision DTO, ADR-0001): - the eligibility rule lives on the backend; the optional reason lets the UI - explain itself without knowing the rule. */ -export interface HerregistratieDecisions { - eligibleForHerregistratie: boolean; - herregistratieReason?: string; -} - -export interface DashboardViewDto { - registration: RegistrationDto; - person: PersonDto; - decisions: HerregistratieDecisions; -} diff --git a/apps/ssp/src/app/registratie/domain/registration.ts b/apps/ssp/src/app/registratie/domain/registration.ts index 2dba1c3..94af1fb 100644 --- a/apps/ssp/src/app/registratie/domain/registration.ts +++ b/apps/ssp/src/app/registratie/domain/registration.ts @@ -33,3 +33,11 @@ export interface Aantekening { omschrijving: string; datum: string; } + +/** Server-computed eligibility for herregistratie (ADR-0001 decision DTO): the FE + renders this as-is, it never recomputes the rule. The optional reason lets the + UI explain a "not eligible" outcome without knowing why. */ +export interface HerregistratieDecisions { + eligibleForHerregistratie: boolean; + herregistratieReason?: string; +} diff --git a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts index c343ba1..512c221 100644 --- a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { BrpAddressDto } from '@registratie/contracts/brp-address.dto'; +import { BrpAddressDto } from '@shared/infrastructure/api-client'; import { ApiClient } from '@shared/infrastructure/api-client'; /** diff --git a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts index e2fe78d..2ad78d7 100644 --- a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts +++ b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts @@ -35,4 +35,43 @@ describe('parseDashboardView (trust boundary)', () => { parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok, ).toBe(false); }); + + it('rejects a status whose tag is present but its required fields are missing', () => { + // The generated RegistrationStatusDto flattens the union — every field is + // optional, so a wire bug (a Geregistreerd row with no herregistratieDatum) + // must be caught here, not by the compiler. + expect( + parseDashboardView({ + ...valid, + registration: { ...valid.registration, status: { tag: 'Geregistreerd' } }, + }).ok, + ).toBe(false); + expect( + parseDashboardView({ + ...valid, + registration: { + ...valid.registration, + status: { tag: 'Geschorst', geschorstTot: '2027-01-01' }, // missing reden + }, + }).ok, + ).toBe(false); + }); + + it('rejects an unknown status tag', () => { + expect( + parseDashboardView({ + ...valid, + registration: { ...valid.registration, status: { tag: 'Ingetrokken' } }, + }).ok, + ).toBe(false); + }); + + it('rejects a person with an incomplete adres', () => { + expect( + parseDashboardView({ + ...valid, + person: { ...valid.person, adres: { straat: 'X 1' } }, + }).ok, + ).toBe(false); + }); }); diff --git a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts index 1a55742..7791ea8 100644 --- a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts @@ -1,18 +1,24 @@ import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { + ApiClient, DashboardViewDto, + RegistrationDto, + RegistrationStatusDto, + PersonDto, +} from '@shared/infrastructure/api-client'; +import { + Registration, + RegistrationStatus, HerregistratieDecisions, -} from '@registratie/contracts/dashboard-view.dto'; -import { Registration } from '@registratie/domain/registration'; +} from '@registratie/domain/registration'; import { Person } from '@registratie/domain/person'; import { BigProfile } from '@registratie/domain/big-profile'; -import { ApiClient } from '@shared/infrastructure/api-client'; /** * The parsed, frontend-side view: the wire DTO mapped onto our own domain model. - * Lives HERE, not in contracts/, because it references domain types — contracts - * stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001). + * Lives HERE, not in a `contracts/*.dto.ts` file, because it references domain + * types — that split is the decoupling seam (CLAUDE.md §1, ADR-0001). */ export interface DashboardView { profile: BigProfile; @@ -31,17 +37,80 @@ export class DashboardViewAdapter { // The value is still untrusted JSON — parseDashboardView validates it at the // boundary and maps DTO → domain before the app uses it. - // - // SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here — - // e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the - // adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls - // (the submit-* commands) must NEVER auto-retry — and don't. Manual retry - // (resource.reload via ) covers the UX today, so backoff stays unbuilt. dashboardViewResource() { return resource({ loader: () => this.client.dashboardView() }); } } +/** Trust-boundary parse of the status union — the generated `RegistrationStatusDto` + flattens all three variants into one object with every field optional (NSwag + can't express a discriminated union), so the tag drives which fields must + actually be present. Mirrors `parseAanvraagStatus` in `aanvragen.adapter.ts`. */ +export function parseRegistrationStatus( + s: RegistrationStatusDto | undefined, +): Result { + if (!s || typeof s.tag !== 'string') return err('registration: missing status'); + switch (s.tag) { + case 'Geregistreerd': + if (typeof s.herregistratieDatum !== 'string') + return err('registration: bad Geregistreerd status'); + return ok({ tag: 'Geregistreerd', herregistratieDatum: s.herregistratieDatum }); + case 'Geschorst': + if (typeof s.geschorstTot !== 'string' || typeof s.reden !== 'string') + return err('registration: bad Geschorst status'); + return ok({ tag: 'Geschorst', geschorstTot: s.geschorstTot, reden: s.reden }); + case 'Doorgehaald': + if (typeof s.doorgehaaldOp !== 'string' || typeof s.reden !== 'string') + return err('registration: bad Doorgehaald status'); + return ok({ tag: 'Doorgehaald', doorgehaaldOp: s.doorgehaaldOp, reden: s.reden }); + default: + return err(`registration: unknown status tag ${s.tag}`); + } +} + +function parseRegistration(dto: RegistrationDto | undefined): Result { + if ( + !dto || + typeof dto.bigNummer !== 'string' || + typeof dto.naam !== 'string' || + typeof dto.beroep !== 'string' || + typeof dto.registratiedatum !== 'string' || + typeof dto.geboortedatum !== 'string' + ) { + return err('dashboard-view: missing/invalid registration'); + } + const status = parseRegistrationStatus(dto.status); + if (!status.ok) return status; + return ok({ + bigNummer: dto.bigNummer, + naam: dto.naam, + beroep: dto.beroep, + registratiedatum: dto.registratiedatum, + geboortedatum: dto.geboortedatum, + status: status.value, + }); +} + +function parsePerson(dto: PersonDto | undefined): Result { + const a = dto?.adres; + if ( + !dto || + typeof dto.naam !== 'string' || + typeof dto.geboortedatum !== 'string' || + !a || + typeof a.straat !== 'string' || + typeof a.postcode !== 'string' || + typeof a.woonplaats !== 'string' + ) { + return err('dashboard-view: missing/invalid person'); + } + return ok({ + naam: dto.naam, + geboortedatum: dto.geboortedatum, + adres: { straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats }, + }); +} + /** * Trust-boundary parse: validate the untrusted response shape and map the DTO * onto our own domain model. Hand-written on purpose — no Zod for a single @@ -51,43 +120,17 @@ export function parseDashboardView(json: unknown): Result if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object'); const dto = json as Partial; - const reg = dto.registration; - if ( - !reg || - typeof reg.bigNummer !== 'string' || - !reg.status || - typeof reg.status.tag !== 'string' - ) { - return err('dashboard-view: missing/invalid registration'); - } - const person = dto.person; - if (!person || !person.adres || typeof person.adres.postcode !== 'string') { - return err('dashboard-view: missing/invalid person'); - } + const registration = parseRegistration(dto.registration); + if (!registration.ok) return registration; + const person = parsePerson(dto.person); + if (!person.ok) return person; const d = dto.decisions; if (!d || typeof d.eligibleForHerregistratie !== 'boolean') { return err('dashboard-view: missing/invalid decisions'); } - // Map wire → domain. The shapes are identical today, so this reads as an - // identity copy — but the TYPES differ (wire DTO vs domain), so the moment the - // wire diverges the compiler forces a real mapping here. That's the seam. - const registration: Registration = { - bigNummer: reg.bigNummer, - naam: reg.naam, - beroep: reg.beroep, - registratiedatum: reg.registratiedatum, - geboortedatum: reg.geboortedatum, - status: reg.status, - }; - const persoon: Person = { - naam: person.naam, - geboortedatum: person.geboortedatum, - adres: person.adres, - }; - return ok({ - profile: { registration, person: persoon }, + profile: { registration: registration.value, person: person.value }, decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason, -- 2.54.0 From 5977efe0449c230040af93687d198f22f29bdf9d Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 15:17:02 +0200 Subject: [PATCH 3/5] refactor: split dashboard.page.ts into per-concern sections Each dashboard section (Mijn aanvragen, Wat moet ik regelen, Mijn registratie, Specialismen, Wat wilt u doen, Beheer) now owns its own store access, async state, and template. DashboardPage becomes pure composition. Extract the repeated RemoteData Success-narrowing pattern into successOf() and the dashboard sort/split logic into sortForDashboard/concepten/ingediend, both with tests. Co-Authored-By: Claude Sonnet 5 --- .../registratie/domain/aanvraag-view.spec.ts | 48 ++- .../app/registratie/domain/aanvraag-view.ts | 27 ++ .../registratie/ui/aanvraag-detail.page.ts | 9 +- .../src/app/registratie/ui/dashboard.page.ts | 344 ++---------------- .../ui/dashboard/beheer-links.section.ts | 35 ++ .../mijn-aanvragen.section.stories.ts | 84 +++++ .../ui/dashboard/mijn-aanvragen.section.ts | 112 ++++++ .../mijn-registratie.section.stories.ts | 57 +++ .../ui/dashboard/mijn-registratie.section.ts | 71 ++++ .../dashboard/specialismen.section.stories.ts | 51 +++ .../ui/dashboard/specialismen.section.ts | 43 +++ .../dashboard/wat-moet-ik-regelen.section.ts | 63 ++++ .../ui/dashboard/wat-wilt-u-doen.section.ts | 79 ++++ .../ui/registration-detail.page.ts | 9 +- .../src/application/remote-data.spec.ts | 16 +- libs/shared/src/application/remote-data.ts | 9 + libs/shared/src/testing/remote-data.ts | 2 + 17 files changed, 725 insertions(+), 334 deletions(-) create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts create mode 100644 apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts diff --git a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts index 17a3234..03eb010 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view'; +import { + submittedRow, + detailRows, + purposeLabel, + statusLabel, + TYPE_LABELS, + sortForDashboard, + concepten, + ingediend, +} from './aanvraag-view'; import { Aanvraag } from './aanvraag'; const base: Omit = { @@ -114,3 +123,40 @@ describe('detailRows', () => { expect(rows.length).toBe(5); }); }); + +describe('sortForDashboard / concepten / ingediend', () => { + const withStatus = (id: string, tag: Aanvraag['status']['tag']): Aanvraag => ({ + ...base, + id, + status: + tag === 'Concept' + ? { tag, stepIndex: 0, stepCount: 1 } + : tag === 'Afgewezen' || tag === 'MeerInfoGevraagd' + ? { tag, referentie: 'R', reden: 'x' } + : tag === 'InBehandeling' + ? { tag, referentie: 'R', manual: false } + : { tag, referentie: 'R' }, + }); + + it('sorts Concept, then still-open, then resolved last', () => { + const goedgekeurd = withStatus('1', 'Goedgekeurd'); + const concept = withStatus('2', 'Concept'); + const inBehandeling = withStatus('3', 'InBehandeling'); + const sorted = sortForDashboard([goedgekeurd, concept, inBehandeling]); + expect(sorted.map((a) => a.id)).toEqual(['2', '3', '1']); + }); + + it('does not mutate the input array', () => { + const list = [withStatus('1', 'Goedgekeurd'), withStatus('2', 'Concept')]; + const copy = [...list]; + sortForDashboard(list); + expect(list).toEqual(copy); + }); + + it('concepten/ingediend split on the Concept tag', () => { + const concept = withStatus('1', 'Concept'); + const ingediendItem = withStatus('2', 'Ingediend'); + expect(concepten([concept, ingediendItem])).toEqual([concept]); + expect(ingediend([concept, ingediendItem])).toEqual([ingediendItem]); + }); +}); diff --git a/apps/ssp/src/app/registratie/domain/aanvraag-view.ts b/apps/ssp/src/app/registratie/domain/aanvraag-view.ts index bcf26bc..ab734d3 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag-view.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag-view.ts @@ -106,3 +106,30 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] { } return rows; } + +/** Dashboard sort order: still-open work first (Concept, then submitted-and-pending), + resolved aanvragen (Goedgekeurd/Afgewezen) last. Within a group, order is stable + (the sort is by rank only). */ +const SORT_RANK: Record = { + Concept: 0, + Ingediend: 1, + InBehandeling: 1, + MeerInfoGevraagd: 1, + Goedgekeurd: 2, + Afgewezen: 2, +}; + +/** The dashboard's "Mijn aanvragen" ordering: open work before resolved cases. */ +export function sortForDashboard(aanvragen: Aanvraag[]): Aanvraag[] { + return aanvragen.slice().sort((a, b) => SORT_RANK[a.status.tag] - SORT_RANK[b.status.tag]); +} + +/** A Concept ("lopende aanvraag") renders as a melding above the list; a submitted + aanvraag as a keuzelijst item — the two shapes need different HTML contexts. */ +export function concepten(aanvragen: Aanvraag[]): Aanvraag[] { + return aanvragen.filter((a) => a.status.tag === 'Concept'); +} + +export function ingediend(aanvragen: Aanvraag[]): Aanvraag[] { + return aanvragen.filter((a) => a.status.tag !== 'Concept'); +} diff --git a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts index fdf7d00..c23353e 100644 --- a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts +++ b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts @@ -1,4 +1,5 @@ import { Component, computed, inject } from '@angular/core'; +import { successOf } from '@shared/application/remote-data'; import { ActivatedRoute } from '@angular/router'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; @@ -66,9 +67,7 @@ export class AanvraagDetailPage { protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id); protected rows = detailRows; - /** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */ - protected readonly aanvragen = computed(() => { - const rd = this.store.aanvragen(); - return rd.tag === 'Success' ? rd.value : undefined; - }); + /** `successOf`: `` can't inherit a generic from a sibling host input, + so the Success value is unwrapped here instead of through `let-`. */ + protected readonly aanvragen = computed(() => successOf(this.store.aanvragen())); } diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/registratie/ui/dashboard.page.ts index 21a3426..0d4f177 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard.page.ts @@ -1,47 +1,25 @@ -import { Component, computed, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { Component } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; -import { HeadingComponent } from '@shared/ui/heading/heading.component'; -import { AlertComponent } from '@shared/ui/alert/alert.component'; -import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; -import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; -import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; -import { TaskListComponent } from '@shared/ui/task-list/task-list.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 { AccessStore } from '@shared/application/access.store'; -import { FeatureFlagStore } from '@shared/application/feature-flags.store'; -import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; -import { ADMIN_LINKS } from '../../shell/nav.config'; -import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; -import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; -import { AanvraagBlockComponent } from '@registratie/ui/aanvraag-block/aanvraag-block.component'; -import { BigProfileStore } from '@registratie/application/big-profile.store'; -import { AanvragenStore } from '@registratie/application/aanvragen.store'; -import { Registration } from '@registratie/domain/registration'; -import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; -import { submittedRow } from '@registratie/domain/aanvraag-view'; -import { tasksFromProfile } from '@registratie/domain/tasks'; +import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section'; +import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section'; +import { MijnRegistratieSection } from './dashboard/mijn-registratie.section'; +import { SpecialismenSection } from './dashboard/specialismen.section'; +import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section'; +import { BeheerLinksSection } from './dashboard/beheer-links.section'; -/** Page:"Mijn overzicht" — the portal home, following the NL Design System -"Mijn omgeving" pattern (side nav +"Wat moet ik regelen" +"Mijn zaken"). */ +/** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn + omgeving" pattern. Composition only: each section below answers its own data + question (own store, own async state) — see `ui/dashboard/*.section.ts`. */ @Component({ selector: 'app-dashboard-page', imports: [ PageShellComponent, - HeadingComponent, - AlertComponent, - SkeletonComponent, - DataRowComponent, - DataBlockComponent, - TaskListComponent, - ApplicationListComponent, - ApplicationLinkComponent, - ...ASYNC, - RegistrationSummaryComponent, - RegistrationTableComponent, - AanvraagBlockComponent, + MijnAanvragenSection, + WatMoetIkRegelenSection, + MijnRegistratieSection, + SpecialismenSection, + WatWiltUDoenSection, + BeheerLinksSection, ], template: `
- @if (cancelError(); as err) { - {{ err }} - } - @if (aanvragen().length) { -
- @for (a of concepten(); track a.id) { - - } - @if (ingediend().length) { - Mijn aanvragen - - @for (a of ingediend(); track a.id) { - @let row = submittedRow(a); -
  • - } -
    - } -
    - } - - @if (store.pendingHerregistratie()) { - Uw herregistratie-aanvraag is in behandeling. - } - - - - @if (profile(); as p) { - @let tasks = tasksFor(p.registration); - -
    - @if (tasks.length) { - - } @else { - Wat moet ik regelen -

    - U heeft op dit moment niets openstaan. -

    - } -
    - -
    - Mijn registratie -
    - -
    - -
    -
    -
    -
    -
    - } -
    - - - -
    - -
    - Specialismen en aantekeningen -
    - - - @if (aantekeningen(); as r) { - - } - - - - - -

    - U heeft nog geen specialismen of aantekeningen. -

    -
    -
    -
    -
    - -
    - Wat wilt u doen? - - @for (a of acties(); track a.to) { -
  • - } -
    -
    - - @if (adminLinks().length) { -
    - Beheer - - @for (link of adminLinks(); track link.to) { -
  • - } -
    -
    - } + + + + + +
    `, }) -export class DashboardPage { - protected store = inject(BigProfileStore); - private apps = inject(AanvragenStore); - private access = inject(AccessStore); - private flags = inject(FeatureFlagStore); - private router = inject(Router); - - /** Admin pages the current principal may reach — capability-gated (never role-derived), - the same source + filter the site header uses. Empty for a non-admin → section hidden. */ - protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap))); - - /** Pure view mapping for a submitted aanvraag → CIBG aanvragen-row fields. */ - protected submittedRow = submittedRow; - - constructor() { - // Re-fetch on each visit so server-computed auto-approval transitions show up - // (Concept → In behandeling → Goedgekeurd after the processing window). - this.apps.reload(); - } - - /** The user's aanvragen, sorted Concept → In behandeling → resolved. Empty → - the"Mijn aanvragen" section is hidden (see template). */ - protected aanvragen = computed(() => { - const rd = this.apps.aanvragen(); - if (rd.tag !== 'Success') return []; - const order: Record = { - Concept: 0, - Ingediend: 1, - InBehandeling: 1, - MeerInfoGevraagd: 1, - Goedgekeurd: 2, - Afgewezen: 2, - }; - return rd.value.slice().sort((a, b) => order[a.status.tag] - order[b.status.tag]); - }); - /** A Concept ("lopende aanvraag") renders as a melding above the list; the rest - as keuzelijst items — the two shapes need different HTML contexts. */ - protected concepten = computed(() => this.aanvragen().filter((a) => a.status.tag === 'Concept')); - protected ingediend = computed(() => this.aanvragen().filter((a) => a.status.tag !== 'Concept')); - - private readonly resumeRoutes: Record = { - registratie: '/registreren', - herregistratie: '/herregistratie', - intake: '/intake', - }; - protected resume(a: Aanvraag) { - void this.router.navigate([this.resumeRoutes[a.type]], { queryParams: { aanvraag: a.id } }); - } - protected cancelAanvraag(a: Aanvraag) { - void this.apps.cancel(a.id); - } - /** RB-20: the message from a failed cancel, rendered above the list. */ - protected cancelError = computed(() => this.apps.lastError()); - - /** Server-computed eligibility (rendered, not recomputed). */ - private readonly eligible = computed(() => { - const d = this.store.decisions(); - return d.tag === 'Success' && d.value.eligibleForHerregistratie; - }); - - protected tasksFor(reg: Registration) { - return tasksFromProfile(reg, this.eligible()); - } - - /** Typed narrowing for the `` loaded slot — ``'s own - context can't inherit a generic from a sibling host input (Angular only infers - a structural directive's type parameter from an input on that same node), so - the Success value is unwrapped here instead of through `let-`. */ - protected readonly profile = computed(() => { - const rd = this.store.profile(); - return rd.tag === 'Success' ? rd.value : undefined; - }); - protected readonly aantekeningen = computed(() => { - const rd = this.store.aantekeningen(); - return rd.tag === 'Success' ? rd.value : undefined; - }); - - /** Primary transactional actions, as an "aanvragen" list (see CIBG's - componenten/aanvragen). The core portal sections live in the header nav now; - the teaching pages (concepts/brief) are only reachable from here. */ - private readonly allActies = [ - { - to: '/registreren', - titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`, - tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`, - actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving`, - }, - { - to: '/herregistratie', - titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`, - tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`, - actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan`, - }, - { - to: '/intake', - titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`, - tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`, - actie: $localize`:@@dashboard.actie.intake.actie:Start intake`, - }, - { - to: '/registratie', - titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`, - tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`, - actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens`, - }, - { - to: '/concepts', - titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`, - tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`, - actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen`, - }, - { - to: '/brief', - titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`, - tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`, - actie: $localize`:@@dashboard.actie.brief.actie:Start brief`, - }, - ]; - - /** Hide the "Inschrijven" action when self-service registration is flagged off (WP-47). */ - protected readonly acties = computed(() => - this.allActies.filter( - (a) => a.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN), - ), - ); -} +export class DashboardPage {} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts new file mode 100644 index 0000000..cbfacfc --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/beheer-links.section.ts @@ -0,0 +1,35 @@ +import { Component, computed, inject } from '@angular/core'; +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 { AccessStore } from '@shared/application/access.store'; +import { ADMIN_LINKS } from '../../../shell/nav.config'; + +/** Section: "Beheer" — the admin pages the current principal may reach, capability- + gated (never role-derived), the same source + filter the site header uses. + Empty for a non-admin → the section renders nothing (see the page). */ +@Component({ + selector: 'app-beheer-links-section', + imports: [HeadingComponent, ApplicationListComponent, ApplicationLinkComponent], + template: ` + @if (adminLinks().length) { +
    + Beheer + + @for (link of adminLinks(); track link.to) { +
  • + } +
    +
    + } + `, +}) +export class BeheerLinksSection { + private access = inject(AccessStore); + protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap))); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts new file mode 100644 index 0000000..ae81b5e --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.stories.ts @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { provideRouter } from '@angular/router'; +import { MijnAanvragenSection } from './mijn-aanvragen.section'; +import { AanvragenStore } from '@registratie/application/aanvragen.store'; +import { Aanvraag } from '@registratie/domain/aanvraag'; +import { RemoteData } from '@shared/application/remote-data'; +import { loading, success, failure } from '@shared/testing/remote-data'; + +const base = { + id: 'a1', + type: 'herregistratie', + documentIds: [], + createdAt: '2026-06-28T10:00:00Z', + updatedAt: '2026-06-28T10:05:00Z', + submittedAt: '2026-06-28T10:05:00Z', +} satisfies Omit; + +const concept: Aanvraag = { ...base, status: { tag: 'Concept', stepIndex: 1, stepCount: 3 } }; +const ingediend: Aanvraag = { + ...base, + id: 'a2', + status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false }, +}; + +/** Minimal store stand-in — only the members the section's template reads. */ +function storeStub(aanvragen: RemoteData, lastError = '') { + return { + aanvragen: () => aanvragen, + reload: () => {}, + cancel: async () => {}, + lastError: () => lastError, + }; +} + +const meta: Meta = { + title: 'Domein/Registratie/Dashboard/Mijn Aanvragen', + component: MijnAanvragenSection, + decorators: [applicationConfig({ providers: [provideRouter([])] })], +}; +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + decorators: [ + applicationConfig({ providers: [{ provide: AanvragenStore, useValue: storeStub(loading()) }] }), + ], +}; +export const WithConceptAndSubmitted: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: AanvragenStore, useValue: storeStub(success([concept, ingediend])) }], + }), + ], +}; +export const Empty: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: AanvragenStore, useValue: storeStub(success([])) }], + }), + ], +}; +export const CancelFailed: Story = { + decorators: [ + applicationConfig({ + providers: [ + { + provide: AanvragenStore, + useValue: storeStub( + success([concept]), + $localize`:@@dashboard.cancel.failed:Verwijderen is niet gelukt.`, + ), + }, + ], + }), + ], +}; +export const Failed: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: AanvragenStore, useValue: storeStub(failure(new Error('offline'))) }], + }), + ], +}; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts new file mode 100644 index 0000000..7fce4b6 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-aanvragen.section.ts @@ -0,0 +1,112 @@ +import { Component, computed, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +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 { AanvragenStore } from '@registratie/application/aanvragen.store'; +import { Aanvraag, AanvraagType } from '@registratie/domain/aanvraag'; +import { + submittedRow, + sortForDashboard, + concepten, + ingediend, +} from '@registratie/domain/aanvraag-view'; +import { AanvraagBlockComponent } from '@registratie/ui/aanvraag-block/aanvraag-block.component'; + +/** Section: "Mijn aanvragen" — the user's own aanvragen (concepten as resumable + meldingen, submitted ones as keuzelijst rows), owning its own fetch, sort and + the resume/cancel actions. Empty → renders nothing, same as the aanvraag-block + convention (see AanvraagBlockComponent). */ +@Component({ + selector: 'app-mijn-aanvragen-section', + imports: [ + AlertComponent, + SkeletonComponent, + HeadingComponent, + ApplicationListComponent, + ApplicationLinkComponent, + AanvraagBlockComponent, + ...ASYNC, + ], + template: ` + @if (cancelError(); as err) { + {{ err }} + } + + + @if (aanvragen().length) { +
    + @for (a of concepten_(); track a.id) { + + } + @if (ingediend_().length) { + Mijn aanvragen + + @for (a of ingediend_(); track a.id) { + @let row = submittedRow(a); +
  • + } +
    + } +
    + } +
    + + + +
    + `, +}) +export class MijnAanvragenSection { + protected store = inject(AanvragenStore); + private router = inject(Router); + + constructor() { + // Re-fetch on each visit so server-computed auto-approval transitions show up + // (Concept → In behandeling → Goedgekeurd after the processing window). + this.store.reload(); + } + + protected submittedRow = submittedRow; + + protected aanvragen = computed(() => { + const rd = this.store.aanvragen(); + return rd.tag === 'Success' ? sortForDashboard(rd.value) : []; + }); + protected concepten_ = computed(() => concepten(this.aanvragen())); + protected ingediend_ = computed(() => ingediend(this.aanvragen())); + + private readonly resumeRoutes: Record = { + registratie: '/registreren', + herregistratie: '/herregistratie', + intake: '/intake', + }; + protected resume(a: Aanvraag) { + void this.router.navigate([this.resumeRoutes[a.type]], { queryParams: { aanvraag: a.id } }); + } + protected cancel(a: Aanvraag) { + void this.store.cancel(a.id); + } + + /** The message from a failed cancel, rendered above the list. */ + protected cancelError = computed(() => this.store.lastError()); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts new file mode 100644 index 0000000..58ec2f1 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.stories.ts @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { MijnRegistratieSection } from './mijn-registratie.section'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { BigProfile } from '@registratie/domain/big-profile'; +import { RemoteData } from '@shared/application/remote-data'; +import { loading, success, failure } 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 reads. */ +function storeStub(profileRd: RemoteData) { + return { profile: () => profileRd, reloadProfile: () => {} }; +} + +const meta: Meta = { + title: 'Domein/Registratie/Dashboard/Mijn Registratie', + component: MijnRegistratieSection, +}; +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: BigProfileStore, useValue: storeStub(loading()) }], + }), + ], +}; +export const Loaded: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: BigProfileStore, useValue: storeStub(success(profile)) }], + }), + ], +}; +export const Failed: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: BigProfileStore, useValue: storeStub(failure(new Error('offline'))) }], + }), + ], +}; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts new file mode 100644 index 0000000..56a5793 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/mijn-registratie.section.ts @@ -0,0 +1,71 @@ +import { Component, inject } from '@angular/core'; +import { successOf } from '@shared/application/remote-data'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +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 { BigProfileStore } from '@registratie/application/big-profile.store'; +import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; + +/** Section: "Mijn registratie" — the BIG-register summary plus the BRP + persoonsgegevens, both from the one screen-shaped dashboard call + (BigProfileStore). */ +@Component({ + selector: 'app-mijn-registratie-section', + imports: [ + HeadingComponent, + SkeletonComponent, + DataBlockComponent, + DataRowComponent, + RegistrationSummaryComponent, + ...ASYNC, + ], + template: ` + + + @if (profile(); as p) { +
    + Mijn registratie +
    + +
    + +
    +
    +
    +
    +
    + } +
    + + + +
    + `, +}) +export class MijnRegistratieSection { + protected store = inject(BigProfileStore); + protected profile = () => successOf(this.store.profile()); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts new file mode 100644 index 0000000..9912e39 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.stories.ts @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { SpecialismenSection } from './specialismen.section'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { Aantekening } from '@registratie/domain/registration'; +import { RemoteData } from '@shared/application/remote-data'; +import { loading, success, empty, failure } from '@shared/testing/remote-data'; + +const rows: Aantekening[] = [ + { type: 'Specialisme', omschrijving: 'Huisartsgeneeskunde', datum: '2016-04-12' }, + { type: 'Aantekening', omschrijving: 'Erkend opleider huisartsgeneeskunde', datum: '2019-01-08' }, +]; + +/** Minimal store stand-in — only the members the section's template reads. */ +function storeStub(aantekeningen: RemoteData) { + return { aantekeningen: () => aantekeningen, reloadAantekeningen: () => {} }; +} + +const meta: Meta = { + title: 'Domein/Registratie/Dashboard/Specialismen', + component: SpecialismenSection, +}; +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: BigProfileStore, useValue: storeStub(loading()) }], + }), + ], +}; +export const Loaded: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: BigProfileStore, useValue: storeStub(success(rows)) }], + }), + ], +}; +export const Empty: Story = { + decorators: [ + applicationConfig({ providers: [{ provide: BigProfileStore, useValue: storeStub(empty()) }] }), + ], +}; +export const Failed: Story = { + decorators: [ + applicationConfig({ + providers: [{ provide: BigProfileStore, useValue: storeStub(failure(new Error('offline'))) }], + }), + ], +}; diff --git a/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts new file mode 100644 index 0000000..01a30f8 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/specialismen.section.ts @@ -0,0 +1,43 @@ +import { Component, inject } from '@angular/core'; +import { successOf } from '@shared/application/remote-data'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; + +/** Section: "Specialismen en aantekeningen" — a separate resource from the rest of + the dashboard (own load/empty/error state), because it is genuinely a second + endpoint (`big-register.adapter.ts`), not part of the BFF-lite view call. */ +@Component({ + selector: 'app-specialismen-section', + imports: [HeadingComponent, SkeletonComponent, RegistrationTableComponent, ...ASYNC], + template: ` +
    + Specialismen en aantekeningen +
    + + + @if (aantekeningen(); as r) { + + } + + + + + +

    + U heeft nog geen specialismen of aantekeningen. +

    +
    +
    +
    +
    + `, +}) +export class SpecialismenSection { + protected store = inject(BigProfileStore); + protected aantekeningen = () => successOf(this.store.aantekeningen()); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts new file mode 100644 index 0000000..a2d0716 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/wat-moet-ik-regelen.section.ts @@ -0,0 +1,63 @@ +import { Component, computed, inject } from '@angular/core'; +import { successOf } from '@shared/application/remote-data'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +import { TaskListComponent } from '@shared/ui/task-list/task-list.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { tasksFromProfile } from '@registratie/domain/tasks'; + +/** Section: "Wat moet ik regelen" — the open tasks derived from the registration + + the server-computed herregistratie eligibility (rendered, never recomputed; + ADR-0001). Empty task list → a plain "niets openstaan" message, not an empty + async state (the registration itself did load). */ +@Component({ + selector: 'app-wat-moet-ik-regelen-section', + imports: [HeadingComponent, AlertComponent, SkeletonComponent, TaskListComponent, ...ASYNC], + template: ` + @if (store.pendingHerregistratie()) { + Uw herregistratie-aanvraag is in behandeling. + } + + + @if (tasks(); as t) { +
    + @if (t.length) { + + } @else { + Wat moet ik regelen +

    + U heeft op dit moment niets openstaan. +

    + } +
    + } +
    + + + +
    + `, +}) +export class WatMoetIkRegelenSection { + protected store = inject(BigProfileStore); + + private eligible = computed(() => { + const d = successOf(this.store.decisions()); + return d?.eligibleForHerregistratie ?? false; + }); + + protected tasks = computed(() => { + const p = successOf(this.store.profile()); + return p ? tasksFromProfile(p.registration, this.eligible()) : undefined; + }); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts new file mode 100644 index 0000000..7f39f57 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts @@ -0,0 +1,79 @@ +import { Component, computed, inject } from '@angular/core'; +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 { FeatureFlagStore } from '@shared/application/feature-flags.store'; +import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; + +/** Section: "Wat wilt u doen?" — the portal's primary transactional actions (see + CIBG's componenten/aanvragen). The core pages live in the header nav now; the + teaching pages (concepts/brief) are only reachable from here. */ +@Component({ + selector: 'app-wat-wilt-u-doen-section', + imports: [HeadingComponent, ApplicationListComponent, ApplicationLinkComponent], + template: ` +
    + Wat wilt u doen? + + @for (a of acties(); track a.to) { +
  • + } +
    +
    + `, +}) +export class WatWiltUDoenSection { + private flags = inject(FeatureFlagStore); + + private readonly allActies = [ + { + to: '/registreren', + titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`, + tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`, + actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving`, + }, + { + to: '/herregistratie', + titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`, + tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`, + actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan`, + }, + { + to: '/intake', + titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`, + tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`, + actie: $localize`:@@dashboard.actie.intake.actie:Start intake`, + }, + { + to: '/registratie', + titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`, + tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`, + actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens`, + }, + { + to: '/concepts', + titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`, + tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`, + actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen`, + }, + { + to: '/brief', + titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`, + tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`, + actie: $localize`:@@dashboard.actie.brief.actie:Start brief`, + }, + ]; + + /** Hide "Inschrijven" when self-service registration is flagged off. */ + protected readonly acties = computed(() => + this.allActies.filter( + (a) => a.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN), + ), + ); +} diff --git a/apps/ssp/src/app/registratie/ui/registration-detail.page.ts b/apps/ssp/src/app/registratie/ui/registration-detail.page.ts index 25425f4..01f0063 100644 --- a/apps/ssp/src/app/registratie/ui/registration-detail.page.ts +++ b/apps/ssp/src/app/registratie/ui/registration-detail.page.ts @@ -1,4 +1,5 @@ import { Component, computed, inject } from '@angular/core'; +import { successOf } from '@shared/application/remote-data'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { ASYNC } from '@shared/ui/async/async.component'; @@ -41,9 +42,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store'; export class RegistrationDetailPage { protected store = inject(BigProfileStore); - /** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */ - protected readonly profile = computed(() => { - const rd = this.store.profile(); - return rd.tag === 'Success' ? rd.value : undefined; - }); + /** `successOf`: `` can't inherit a generic from a sibling host input, + so the Success value is unwrapped here instead of through `let-`. */ + protected readonly profile = computed(() => successOf(this.store.profile())); } diff --git a/libs/shared/src/application/remote-data.spec.ts b/libs/shared/src/application/remote-data.spec.ts index ee7bd64..45e2d05 100644 --- a/libs/shared/src/application/remote-data.spec.ts +++ b/libs/shared/src/application/remote-data.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { RemoteData, map2, map } from './remote-data'; -import { loading, failure, success } from '../testing/remote-data'; +import { RemoteData, map2, map, successOf } from './remote-data'; +import { loading, failure, empty, success } from '../testing/remote-data'; const loadingRd: RemoteData = loading(); const failureRd: RemoteData = failure('x'); @@ -21,3 +21,15 @@ describe('RemoteData combinators', () => { expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 }); }); }); + +describe('successOf', () => { + it('unwraps a Success value', () => { + expect(successOf(ok(2))).toBe(2); + }); + + it('is undefined for every other state', () => { + expect(successOf(loadingRd)).toBeUndefined(); + expect(successOf(failureRd)).toBeUndefined(); + expect(successOf(empty())).toBeUndefined(); + }); +}); diff --git a/libs/shared/src/application/remote-data.ts b/libs/shared/src/application/remote-data.ts index 45828d8..2b9b4c1 100644 --- a/libs/shared/src/application/remote-data.ts +++ b/libs/shared/src/application/remote-data.ts @@ -80,3 +80,12 @@ export function andThen( ): RemoteData { return rd.tag === 'Success' ? f(rd.value) : rd; } + +/** Unwrap a Success value, or `undefined` for every other state. Used to narrow + an `` loaded slot: `` can't inherit a generic from a + sibling host input (Angular only infers a structural directive's type + parameter from an input on that same node), so the caller unwraps here + instead of through `let-`. */ +export function successOf(rd: RemoteData): T | undefined { + return rd.tag === 'Success' ? rd.value : undefined; +} diff --git a/libs/shared/src/testing/remote-data.ts b/libs/shared/src/testing/remote-data.ts index 1ed22f2..5889dd2 100644 --- a/libs/shared/src/testing/remote-data.ts +++ b/libs/shared/src/testing/remote-data.ts @@ -11,3 +11,5 @@ export const loading = (): RemoteData => ({ tag: 'Lo export const success = (value: T): RemoteData => ({ tag: 'Success', value }); export const failure = (error: E): RemoteData => ({ tag: 'Failure', error }); + +export const empty = (): RemoteData => ({ tag: 'Empty' }); -- 2.54.0 From c7aed8d308c629d5e7e23dfa2956a54fbdaf8754 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 15:17:06 +0200 Subject: [PATCH 4/5] fix: narrow parseBrpAddress's return type instead of leaking the DTO BrpAddressDto's fields are generated as optional, so returning it directly from parseBrpAddress lost the narrowing the runtime check already did. This broke the build once registratie-lookup.store.ts assigned the parsed address into a stricter local type. Map to a proper BrpAddress domain shape at the trust boundary instead. Co-Authored-By: Claude Sonnet 5 --- .../application/registratie-lookup.store.ts | 2 +- .../registratie/infrastructure/brp.adapter.ts | 33 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts b/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts index 3b93480..65abebb 100644 --- a/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts +++ b/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts @@ -41,7 +41,7 @@ export class RegistratieLookupStore { const json = this.adresRes.value(); if (json === undefined) return null; const parsed = parseBrpAddress(json); - return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null; + return parsed.ok && parsed.value.adres ? parsed.value.adres : null; }, ); diff --git a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts index 512c221..ebae5c0 100644 --- a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts @@ -3,6 +3,13 @@ import { Result, ok, err } from '@shared/kernel/fp'; import { BrpAddressDto } from '@shared/infrastructure/api-client'; import { ApiClient } from '@shared/infrastructure/api-client'; +/** BRP address lookup, narrowed from the generated (all-optional) `BrpAddressDto` + to what `gevonden` actually guarantees. */ +export interface BrpAddress { + gevonden: boolean; + adres?: { straat: string; postcode: string; woonplaats: string }; +} + /** * Infrastructure adapter for the BRP address lookup, reached only through our own * ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET @@ -21,20 +28,22 @@ export class BrpAdapter { /** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a valid outcome (gevonden: false), not a malformed response. ponytail: hand-written; reach for a schema lib once the contract count grows. */ -export function parseBrpAddress(json: unknown): Result { +export function parseBrpAddress(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('brp-address: not an object'); const dto = json as Partial; if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden'); - if (dto.gevonden) { - const a = dto.adres; - if ( - !a || - typeof a.straat !== 'string' || - typeof a.postcode !== 'string' || - typeof a.woonplaats !== 'string' - ) { - return err('brp-address: missing/invalid adres'); - } + if (!dto.gevonden) return ok({ gevonden: false }); + const a = dto.adres; + if ( + !a || + typeof a.straat !== 'string' || + typeof a.postcode !== 'string' || + typeof a.woonplaats !== 'string' + ) { + return err('brp-address: missing/invalid adres'); } - return ok({ gevonden: dto.gevonden, adres: dto.adres }); + return ok({ + gevonden: true, + adres: { straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats }, + }); } -- 2.54.0 From c8029d23e2a5fae1b12395c69f21b2711772fdf4 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 4 Sep 2026 15:17:10 +0200 Subject: [PATCH 5/5] docs: regenerate behaviour-spec.mdx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches drift accumulated since the Application → Aanvraag rename (Step 1/8, Step 2/8) plus the new tests from this branch. Co-Authored-By: Claude Sonnet 5 --- libs/shared/docs/behaviour-spec.mdx | 58 ++++++++++++++++++----------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 2d7c71d..725d977 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 497 frontend behaviours across +**is** the suite, reshaped for a business reader. 505 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -418,6 +418,13 @@ classes. ### registratie +#### AanvragenStore + +- loads and parses the list +- cancels optimistically and confirms via the DELETE endpoint +- rolls back the removal and surfaces the error when the cancel fails +- clears a stale error on the next cancel attempt + #### AdminCasesStore - loads and parses the cross-owner list @@ -425,13 +432,6 @@ classes. - rolls back the removal and surfaces the error when the delete fails - clears a stale error on the next delete attempt -#### ApplicationsStore - -- loads and parses the list -- cancels optimistically and confirms via the DELETE endpoint -- rolls back the removal and surfaces the error when the cancel fails -- clears a stale error on the next cancel attempt - #### STEPS (fixed) - always has the same three steps @@ -544,12 +544,12 @@ classes. - parses each tag with its required fields - rejects a missing status, unknown tag, and wrong-typed fields -#### parseApplicationSummary +#### parseAanvraagSummary - maps a valid DTO to domain - rejects a bad type and non-objects -#### parseApplications / parseApplicationDetail +#### parseAanvragen / parseAanvraagDetail - parses a list and fails fast on a bad element - carries the opaque draft through detail @@ -569,6 +569,9 @@ classes. - maps a valid response into a DashboardView - rejects malformed responses instead of trusting them +- rejects a status whose tag is present but its required fields are missing +- rejects an unknown status tag +- rejects a person with an incomplete adres #### parseDuoLookup (trust boundary) @@ -617,6 +620,12 @@ classes. - statusColor is total over the union - herregistratieDeadline is only set for an active registration +#### sortForDashboard / concepten / ingediend + +- sorts Concept, then still-open, then resolved last +- does not mutate the input array +- concepten/ingediend split on the Concept tag + #### submit - stays in Invullen when the draft is incomplete (no diploma) @@ -923,6 +932,11 @@ classes. - leaves a non-API request untouched even when a subject is known - sends no header at all when no subject has ever been seen +#### successOf + +- unwraps a Success value +- is undefined for every other state + #### upload lifecycle messages - queued → progress → complete @@ -963,18 +977,7 @@ classes. ## Backend (by test class) -### AdminCasesTests - -- Admin lists every case with its owner -- Non admin is forbidden -- Admin can delete a submitted case -- Deleting a missing case is not found - -### ApplicationRuleTests - -- AanvraagStatusTag covers the published lifecycle - -### ApplicationTests +### AanvraagTests - Create then list shows a concept with step progress - Draft sync is readable back from detail @@ -993,6 +996,17 @@ classes. - AutoApprovable flips to goedgekeurd after the window - Manual case never auto advances +### AdminCasesTests + +- Admin lists every case with its owner +- Non admin is forbidden +- Admin can delete a submitted case +- Deleting a missing case is not found + +### ApplicationRuleTests + +- AanvraagStatusTag covers the published lifecycle + ### AuthzAuditTests - A denied admin action is recorded -- 2.54.0