Compare commits

...
5 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 c8029d23e2 docs: regenerate behaviour-spec.mdx
CI / changes (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 2m19s
CI / backend (pull_request) Successful in 2m9s
CI / e2e (pull_request) Successful in 3m0s
CI / semgrep (pull_request) Successful in 1m5s
CI / api-client-drift (pull_request) Successful in 1m48s
CI / changes (push) Successful in 7s
CI / lint (push) Successful in 1m15s
CI / frontend (push) Successful in 2m15s
CI / storybook-a11y (pull_request) Successful in 13m11s
CI / backend (push) Successful in 2m10s
CI / e2e (push) Successful in 2m58s
CI / semgrep (push) Successful in 1m2s
CI / api-client-drift (push) Successful in 1m49s
CI / storybook-a11y (push) Successful in 10m36s
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 <noreply@anthropic.com>
2026-09-04 15:17:10 +02:00
ehoandClaude Sonnet 5 c7aed8d308 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 <noreply@anthropic.com>
2026-09-04 15:17:06 +02:00
ehoandClaude Sonnet 5 5977efe044 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 <noreply@anthropic.com>
2026-09-04 15:17:02 +02:00
ehoandClaude Opus 5 42e7a1e927 refactor: delete shadow contracts DTOs, parse the generated shape for real (Step 2/8)
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 <noreply@anthropic.com>
2026-09-04 14:36:30 +02:00
ehoandClaude Opus 5 194cccfd02 refactor: rename Application → Aanvraag across the wire (Step 1/8)
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 <noreply@anthropic.com>
2026-09-04 14:33:16 +02:00
60 changed files with 1311 additions and 893 deletions
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; 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 { import {
WerkvoorraadItem, WerkvoorraadItem,
WerkvoorraadStatus, WerkvoorraadStatus,
@@ -18,7 +18,7 @@ import {
export class WerkvoorraadAdapter { export class WerkvoorraadAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
list(): Promise<ApplicationSummaryDto[]> { list(): Promise<AanvraagSummaryDto[]> {
return this.client.werkvoorraad(); return this.client.werkvoorraad();
} }
} }
@@ -26,7 +26,7 @@ export class WerkvoorraadAdapter {
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake']; const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
function parseWerkvoorraadStatus( function parseWerkvoorraadStatus(
s: ApplicationSummaryDto['status'] | undefined, s: AanvraagSummaryDto['status'] | undefined,
): Result<string, WerkvoorraadStatus> { ): Result<string, WerkvoorraadStatus> {
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status'); if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
switch (s.tag) { switch (s.tag) {
@@ -44,7 +44,7 @@ function parseWerkvoorraadStatus(
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> { export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object'); 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.id !== 'string') return err('werkvoorraad: missing id');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
return err(`werkvoorraad: bad type ${dto.type}`); return err(`werkvoorraad: bad type ${dto.type}`);
@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { SUBMIT_FAILED } from '@shared/application/submit'; import { SUBMIT_FAILED } from '@shared/application/submit';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter';
import { ApplicationsStore } from './applications.store'; import { AanvragenStore } from './aanvragen.store';
const summary = (id: string) => ({ const summary = (id: string) => ({
id, id,
@@ -13,20 +13,20 @@ const summary = (id: string) => ({
updatedAt: '2026-07-23T10:00:00Z', updatedAt: '2026-07-23T10:00:00Z',
}); });
function setup(adapter: Partial<ApplicationsAdapter>): ApplicationsStore { function setup(adapter: Partial<AanvragenAdapter>): AanvragenStore {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [{ provide: ApplicationsAdapter, useValue: adapter }], providers: [{ provide: AanvragenAdapter, useValue: adapter }],
}); });
// The store's own constructor kicks off `load()` (dashboard revisit refresh) — // The store's own constructor kicks off `load()` (dashboard revisit refresh) —
// give every test a `list` so that initial call has something to resolve. // 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 () => { it('loads and parses the list', async () => {
const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) }); const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) });
await store.load(); await store.load();
const s = store.applications(); const s = store.aanvragen();
expect(s.tag).toBe('Success'); expect(s.tag).toBe('Success');
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']); expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']);
}); });
@@ -41,7 +41,7 @@ describe('ApplicationsStore', () => {
await store.cancel('a'); await store.cancel('a');
expect(cancel).toHaveBeenCalledWith('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(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']);
expect(store.lastError()).toBeNull(); expect(store.lastError()).toBeNull();
}); });
@@ -55,7 +55,7 @@ describe('ApplicationsStore', () => {
await store.load(); await store.load();
await store.cancel('a'); 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(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears
expect(store.lastError()).toBe(SUBMIT_FAILED); expect(store.lastError()).toBe(SUBMIT_FAILED);
}); });
@@ -2,15 +2,12 @@ import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data'; import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter';
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
type Err = Error | undefined; 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 * 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 * RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes
* the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on * 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). * surfaces `lastError` on failure (RB-20).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ApplicationsStore { export class AanvragenStore {
private adapter = inject(ApplicationsAdapter); private adapter = inject(AanvragenAdapter);
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ 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 /** 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. */ then, this is only the message for the alert the page renders above the list. */
@@ -40,7 +37,7 @@ export class ApplicationsStore {
async load() { async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try { try {
const parsed = parseApplications(await this.adapter.list()); const parsed = parseAanvragen(await this.adapter.list());
this.state.set( this.state.set(
parsed.ok parsed.ok
? { tag: 'Success', value: parsed.value } ? { tag: 'Success', value: parsed.value }
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { SUBMIT_FAILED } from '@shared/application/submit'; 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'; import { AdminCasesStore } from './admin-cases.store';
const summary = (id: string) => ({ const summary = (id: string) => ({
@@ -14,9 +14,9 @@ const summary = (id: string) => ({
owner: '19012345601', owner: '19012345601',
}); });
function setup(adapter: Partial<ApplicationsAdapter>): AdminCasesStore { function setup(adapter: Partial<AanvragenAdapter>): AdminCasesStore {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [{ provide: ApplicationsAdapter, useValue: adapter }], providers: [{ provide: AanvragenAdapter, useValue: adapter }],
}); });
return TestBed.inject(AdminCasesStore); return TestBed.inject(AdminCasesStore);
} }
@@ -2,16 +2,13 @@ import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data'; import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter';
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
type Err = Error | undefined; type Err = Error | undefined;
/** /**
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office * Admin view of ALL cases across owners (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 * owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the * failure (RB-20). Admin delete removes any case (any owner, submitted or not — the
@@ -19,7 +16,7 @@ type Err = Error | undefined;
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AdminCasesStore { export class AdminCasesStore {
private adapter = inject(ApplicationsAdapter); private adapter = inject(AanvragenAdapter);
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly(); readonly cases = this.state.asReadonly();
@@ -34,7 +31,7 @@ export class AdminCasesStore {
async load() { async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try { try {
const parsed = parseApplications(await this.adapter.listAll()); const parsed = parseAanvragen(await this.adapter.listAll());
this.state.set( this.state.set(
parsed.ok parsed.ok
? { tag: 'Success', value: parsed.value } ? { tag: 'Success', value: parsed.value }
@@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource, map } from '@shared/application/remote-data'; import { RemoteData, fromResource, map } from '@shared/application/remote-data';
import { Aantekening } from '../domain/registration'; import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile'; 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 { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import { import {
DashboardView, DashboardView,
@@ -2,14 +2,14 @@ import { ApplicationRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 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'; import { createDraftSync, DraftSnapshot } from './draft-sync';
function setup(adapter: Partial<ApplicationsAdapter>) { function setup(adapter: Partial<AanvragenAdapter>) {
const navigate = vi.fn().mockResolvedValue(true); const navigate = vi.fn().mockResolvedValue(true);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{ provide: ApplicationsAdapter, useValue: adapter }, { provide: AanvragenAdapter, useValue: adapter },
{ provide: Router, useValue: { navigate } }, { provide: Router, useValue: { navigate } },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
], ],
@@ -4,11 +4,11 @@ import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { registerPendingSave } from '@shared/application/pending-saves'; import { registerPendingSave } from '@shared/application/pending-saves';
import type { import type {
SubmitApplicationRequest, AanvraagIndienenRequest,
SubmitApplicationResponse, AanvraagIndienenResponse,
} from '@shared/infrastructure/api-client'; } from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag'; 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'; import { findConcept, loadConcept } from './find-concept';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ /** 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. * Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
*/ */
export function createDraftSync(deps: DraftSyncDeps) { export function createDraftSync(deps: DraftSyncDeps) {
const adapter = inject(ApplicationsAdapter); const adapter = inject(AanvragenAdapter);
const router = inject(Router, { optional: true }); const router = inject(Router, { optional: true });
const route = inject(ActivatedRoute, { optional: true }); const route = inject(ActivatedRoute, { optional: true });
const active = () => deps.enabled() && !!router && !!route; 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 /** Submit through the aanvraag lifecycle: ensure the Concept exists, then
`POST /applications/{id}/submit` (server sets autoApprovable + transitions). `POST /applications/{id}/submit` (server sets autoApprovable + transitions).
Folded into a Result like the old submit-* commands. */ Folded into a Result like the old submit-* commands. */
submit(body: SubmitApplicationRequest): Promise<Result<string, SubmitApplicationResponse>> { submit(body: AanvraagIndienenRequest): Promise<Result<string, AanvraagIndienenResponse>> {
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED); return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
}, },
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest'; 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'; import { findConcept, loadConcept } from './find-concept';
// Free functions taking the adapter as a parameter (no inject()) — a plain fake // Free functions taking the adapter as a parameter (no inject()) — a plain fake
// object is enough, no Angular TestBed needed. // object is enough, no Angular TestBed needed.
function fakeAdapter(overrides: Partial<ApplicationsAdapter>): ApplicationsAdapter { function fakeAdapter(overrides: Partial<AanvragenAdapter>): AanvragenAdapter {
return overrides as ApplicationsAdapter; return overrides as AanvragenAdapter;
} }
describe('findConcept', () => { describe('findConcept', () => {
@@ -1,8 +1,5 @@
import { AanvraagType } from '@registratie/domain/aanvraag'; import { AanvraagType } from '@registratie/domain/aanvraag';
import { import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter';
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
/** /**
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs * 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. */ /** Find the user's existing Concept of a given type (at most one), if any. */
export async function findConcept( export async function findConcept(
adapter: ApplicationsAdapter, adapter: AanvragenAdapter,
type: AanvraagType, type: AanvraagType,
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
const parsed = parseApplications(await adapter.list()); const parsed = parseAanvragen(await adapter.list());
return parsed.ok return parsed.ok
? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id ? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id
: undefined; : undefined;
@@ -34,10 +31,7 @@ export async function findConcept(
export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' }; export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' };
/** Load a specific Concept by id and report whether it is still editable. */ /** Load a specific Concept by id and report whether it is still editable. */
export async function loadConcept( export async function loadConcept(adapter: AanvragenAdapter, id: string): Promise<LoadedConcept> {
adapter: ApplicationsAdapter,
id: string,
): Promise<LoadedConcept> {
try { try {
const dto = await adapter.detail(id); const dto = await adapter.detail(id);
if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' }; if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' };
@@ -41,7 +41,7 @@ export class RegistratieLookupStore {
const json = this.adresRes.value(); const json = this.adresRes.value();
if (json === undefined) return null; if (json === undefined) return null;
const parsed = parseBrpAddress(json); 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;
}, },
); );
@@ -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 };
}
@@ -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;
}
@@ -1,5 +1,14 @@
import { describe, it, expect } from 'vitest'; 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'; import { Aanvraag } from './aanvraag';
const base: Omit<Aanvraag, 'status'> = { const base: Omit<Aanvraag, 'status'> = {
@@ -114,3 +123,40 @@ describe('detailRows', () => {
expect(rows.length).toBe(5); 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]);
});
});
@@ -106,3 +106,30 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
} }
return rows; 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<AanvraagStatus['tag'], number> = {
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');
}
@@ -33,3 +33,11 @@ export interface Aantekening {
omschrijving: string; omschrijving: string;
datum: 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;
}
@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
parseAanvraagStatus, parseAanvraagStatus,
parseApplicationSummary, parseAanvraagSummary,
parseApplications, parseAanvragen,
parseApplicationDetail, parseAanvraagDetail,
} from './applications.adapter'; } from './aanvragen.adapter';
const concept = { const concept = {
id: 'a1', id: 'a1',
@@ -42,29 +42,29 @@ describe('parseAanvraagStatus', () => {
}); });
}); });
describe('parseApplicationSummary', () => { describe('parseAanvraagSummary', () => {
it('maps a valid DTO to domain', () => { 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.type).toBe('registratie');
expect(r.ok && r.value.status.tag).toBe('Concept'); expect(r.ok && r.value.status.tag).toBe('Concept');
}); });
it('rejects a bad type and non-objects', () => { it('rejects a bad type and non-objects', () => {
expect(parseApplicationSummary({ ...concept, type: 'onbekend' }).ok).toBe(false); expect(parseAanvraagSummary({ ...concept, type: 'onbekend' }).ok).toBe(false);
expect(parseApplicationSummary(null).ok).toBe(false); expect(parseAanvraagSummary(null).ok).toBe(false);
expect(parseApplicationSummary({ ...concept, id: 42 }).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', () => { it('parses a list and fails fast on a bad element', () => {
expect(parseApplications([concept, concept]).ok).toBe(true); expect(parseAanvragen([concept, concept]).ok).toBe(true);
expect(parseApplications([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false); expect(parseAanvragen([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false);
expect(parseApplications({}).ok).toBe(false); expect(parseAanvragen({}).ok).toBe(false);
}); });
it('carries the opaque draft through detail', () => { 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'); expect(r.ok && (r.value.draft as { beroep: string }).beroep).toBe('arts');
}); });
}); });
@@ -3,11 +3,11 @@ import { Result, ok, err } from '@shared/kernel/fp';
import { import {
ApiClient, ApiClient,
AanvraagStatusDto, AanvraagStatusDto,
ApplicationSummaryDto, AanvraagSummaryDto,
ApplicationDetailDto, AanvraagDetailDto,
DraftSyncRequest, DraftSyncRequest,
SubmitApplicationRequest, AanvraagIndienenRequest,
SubmitApplicationResponse, AanvraagIndienenResponse,
} from '@shared/infrastructure/api-client'; } from '@shared/infrastructure/api-client';
import { import {
Aanvraag, Aanvraag,
@@ -19,21 +19,21 @@ import {
/** /**
* Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place * 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 * 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 * orchestrates optimistically. The untrusted response is validated + mapped to
* domain by the hand-written parse* boundary below. * domain by the hand-written parse* boundary below.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ApplicationsAdapter { export class AanvragenAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
/** The dashboard's application list (raw DTOs; the store parses at the boundary). */ /** The dashboard's aanvraag list (raw DTOs; the store parses at the boundary). */
list(): Promise<ApplicationSummaryDto[]> { list(): Promise<AanvraagSummaryDto[]> {
return this.client.applicationsAll(); return this.client.aanvragenAll();
} }
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ /** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */
listAll(): Promise<ApplicationSummaryDto[]> { listAll(): Promise<AanvraagSummaryDto[]> {
return this.client.casesAll(); return this.client.casesAll();
} }
@@ -42,26 +42,26 @@ export class ApplicationsAdapter {
return this.client.cases(id); return this.client.cases(id);
} }
detail(id: string): Promise<ApplicationDetailDto> { detail(id: string): Promise<AanvraagDetailDto> {
return this.client.applicationsGET(id); return this.client.aanvragenGET(id);
} }
/** Create a Concept for a wizard type; resolves to the new aanvraag id. */ /** Create a Concept for a wizard type; resolves to the new aanvraag id. */
create(type: AanvraagType): Promise<string> { create(type: AanvraagType): Promise<string> {
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. */ /** Draft sync per step (idempotent). Keep it debounced at the call site — it is chatty. */
syncDraft(id: string, body: DraftSyncRequest): Promise<void> { syncDraft(id: string, body: DraftSyncRequest): Promise<void> {
return this.client.applicationsPUT(id, body); return this.client.aanvragenPUT(id, body);
} }
/** Cancel a Concept (cascades to its unlinked documents server-side). */ /** Cancel a Concept (cascades to its unlinked documents server-side). */
cancel(id: string): Promise<void> { cancel(id: string): Promise<void> {
return this.client.applicationsDELETE(id); return this.client.aanvragenDELETE(id);
} }
submit(id: string, body: SubmitApplicationRequest): Promise<SubmitApplicationResponse> { submit(id: string, body: AanvraagIndienenRequest): Promise<AanvraagIndienenResponse> {
return this.client.submit(id, body); return this.client.submit(id, body);
} }
} }
@@ -101,7 +101,7 @@ export function parseAanvraagStatus(
} }
} }
function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> { function parseCommon(dto: AanvraagSummaryDto): Result<string, Aanvraag> {
if (typeof dto.id !== 'string') return err('aanvraag: missing id'); if (typeof dto.id !== 'string') return err('aanvraag: missing id');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
return err(`aanvraag: bad type ${dto.type}`); return err(`aanvraag: bad type ${dto.type}`);
@@ -121,25 +121,25 @@ function parseCommon(dto: ApplicationSummaryDto): Result<string, Aanvraag> {
}); });
} }
export function parseApplicationSummary(json: unknown): Result<string, Aanvraag> { export function parseAanvraagSummary(json: unknown): Result<string, Aanvraag> {
if (typeof json !== 'object' || json === null) return err('aanvraag: not an object'); 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<string, Aanvraag[]> { export function parseAanvragen(json: unknown): Result<string, Aanvraag[]> {
if (!Array.isArray(json)) return err('aanvragen: not an array'); if (!Array.isArray(json)) return err('aanvragen: not an array');
const out: Aanvraag[] = []; const out: Aanvraag[] = [];
for (const item of json) { for (const item of json) {
const parsed = parseApplicationSummary(item); const parsed = parseAanvraagSummary(item);
if (!parsed.ok) return parsed; if (!parsed.ok) return parsed;
out.push(parsed.value); out.push(parsed.value);
} }
return ok(out); return ok(out);
} }
export function parseApplicationDetail(json: unknown): Result<string, AanvraagDetail> { export function parseAanvraagDetail(json: unknown): Result<string, AanvraagDetail> {
if (typeof json !== 'object' || json === null) return err('aanvraag: not an object'); 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; 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 });
} }
@@ -1,8 +1,15 @@
import { Injectable, inject, resource } from '@angular/core'; import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; 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'; 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 * Infrastructure adapter for the BRP address lookup, reached only through our own
* ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET * ("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 /** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a
valid outcome (gevonden: false), not a malformed response. ponytail: hand-written; valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;
reach for a schema lib once the contract count grows. */ reach for a schema lib once the contract count grows. */
export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> { export function parseBrpAddress(json: unknown): Result<string, BrpAddress> {
if (typeof json !== 'object' || json === null) return err('brp-address: not an object'); if (typeof json !== 'object' || json === null) return err('brp-address: not an object');
const dto = json as Partial<BrpAddressDto>; const dto = json as Partial<BrpAddressDto>;
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden'); if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
if (dto.gevonden) { if (!dto.gevonden) return ok({ gevonden: false });
const a = dto.adres; const a = dto.adres;
if ( if (
!a || !a ||
typeof a.straat !== 'string' || typeof a.straat !== 'string' ||
typeof a.postcode !== 'string' || typeof a.postcode !== 'string' ||
typeof a.woonplaats !== 'string' typeof a.woonplaats !== 'string'
) { ) {
return err('brp-address: missing/invalid adres'); 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 },
});
} }
@@ -35,4 +35,43 @@ describe('parseDashboardView (trust boundary)', () => {
parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok, parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok,
).toBe(false); ).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);
});
}); });
@@ -1,18 +1,24 @@
import { Injectable, inject, resource } from '@angular/core'; import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { import {
ApiClient,
DashboardViewDto, DashboardViewDto,
RegistrationDto,
RegistrationStatusDto,
PersonDto,
} from '@shared/infrastructure/api-client';
import {
Registration,
RegistrationStatus,
HerregistratieDecisions, HerregistratieDecisions,
} from '@registratie/contracts/dashboard-view.dto'; } from '@registratie/domain/registration';
import { Registration } from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person'; import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile'; 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. * 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 * Lives HERE, not in a `contracts/*.dto.ts` file, because it references domain
* stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001). * types — that split is the decoupling seam (CLAUDE.md §1, ADR-0001).
*/ */
export interface DashboardView { export interface DashboardView {
profile: BigProfile; profile: BigProfile;
@@ -31,17 +37,80 @@ export class DashboardViewAdapter {
// The value is still untrusted JSON — parseDashboardView validates it at the // The value is still untrusted JSON — parseDashboardView validates it at the
// boundary and maps DTO → domain before the app uses it. // 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 <app-async>) covers the UX today, so backoff stays unbuilt.
dashboardViewResource() { dashboardViewResource() {
return resource({ loader: () => this.client.dashboardView() }); 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<string, RegistrationStatus> {
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<string, Registration> {
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<string, Person> {
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 * 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 * onto our own domain model. Hand-written on purpose — no Zod for a single
@@ -51,43 +120,17 @@ export function parseDashboardView(json: unknown): Result<string, DashboardView>
if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object'); if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object');
const dto = json as Partial<DashboardViewDto>; const dto = json as Partial<DashboardViewDto>;
const reg = dto.registration; const registration = parseRegistration(dto.registration);
if ( if (!registration.ok) return registration;
!reg || const person = parsePerson(dto.person);
typeof reg.bigNummer !== 'string' || if (!person.ok) return person;
!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 d = dto.decisions; const d = dto.decisions;
if (!d || typeof d.eligibleForHerregistratie !== 'boolean') { if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {
return err('dashboard-view: missing/invalid decisions'); 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({ return ok({
profile: { registration, person: persoon }, profile: { registration: registration.value, person: person.value },
decisions: { decisions: {
eligibleForHerregistratie: d.eligibleForHerregistratie, eligibleForHerregistratie: d.eligibleForHerregistratie,
herregistratieReason: d.herregistratieReason, herregistratieReason: d.herregistratieReason,
@@ -1,4 +1,5 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
@@ -6,7 +7,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.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 { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view'; import { detailRows } from '@registratie/domain/aanvraag-view';
@@ -29,9 +30,9 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
heading="Aanvraag" heading="Aanvraag"
backLink="/dashboard" backLink="/dashboard"
> >
<app-async [data]="store.applications()"> <app-async [data]="store.aanvragen()">
<ng-template appAsyncLoaded> <ng-template appAsyncLoaded>
@if (applications(); as list) { @if (aanvragen(); as list) {
@let a = find(list); @let a = find(list);
@if (a) { @if (a) {
<app-data-block <app-data-block
@@ -60,15 +61,13 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
`, `,
}) })
export class AanvraagDetailPage { export class AanvraagDetailPage {
protected store = inject(ApplicationsStore); protected store = inject(AanvragenStore);
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? ''; private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id); protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows; protected rows = detailRows;
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */ /** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
protected readonly applications = computed(() => { so the Success value is unwrapped here instead of through `let-`. */
const rd = this.store.applications(); protected readonly aanvragen = computed(() => successOf(this.store.aanvragen()));
return rd.tag === 'Success' ? rd.value : undefined;
});
} }
+23 -321
View File
@@ -1,47 +1,25 @@
import { Component, computed, inject } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { MijnRegistratieSection } from './dashboard/mijn-registratie.section';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { SpecialismenSection } from './dashboard/specialismen.section';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component'; import { BeheerLinksSection } from './dashboard/beheer-links.section';
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 { ApplicationsStore } from '@registratie/application/applications.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';
/** Page:"Mijn overzicht" — the portal home, following the NL Design System /** Page: "Mijn overzicht" — the portal home, following the NL Design System "Mijn
"Mijn omgeving" pattern (side nav +"Wat moet ik regelen" +"Mijn zaken"). */ omgeving" pattern. Composition only: each section below answers its own data
question (own store, own async state) — see `ui/dashboard/*.section.ts`. */
@Component({ @Component({
selector: 'app-dashboard-page', selector: 'app-dashboard-page',
imports: [ imports: [
PageShellComponent, PageShellComponent,
HeadingComponent, MijnAanvragenSection,
AlertComponent, WatMoetIkRegelenSection,
SkeletonComponent, MijnRegistratieSection,
DataRowComponent, SpecialismenSection,
DataBlockComponent, WatWiltUDoenSection,
TaskListComponent, BeheerLinksSection,
ApplicationListComponent,
ApplicationLinkComponent,
...ASYNC,
RegistrationSummaryComponent,
RegistrationTableComponent,
AanvraagBlockComponent,
], ],
template: ` template: `
<app-page-shell <app-page-shell
@@ -51,290 +29,14 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken." intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
> >
<div class="app-stack"> <div class="app-stack">
@if (cancelError(); as err) { <app-mijn-aanvragen-section />
<app-alert type="error">{{ err }}</app-alert> <app-wat-moet-ik-regelen-section />
} <app-mijn-registratie-section />
@if (aanvragen().length) { <app-specialismen-section />
<section> <app-wat-wilt-u-doen-section />
@for (a of concepten(); track a.id) { <app-beheer-links-section />
<app-aanvraag-block
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[aanvraag]="a"
(resume)="resume(a)"
(cancel)="cancelAanvraag(a)"
/>
}
@if (ingediend().length) {
<app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen"
>Mijn aanvragen</app-heading
>
<app-application-list>
@for (a of ingediend(); track a.id) {
@let row = submittedRow(a);
<li
app-application-link
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[heading]="row.heading"
[subtitle]="row.subtitle"
[status]="row.status"
[to]="'/aanvraag/' + a.id"
></li>
}
</app-application-list>
}
</section>
}
@if (store.pendingHerregistratie()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie"
>Uw herregistratie-aanvraag is in behandeling.</app-alert
>
}
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (profile(); as p) {
@let tasks = tasksFor(p.registration);
<section>
@if (tasks.length) {
<app-task-list
class="app-section"
i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen"
[tasks]="tasks"
/>
} @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
>Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
}
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
>
<div class="app-section">
<app-registration-summary [reg]="p.registration" />
</div>
<app-data-block
class="app-section"
i18n-heading="@@dashboard.persoonsgegevens"
heading="Persoonsgegevens (BRP)"
>
<div
app-data-row
i18n-key="@@dashboard.straat"
key="Straat"
[value]="p.person.adres.straat"
></div>
<div
app-data-row
i18n-key="@@dashboard.postcode"
key="Postcode"
[value]="p.person.adres.postcode"
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="p.person.adres.woonplaats"
></div>
</app-data-block>
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
</ng-template>
</app-async>
<section>
<app-heading [level]="2" i18n="@@dashboard.specialismen"
>Specialismen en aantekeningen</app-heading
>
<div class="app-section">
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
<ng-template appAsyncLoaded>
@if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">
U heeft nog geen specialismen of aantekeningen.
</p>
</ng-template>
</app-async>
</div>
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section">
@for (a of acties(); track a.to) {
<li
app-application-link
[heading]="a.titel"
[subtitle]="a.tekst"
[cta]="a.actie"
[to]="a.to"
></li>
}
</app-application-list>
</section>
@if (adminLinks().length) {
<section>
<app-heading [level]="2" i18n="@@dashboard.beheer">Beheer</app-heading>
<app-application-list class="app-section">
@for (link of adminLinks(); track link.to) {
<li
app-application-link
[heading]="link.label"
[subtitle]="link.description"
[to]="link.to"
></li>
}
</app-application-list>
</section>
}
</div> </div>
</app-page-shell> </app-page-shell>
`, `,
}) })
export class DashboardPage { export class DashboardPage {}
protected store = inject(BigProfileStore);
private apps = inject(ApplicationsStore);
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 applications, sorted Concept → In behandeling → resolved. Empty →
the"Mijn aanvragen" section is hidden (see template). */
protected aanvragen = computed<Aanvraag[]>(() => {
const rd = this.apps.applications();
if (rd.tag !== 'Success') return [];
const order: Record<Aanvraag['status']['tag'], number> = {
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<AanvraagType, string> = {
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 `<app-async>` loaded slot — `<ng-template>`'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),
),
);
}
@@ -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) {
<section>
<app-heading [level]="2" i18n="@@dashboard.beheer">Beheer</app-heading>
<app-application-list class="app-section">
@for (link of adminLinks(); track link.to) {
<li
app-application-link
[heading]="link.label"
[subtitle]="link.description"
[to]="link.to"
></li>
}
</app-application-list>
</section>
}
`,
})
export class BeheerLinksSection {
private access = inject(AccessStore);
protected adminLinks = computed(() => ADMIN_LINKS.filter((l) => this.access.can(l.cap)));
}
@@ -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<Aanvraag, 'status'>;
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<Error | undefined, Aanvraag[]>, lastError = '') {
return {
aanvragen: () => aanvragen,
reload: () => {},
cancel: async () => {},
lastError: () => lastError,
};
}
const meta: Meta<MijnAanvragenSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Aanvragen',
component: MijnAanvragenSection,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<MijnAanvragenSection>;
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'))) }],
}),
],
};
@@ -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) {
<app-alert type="error">{{ err }}</app-alert>
}
<app-async [data]="store.aanvragen()" (retryClicked)="store.reload()">
<ng-template appAsyncLoaded>
@if (aanvragen().length) {
<section>
@for (a of concepten_(); track a.id) {
<app-aanvraag-block
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[aanvraag]="a"
(resume)="resume(a)"
(cancel)="cancel(a)"
/>
}
@if (ingediend_().length) {
<app-heading [level]="2" class="app-section" i18n="@@dashboard.mijnAanvragen"
>Mijn aanvragen</app-heading
>
<app-application-list>
@for (a of ingediend_(); track a.id) {
@let row = submittedRow(a);
<li
app-application-link
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[heading]="row.heading"
[subtitle]="row.subtitle"
[status]="row.status"
[to]="'/aanvraag/' + a.id"
></li>
}
</app-application-list>
}
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="2" />
</ng-template>
</app-async>
`,
})
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<Aanvraag[]>(() => {
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<AanvraagType, string> = {
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());
}
@@ -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<Error | undefined, BigProfile>) {
return { profile: () => profileRd, reloadProfile: () => {} };
}
const meta: Meta<MijnRegistratieSection> = {
title: 'Domein/Registratie/Dashboard/Mijn Registratie',
component: MijnRegistratieSection,
};
export default meta;
type Story = StoryObj<MijnRegistratieSection>;
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'))) }],
}),
],
};
@@ -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: `
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (profile(); as p) {
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
>
<div class="app-section">
<app-registration-summary [reg]="p.registration" />
</div>
<app-data-block
class="app-section"
i18n-heading="@@dashboard.persoonsgegevens"
heading="Persoonsgegevens (BRP)"
>
<div
app-data-row
i18n-key="@@dashboard.straat"
key="Straat"
[value]="p.person.adres.straat"
></div>
<div
app-data-row
i18n-key="@@dashboard.postcode"
key="Postcode"
[value]="p.person.adres.postcode"
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="p.person.adres.woonplaats"
></div>
</app-data-block>
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="4" />
</ng-template>
</app-async>
`,
})
export class MijnRegistratieSection {
protected store = inject(BigProfileStore);
protected profile = () => successOf(this.store.profile());
}
@@ -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<Error | undefined, Aantekening[]>) {
return { aantekeningen: () => aantekeningen, reloadAantekeningen: () => {} };
}
const meta: Meta<SpecialismenSection> = {
title: 'Domein/Registratie/Dashboard/Specialismen',
component: SpecialismenSection,
};
export default meta;
type Story = StoryObj<SpecialismenSection>;
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'))) }],
}),
],
};
@@ -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: `
<section>
<app-heading [level]="2" i18n="@@dashboard.specialismen"
>Specialismen en aantekeningen</app-heading
>
<div class="app-section">
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
<ng-template appAsyncLoaded>
@if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
<ng-template appAsyncEmpty>
<p class="app-text-subtle" i18n="@@dashboard.geenSpecialismen">
U heeft nog geen specialismen of aantekeningen.
</p>
</ng-template>
</app-async>
</div>
</section>
`,
})
export class SpecialismenSection {
protected store = inject(BigProfileStore);
protected aantekeningen = () => successOf(this.store.aantekeningen());
}
@@ -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()) {
<app-alert type="info" i18n="@@dashboard.pendingHerregistratie"
>Uw herregistratie-aanvraag is in behandeling.</app-alert
>
}
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (tasks(); as t) {
<section>
@if (t.length) {
<app-task-list
i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen"
[tasks]="t"
/>
} @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
>Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
}
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="2" />
</ng-template>
</app-async>
`,
})
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;
});
}
@@ -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: `
<section>
<app-heading [level]="2" i18n="@@dashboard.watWiltUDoen">Wat wilt u doen?</app-heading>
<app-application-list class="app-section">
@for (a of acties(); track a.to) {
<li
app-application-link
[heading]="a.titel"
[subtitle]="a.tekst"
[cta]="a.actie"
[to]="a.to"
></li>
}
</app-application-list>
</section>
`,
})
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),
),
);
}
@@ -1,4 +1,5 @@
import { Component, computed, inject } from '@angular/core'; 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 { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
@@ -41,9 +42,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
export class RegistrationDetailPage { export class RegistrationDetailPage {
protected store = inject(BigProfileStore); protected store = inject(BigProfileStore);
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */ /** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
protected readonly profile = computed(() => { so the Success value is unwrapped here instead of through `let-`. */
const rd = this.store.profile(); protected readonly profile = computed(() => successOf(this.store.profile()));
return rd.tag === 'Success' ? rd.value : undefined;
});
} }
@@ -99,19 +99,19 @@ public sealed record AanvraagStatusDto(
bool? Manual = null, bool? Manual = null,
string? Reden = null); string? Reden = null);
public sealed record ApplicationSummaryDto( public sealed record AanvraagSummaryDto(
string Id, string Type, AanvraagStatusDto Status, string Id, string Type, AanvraagStatusDto Status,
IReadOnlyList<string> DocumentIds, IReadOnlyList<string> DocumentIds,
string CreatedAt, string UpdatedAt, string? SubmittedAt, string CreatedAt, string UpdatedAt, string? SubmittedAt,
string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it
public sealed record ApplicationDetailDto( public sealed record AanvraagDetailDto(
string Id, string Type, AanvraagStatusDto Status, string Id, string Type, AanvraagStatusDto Status,
System.Text.Json.JsonElement? Draft, System.Text.Json.JsonElement? Draft,
IReadOnlyList<string> DocumentIds, IReadOnlyList<string> DocumentIds,
string CreatedAt, string UpdatedAt, string? SubmittedAt); string CreatedAt, string UpdatedAt, string? SubmittedAt);
public sealed record CreateApplicationRequest(string Type); public sealed record CreateAanvraagRequest(string Type);
public sealed record DraftSyncRequest( public sealed record DraftSyncRequest(
System.Text.Json.JsonElement Draft, int StepIndex, int StepCount, 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. // Submit carries only the fields the server re-validates per wizard type.
// AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by // AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by
// IntakePolicy.RejectIncompleteScholing's caller), null for the others. // IntakePolicy.RejectIncompleteScholing's caller), null for the others.
public sealed record SubmitApplicationRequest( public sealed record AanvraagIndienenRequest(
string? DiplomaHerkomst = null, int? Uren = null, string? DiplomaHerkomst = null, int? Uren = null,
IReadOnlyList<DocumentRefDto>? Documents = null, IReadOnlyList<DocumentRefDto>? Documents = null,
bool? AanvullendeScholing = null, int? ScholingPunten = 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. --- // --- 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 BeoordelingDecisionsDto(bool CanBesluiten);
public sealed record BeoordelingViewDto( public sealed record BeoordelingViewDto(
ApplicationSummaryDto Aanvraag, AanvraagSummaryDto Aanvraag,
IReadOnlyList<BeoordelingDocumentDto> Documenten, IReadOnlyList<BeoordelingDocumentDto> Documenten,
BeoordelingDecisionsDto Decisions); BeoordelingDecisionsDto Decisions);
@@ -65,7 +65,7 @@ public static class Mappers
/// reads it past that point; see <c>AanvraagMapper.ApplyTo</c>'s Submitted branch).</summary> /// reads it past that point; see <c>AanvraagMapper.ApplyTo</c>'s Submitted branch).</summary>
private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null; 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.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); 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 /// 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 /// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner
/// list cannot be added that forgets to. /// 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) }; 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.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
} }
@@ -43,7 +43,7 @@ public static class DocumentStore
/// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer, /// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer,
/// a different Dutch identifier scheme). Previously this constant reused that BigNummer value /// 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 /// 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 /// 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 /// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture
/// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen"). /// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen").
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an <see cref="Aanvraag"/> /// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an <see cref="Aanvraag"/>
/// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak) /// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak)
/// replace the local SQLite store <em>behind the same <see cref="ApplicationSummaryDto"/> /// replace the local SQLite store <em>behind the same <see cref="AanvraagSummaryDto"/>
/// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001). /// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001).
/// ///
/// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c> /// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c>
@@ -20,7 +20,7 @@ public interface IZaakSource
{ {
/// <summary>Every case across every owner, newest-first (the admin cross-owner list, /// <summary>Every case across every owner, newest-first (the admin cross-owner list,
/// WP-36) — cases:manage only, deliberately NOT citizen-scoped.</summary> /// WP-36) — cases:manage only, deliberately NOT citizen-scoped.</summary>
IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now); IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now);
/// <summary> /// <summary>
/// Only <paramref name="caller"/>'s own cases (WP-53) — the citizen-scoped counterpart of /// Only <paramref name="caller"/>'s own cases (WP-53) — the citizen-scoped counterpart of
@@ -29,7 +29,7 @@ public interface IZaakSource
/// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen /// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
/// can never see another citizen's zaken. /// can never see another citizen's zaken.
/// </summary> /// </summary>
IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now); IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
/// <summary> /// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is /// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
@@ -12,12 +12,12 @@ namespace BigRegister.Api.Data;
/// </summary> /// </summary>
public sealed class LocalZaakSource : IZaakSource public sealed class LocalZaakSource : IZaakSource
{ {
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
/// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /applications</c> used to compute /// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /aanvragen</c> used to compute
/// inline before it was routed through this seam.</summary> /// inline before it was routed through this seam.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
ApplicationStore.List(caller.Bsn) ApplicationStore.List(caller.Bsn)
.OrderByDescending(a => a.UpdatedAt) .OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList(); .Select(a => a.ToSummaryDto(now)).ToList();
@@ -9,7 +9,7 @@ public sealed record FeatureFlagDef(string Key, string Description, bool Default
public static class FeatureFlags public static class FeatureFlags
{ {
/// Whether self-service registration (inschrijving) is open. When off, the FE hides the /// 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 const string InschrijvingOpen = "inschrijving-open";
public static readonly IReadOnlyList<FeatureFlagDef> Catalog = new[] public static readonly IReadOnlyList<FeatureFlagDef> Catalog = new[]
@@ -6,7 +6,7 @@ namespace BigRegister.Domain.Intake;
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback /// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the /// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the
/// backend re-validating it as the authority on submit (WP-69) — /// backend re-validating it as the authority on submit (WP-69) —
/// <c>POST /applications/{id}/submit</c> (intake-typed aanvragen only) calls it before /// <c>POST /aanvragen/{id}/submit</c> (intake-typed aanvragen only) calls it before
/// writing anything, and a violation 400s (<c>ProblemDetails</c>), never silently accepts /// writing anything, and a violation 400s (<c>ProblemDetails</c>), never silently accepts
/// an incomplete answer. /// an incomplete answer.
/// </summary> /// </summary>
+15 -15
View File
@@ -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 // 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 (BSN-filtered) too, closing the last "reads a static store directly" gap
// openzaak-integration.md's ACL caveat used to flag for this endpoint. // 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)); 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 ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a
? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
: Results.NotFound()) : Results.NotFound())
.Produces<ApplicationDetailDto>() .Produces<AanvraagDetailDto>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- writes --- // --- 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. // Feature flag (WP-47): self-service registration can be closed by an admin.
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen)) if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
@@ -364,13 +364,13 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
return Results.Problem( return Results.Problem(
detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.",
statusCode: StatusCodes.Status409Conflict); 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<ApplicationDetailDto>(StatusCodes.Status201Created) .Produces<AanvraagDetailDto>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty). // 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; var owner = ctx.Zorgverlener().Bsn;
// A citizen may only reference their own uploads in a draft — reject before the sync // 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 // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
// be withdrawn (out of scope — no "intrekken"). // 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); var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
if (a is null) return Results.NotFound(); 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 // Submit runs the server-owned rules, sets autoApprovable, and transitions the
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. // 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); var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
if (existing is null) return Results.NotFound(); 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<SubmitApplicationResponse>() .Produces<AanvraagIndienenResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound); .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, () => api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
.Gate("CasesAdmin") .Gate("CasesAdmin")
.Produces<List<ApplicationSummaryDto>>() .Produces<List<AanvraagSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated // 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 --- // --- writes ---
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing // 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, () => api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
{ {
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); 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") .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
.ToList()))) .ToList())))
.Gate("Beoordelen") .Gate("Beoordelen")
.Produces<List<ApplicationSummaryDto>>() .Produces<List<AanvraagSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
// --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording // --- 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: // 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) // 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 // 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) => api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) =>
Beoordelen(ctx, $"aanvraag/{id}", () => Beoordelen(ctx, $"aanvraag/{id}", () =>
{ {
@@ -18,9 +18,9 @@ public sealed record ZgwPage<T>(
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50 /// The <see cref="IZaakSource"/> 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 /// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the
/// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's /// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's
/// human zaaktype label, which isn't a value <see cref="ApplicationSummaryDto.Type"/>'s /// human zaaktype label, which isn't a value <see cref="AanvraagSummaryDto.Type"/>'s
/// contract accepts; see <see cref="AanvraagTypeFor"/>), and maps into /// contract accepts; see <see cref="AanvraagTypeFor"/>), and maps into
/// <see cref="ApplicationSummaryDto"/> via <see cref="ZgwZaakMapper"/>. Creates a zaak + /// <see cref="AanvraagSummaryDto"/> via <see cref="ZgwZaakMapper"/>. Creates a zaak +
/// status + rol for a just-submitted aanvraag. Selected only when <c>Zgw:Enabled=true</c>; /// status + rol for a just-submitted aanvraag. Selected only when <c>Zgw:Enabled=true</c>;
/// the default stays <see cref="LocalZaakSource"/>. /// the default stays <see cref="LocalZaakSource"/>.
/// ///
@@ -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 // 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 // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the
// default and this blocking call shows up under load. // default and this blocking call shows up under load.
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) =>
ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult(); ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult();
/// <summary>WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param /// <summary>WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param
/// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the /// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the
/// system-level one <see cref="ListCases"/> uses.</summary> /// system-level one <see cref="ListCases"/> uses.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult(); ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync(string? bsn, CallerIdentity? caller) private async Task<IReadOnlyList<AanvraagSummaryDto>> ListCasesAsync(string? bsn, CallerIdentity? caller)
{ {
var url = $"{options.ZrcBaseUrl}/zaken"; var url = $"{options.ZrcBaseUrl}/zaken";
if (bsn is not null) if (bsn is not null)
@@ -55,7 +55,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
} }
/// <summary>Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse): /// <summary>Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse):
/// <c>ApplicationSummaryDto.Type</c>'s contract is the internal aanvraag-type key (e.g. /// <c>AanvraagSummaryDto.Type</c>'s contract is the internal aanvraag-type key (e.g.
/// "herregistratie" — what <see cref="LocalZaakSource"/>/<c>Mappers.ToSummaryDto</c> send, /// "herregistratie" — what <see cref="LocalZaakSource"/>/<c>Mappers.ToSummaryDto</c> send,
/// and what the FE's <c>AANVRAAG_TYPES</c> trust boundary accepts), NOT OpenZaak's human /// and what the FE's <c>AANVRAAG_TYPES</c> trust boundary accepts), NOT OpenZaak's human
/// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round /// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round
@@ -6,7 +6,7 @@ namespace BigRegister.Api.Zgw;
/// <summary> /// <summary>
/// The subset of a ZGW Zaak (Zaken API / ZRC) the read slice needs. The full resource has /// 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 <see cref="ApplicationSummaryDto"/>. Note the /// dozens of fields; we bind only what maps to <see cref="AanvraagSummaryDto"/>. Note the
/// two ZGW traits that force an anti-corruption layer: <see cref="Url"/> is the resource's /// two ZGW traits that force an anti-corruption layer: <see cref="Url"/> is the resource's
/// identity (not a bare id), and <see cref="Zaaktype"/> is a URL <em>into another service</em> /// identity (not a bare id), and <see cref="Zaaktype"/> is a URL <em>into another service</em>
/// (Catalogi/ZTC) that must be resolved to a human label. /// (Catalogi/ZTC) that must be resolved to a human label.
@@ -20,7 +20,7 @@ public sealed record ZgwZaak(
[property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum); [property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum);
/// <summary> /// <summary>
/// Anti-corruption map: ZGW Zaak → the existing <see cref="ApplicationSummaryDto"/> the FE /// Anti-corruption map: ZGW Zaak → the existing <see cref="AanvraagSummaryDto"/> the FE
/// already renders (WP-49). This is where "URL as identity" and the cross-service zaaktype /// 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. /// join get flattened away, so nothing downstream (the FE) sees ZGW shapes.
/// </summary> /// </summary>
@@ -29,7 +29,7 @@ public static class ZgwZaakMapper
/// <summary>Last path segment of a ZGW resource URL — the uuid that identifies it.</summary> /// <summary>Last path segment of a ZGW resource URL — the uuid that identifies it.</summary>
public static string Uuid(string url) => url.TrimEnd('/').Split('/').Last(); 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 // 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 // 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 created = Iso(z.Registratiedatum ?? z.Startdatum);
var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum); var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
return new ApplicationSummaryDto( return new AanvraagSummaryDto(
Id: Uuid(z.Url), Id: Uuid(z.Url),
Type: zaaktypeLabel, Type: zaaktypeLabel,
Status: status, Status: status,
+109 -109
View File
@@ -425,7 +425,7 @@
} }
} }
}, },
"/api/v1/applications": { "/api/v1/aanvragen": {
"get": { "get": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -438,7 +438,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/ApplicationSummaryDto" "$ref": "#/components/schemas/AanvraagSummaryDto"
} }
} }
} }
@@ -454,7 +454,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateApplicationRequest" "$ref": "#/components/schemas/CreateAanvraagRequest"
} }
} }
}, },
@@ -466,7 +466,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/ApplicationDetailDto" "$ref": "#/components/schemas/AanvraagDetailDto"
} }
} }
} }
@@ -484,7 +484,7 @@
} }
} }
}, },
"/api/v1/applications/{id}": { "/api/v1/aanvragen/{id}": {
"get": { "get": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -505,7 +505,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/ApplicationDetailDto" "$ref": "#/components/schemas/AanvraagDetailDto"
} }
} }
} }
@@ -592,7 +592,7 @@
} }
} }
}, },
"/api/v1/applications/{id}/submit": { "/api/v1/aanvragen/{id}/submit": {
"post": { "post": {
"tags": [ "tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -611,7 +611,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/SubmitApplicationRequest" "$ref": "#/components/schemas/AanvraagIndienenRequest"
} }
} }
}, },
@@ -623,7 +623,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/SubmitApplicationResponse" "$ref": "#/components/schemas/AanvraagIndienenResponse"
} }
} }
} }
@@ -667,7 +667,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/ApplicationSummaryDto" "$ref": "#/components/schemas/AanvraagSummaryDto"
} }
} }
} }
@@ -766,7 +766,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/ApplicationSummaryDto" "$ref": "#/components/schemas/AanvraagSummaryDto"
} }
} }
} }
@@ -1484,57 +1484,7 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"AanvraagStatusDto": { "AanvraagDetailDto": {
"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": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1573,7 +1523,83 @@
}, },
"additionalProperties": false "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", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1613,6 +1639,24 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"AdresDto": {
"type": "object",
"properties": {
"straat": {
"type": "string",
"nullable": true
},
"postcode": {
"type": "string",
"nullable": true
},
"woonplaats": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"AuthzAuditDto": { "AuthzAuditDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1674,7 +1718,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"aanvraag": { "aanvraag": {
"$ref": "#/components/schemas/ApplicationSummaryDto" "$ref": "#/components/schemas/AanvraagSummaryDto"
}, },
"documenten": { "documenten": {
"type": "array", "type": "array",
@@ -1860,7 +1904,7 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CreateApplicationRequest": { "CreateAanvraagRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
"type": { "type": {
@@ -2678,50 +2722,6 @@
}, },
"additionalProperties": false "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": { "UploadCategoriesDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8,27 +8,27 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
private async Task<ApplicationDetailDto> Create(string type = "registratie") private async Task<AanvraagDetailDto> Create(string type = "registratie")
{ {
// WP-35: one Concept per type is now server-enforced, and these tests share one DB // 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. // (IClassFixture). Clear any leftover Concept so each test starts from a clean slate.
var existing = await List(); var existing = await List();
Assert.NotNull(existing); Assert.NotNull(existing);
foreach (var s in existing.Where(x => x.Status.Tag == "Concept")) foreach (var s in existing.Where(x => x.Status.Tag == "Concept"))
await _client.DeleteAsync($"/api/v1/applications/{s.Id}"); await _client.DeleteAsync($"/api/v1/aanvragen/{s.Id}");
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); Assert.Equal(HttpStatusCode.Created, res.StatusCode);
var created = await res.Content.ReadFromJsonAsync<ApplicationDetailDto>(); var created = await res.Content.ReadFromJsonAsync<AanvraagDetailDto>();
Assert.NotNull(created); Assert.NotNull(created);
return created; return created;
} }
private Task<List<ApplicationSummaryDto>?> List() => private Task<List<AanvraagSummaryDto>?> List() =>
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications"); _client.GetFromJsonAsync<List<AanvraagSummaryDto>>("/api/v1/aanvragen");
// --- Lifecycle over HTTP --- // --- 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() public async Task Create_then_list_shows_a_concept_with_step_progress()
{ {
var a = await Create(); 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 }); new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
var list = await List(); var list = await List();
@@ -51,10 +51,10 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Draft_sync_is_readable_back_from_detail() public async Task Draft_sync_is_readable_back_from_detail()
{ {
var a = await Create(); 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 }); new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}"); var detail = await _client.GetFromJsonAsync<AanvraagDetailDto>($"/api/v1/aanvragen/{a.Id}");
Assert.NotNull(detail); Assert.NotNull(detail);
Assert.NotNull(detail.Draft); Assert.NotNull(detail.Draft);
Assert.Equal("verpleegkundige", detail.Draft.Value.GetProperty("beroep").GetString()); 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() public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
{ {
var a = await Create("registratie"); 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(); res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.StartsWith("BIG-2026-", body.Referentie); Assert.StartsWith("BIG-2026-", body.Referentie);
Assert.Equal("InBehandeling", body.Status.Tag); Assert.Equal("InBehandeling", body.Status.Tag);
Assert.False(body.Status.Manual); // auto-approvable → not a manual case 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() public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
{ {
var a = await Create("registratie"); 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 res.EnsureSuccessStatusCode(); // no longer a 422
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.Equal("InBehandeling", body.Status.Tag); Assert.Equal("InBehandeling", body.Status.Tag);
Assert.True(body.Status.Manual); 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() public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
{ {
var a = await Create("herregistratie"); 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... res.EnsureSuccessStatusCode(); // the submission is accepted...
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
Assert.NotNull(body.Status.Reden); Assert.NotNull(body.Status.Reden);
} }
@@ -98,8 +98,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Submitting_twice_conflicts() public async Task Submitting_twice_conflicts()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); var again = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); 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() public async Task Creating_a_second_concept_of_the_same_type_conflicts()
{ {
await Create("herregistratie"); 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); 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() public async Task A_concept_of_a_different_type_is_allowed()
{ {
await Create("registratie"); 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); 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() public async Task A_new_concept_is_allowed_once_the_previous_one_is_submitted()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var next = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); var next = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
Assert.Equal(HttpStatusCode.Created, next.StatusCode); Assert.Equal(HttpStatusCode.Created, next.StatusCode);
} }
@@ -134,38 +134,38 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Cancel_concept_removes_it() public async Task Cancel_concept_removes_it()
{ {
var a = await Create(); var a = await Create();
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/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/applications/{a.Id}")).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
} }
[Fact] [Fact]
public async Task Cancel_submitted_aanvraag_conflicts() public async Task Cancel_submitted_aanvraag_conflicts()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); 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] [Fact]
public async Task Applications_are_scoped_to_the_caller_bsn() public async Task Applications_are_scoped_to_the_caller_bsn()
{ {
var mine = await Create("intake"); 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" }), Content = JsonContent.Create(new { type = "intake" }),
Headers = { { "X-Subject", "999888777" } }, Headers = { { "X-Subject", "999888777" } },
}; };
var otherRes = await _client.SendAsync(createOther); var otherRes = await _client.SendAsync(createOther);
Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode); Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode);
var other = (await otherRes.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var other = (await otherRes.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
try try
{ {
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } }; var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/aanvragen") { Headers = { { "X-Subject", "999888777" } } };
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!; var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
Assert.Contains(theirCases, c => c.Id == other.Id); Assert.Contains(theirCases, c => c.Id == other.Id);
Assert.DoesNotContain(theirCases, c => c.Id == mine.Id); Assert.DoesNotContain(theirCases, c => c.Id == mine.Id);
@@ -175,9 +175,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
} }
finally 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.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 foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie"); 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 } } }); new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); 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 foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie"); 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 } }); new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
} }
@@ -9,7 +9,7 @@ namespace BigRegister.Tests.Acceptance;
/// <summary> /// <summary>
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over /// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over
/// <c>POST /applications/{id}/submit</c> (the wizard's real path — WP-72 deleted the legacy /// <c>POST /aanvragen/{id}/submit</c> (the wizard's real path — WP-72 deleted the legacy
/// <c>POST /intakes</c> endpoint this once also covered). Built through the <see /// <c>POST /intakes</c> endpoint this once also covered). Built through the <see
/// cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/> rather /// cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/> rather
/// than the full wizard/upload dance — the builder's default owner IS <see /// than the full wizard/upload dance — the builder's default owner IS <see
@@ -28,7 +28,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
} }
private Task<HttpResponseMessage> Submit(string id, object body) => private Task<HttpResponseMessage> Submit(string id, object body) =>
_client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", body); _client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", body);
[Fact] [Fact]
public async Task Below_threshold_without_an_answer_is_rejected_and_stays_a_concept() 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. // Then the submission is accepted and resolves to Afgewezen — not a 400.
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag); Assert.Equal("Afgewezen", body.Status.Tag);
} }
} }
@@ -18,11 +18,11 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
return req; return req;
} }
private async Task<ApplicationDetailDto> Create(string type) private async Task<AanvraagDetailDto> 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); Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; return (await res.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
} }
[Fact] [Fact]
@@ -33,7 +33,7 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
{ {
var list = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases")); var list = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"));
list.EnsureSuccessStatusCode(); list.EnsureSuccessStatusCode();
var cases = (await list.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!; var cases = (await list.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var mine = cases.Single(x => x.Id == a.Id); 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 // 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. // 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() public async Task Admin_can_delete_a_submitted_case()
{ {
var a = await Create("registratie"); 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(); .EnsureSuccessStatusCode();
// The user-facing DELETE refuses a submitted case (409); admin delete removes it. // 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}")); var del = await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}"));
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); 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] [Fact]
@@ -17,12 +17,12 @@ namespace BigRegister.Tests;
file sealed class IdMismatchZaakSource : IZaakSource file sealed class IdMismatchZaakSource : IZaakSource
{ {
private readonly LocalZaakSource inner = new(); 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<ApplicationSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) =>
inner.ListCases(now).Select(Rekey).ToList(); inner.ListCases(now).Select(Rekey).ToList();
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
inner.ListMyCases(caller, now).Select(Rekey).ToList(); inner.ListMyCases(caller, now).Select(Rekey).ToList();
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak( public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(
@@ -67,13 +67,13 @@ public class BeoordelingIdMismatchTests
using var factory = Factory(); using var factory = Factory();
using var client = factory.CreateClient(); using var client = factory.CreateClient();
var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); var created = await client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var app = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var app = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" }); var submit = await client.PostAsJsonAsync($"/api/v1/aanvragen/{app.Id}/submit", new { diplomaHerkomst = "handmatig" });
submit.EnsureSuccessStatusCode(); submit.EnsureSuccessStatusCode();
var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad")); var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad"));
var items = (await werkvoorraad.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!; var items = (await werkvoorraad.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var caseId = Assert.Single(items).Id; var caseId = Assert.Single(items).Id;
// Sanity: the id divergence this test exists for is real, not accidentally absent. // Sanity: the id divergence this test exists for is real, not accidentally absent.
Assert.NotEqual(app.Id, caseId); Assert.NotEqual(app.Id, caseId);
@@ -35,17 +35,17 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
/// A manual (never auto-approved) case with one linked document, so it stays /// A manual (never auto-approved) case with one linked document, so it stays
/// InBehandeling/decidable regardless of test timing (the 8s auto-approval window /// InBehandeling/decidable regardless of test timing (the 8s auto-approval window
/// would otherwise make a duo-registratie/herregistratie fixture flaky). /// 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 created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var a = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
var localId = Guid.NewGuid().ToString(); var localId = Guid.NewGuid().ToString();
var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf")); var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf"));
upload.EnsureSuccessStatusCode(); upload.EnsureSuccessStatusCode();
var doc = (await upload.Content.ReadFromJsonAsync<UploadResponse>())!; var doc = (await upload.Content.ReadFromJsonAsync<UploadResponse>())!;
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", diplomaHerkomst = "handmatig",
documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } }, documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } },
@@ -88,8 +88,8 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
[Fact] [Fact]
public async Task Concept_and_unknown_id_are_not_found() public async Task Concept_and_unknown_id_are_not_found()
{ {
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var concept = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var concept = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
try try
{ {
var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}")); var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}"));
@@ -100,7 +100,7 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
} }
finally finally
{ {
await _client.DeleteAsync($"/api/v1/applications/{concept.Id}"); await _client.DeleteAsync($"/api/v1/aanvragen/{concept.Id}");
} }
} }
@@ -202,9 +202,9 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
// Through the real submit path (RB-06 deleted POST /registrations, which was the only // Through the real submit path (RB-06 deleted POST /registrations, which was the only
// other caller of DocumentStore.Link and had no ownership guard on it). // other caller of DocumentStore.Link and had no ownership guard on it).
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var aanvraag = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var aanvraag = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit", var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{aanvraag.Id}/submit",
new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } }); new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } });
submit.EnsureSuccessStatusCode(); submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
@@ -51,16 +51,16 @@ public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture
{ {
try 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 }))) (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false })))
.EnsureSuccessStatusCode(); .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); Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode);
// On → allowed again. // On → allowed again.
(await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true }))) (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true })))
.EnsureSuccessStatusCode(); .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); Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
} }
finally finally
@@ -63,7 +63,7 @@ public class OpenZaakIntegrationTests
using var client = factory.CreateClient(); using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage) client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage)
var cases = await client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/admin/cases"); var cases = await client.GetFromJsonAsync<List<AanvraagSummaryDto>>("/api/v1/admin/cases");
Assert.NotNull(cases); Assert.NotNull(cases);
// bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123. // bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123.
@@ -17,7 +17,7 @@ namespace BigRegister.Tests;
/// - it is named, with a reason, in <see cref="AllowList"/> below. /// - it is named, with a reason, in <see cref="AllowList"/> below.
/// ///
/// The allow-list is deliberately not "public routes" — most of its entries are NOT public. /// 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 /// 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 /// 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") /// 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/{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("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("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/aanvragen", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."),
new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), new("GET", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."),
new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), new("POST", "/api/v1/aanvragen", "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("PUT", "/api/v1/aanvragen/{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("DELETE", "/api/v1/aanvragen/{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("POST", "/api/v1/aanvragen/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."),
// --- External caller, not a Principal at all. --- // --- 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."), new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."),
@@ -18,11 +18,11 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
return req; return req;
} }
private async Task<ApplicationDetailDto> CreateAndSubmitHerregistratie() private async Task<AanvraagDetailDto> CreateAndSubmitHerregistratie()
{ {
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var a = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 200 })) (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 200 }))
.EnsureSuccessStatusCode(); .EnsureSuccessStatusCode();
return a; return a;
} }
@@ -35,7 +35,7 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
{ {
var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad")); var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!; var queue = (await res.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var mine = queue.Single(x => x.Id == a.Id); var mine = queue.Single(x => x.Id == a.Id);
Assert.Equal("InBehandeling", mine.Status.Tag); Assert.Equal("InBehandeling", mine.Status.Tag);
// RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto. // RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto.
@@ -53,18 +53,18 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
[Fact] [Fact]
public async Task Queue_excludes_concepts() public async Task Queue_excludes_concepts()
{ {
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var a = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
try try
{ {
var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad")); var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!; var queue = (await res.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
Assert.DoesNotContain(queue, x => x.Id == a.Id); Assert.DoesNotContain(queue, x => x.Id == a.Id);
} }
finally finally
{ {
await _client.DeleteAsync($"/api/v1/applications/{a.Id}"); await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}");
} }
} }
@@ -39,14 +39,14 @@ public class ZgwDivergenceTests
b.ConfigurePrimaryHttpMessageHandler(() => stub)))); b.ConfigurePrimaryHttpMessageHandler(() => stub))));
} }
/// <summary>Doesn't call GET /applications first (unlike ApplicationTests.Create) — under /// <summary>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 /// 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.</summary> /// need to answer since every test here uses a fresh db and creates exactly one aanvraag.</summary>
private static async Task<string> CreateConcept(HttpClient client, string type = "registratie") private static async Task<string> 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(); res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; var body = (await res.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
return body.Id; return body.Id;
} }
@@ -78,11 +78,11 @@ public class ZgwDivergenceTests
using var client = factory.CreateClient(); using var client = factory.CreateClient();
var id = await CreateConcept(client); 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. // The local write is still authoritative: 200 with a real reference, not a 500.
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.NotEmpty(body.Referentie); Assert.NotEmpty(body.Referentie);
var stored = ApplicationStore.ListAll().Single(a => a.Id == id); var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
@@ -103,7 +103,7 @@ public class ZgwDivergenceTests
using var client = factory.CreateClient(); using var client = factory.CreateClient();
var id = await CreateConcept(client); 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(); res.EnsureSuccessStatusCode();
var stored = ApplicationStore.ListAll().Single(a => a.Id == id); var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
@@ -126,7 +126,7 @@ public class ZgwDivergenceTests
using var client = factory.CreateClient(); using var client = factory.CreateClient();
var id = await CreateConcept(client); 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(); .EnsureSuccessStatusCode();
var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError; var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError;
+36 -22
View File
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test 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 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 9 contexts; 261 backend behaviours across 42 test
classes. classes.
@@ -418,6 +418,13 @@ classes.
### registratie ### 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 #### AdminCasesStore
- loads and parses the cross-owner list - loads and parses the cross-owner list
@@ -425,13 +432,6 @@ classes.
- rolls back the removal and surfaces the error when the delete fails - rolls back the removal and surfaces the error when the delete fails
- clears a stale error on the next delete attempt - 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) #### STEPS (fixed)
- always has the same three steps - always has the same three steps
@@ -544,12 +544,12 @@ classes.
- parses each tag with its required fields - parses each tag with its required fields
- rejects a missing status, unknown tag, and wrong-typed fields - rejects a missing status, unknown tag, and wrong-typed fields
#### parseApplicationSummary #### parseAanvraagSummary
- maps a valid DTO to domain - maps a valid DTO to domain
- rejects a bad type and non-objects - rejects a bad type and non-objects
#### parseApplications / parseApplicationDetail #### parseAanvragen / parseAanvraagDetail
- parses a list and fails fast on a bad element - parses a list and fails fast on a bad element
- carries the opaque draft through detail - carries the opaque draft through detail
@@ -569,6 +569,9 @@ classes.
- maps a valid response into a DashboardView - maps a valid response into a DashboardView
- rejects malformed responses instead of trusting them - 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) #### parseDuoLookup (trust boundary)
@@ -617,6 +620,12 @@ classes.
- statusColor is total over the union - statusColor is total over the union
- herregistratieDeadline is only set for an active registration - 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 #### submit
- stays in Invullen when the draft is incomplete (no diploma) - 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 - leaves a non-API request untouched even when a subject is known
- sends no header at all when no subject has ever been seen - 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 #### upload lifecycle messages
- queued → progress → complete - queued → progress → complete
@@ -963,18 +977,7 @@ classes.
## Backend (by test class) ## Backend (by test class)
### AdminCasesTests ### AanvraagTests
- 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
- Create then list shows a concept with step progress - Create then list shows a concept with step progress
- Draft sync is readable back from detail - Draft sync is readable back from detail
@@ -993,6 +996,17 @@ classes.
- AutoApprovable flips to goedgekeurd after the window - AutoApprovable flips to goedgekeurd after the window
- Manual case never auto advances - 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 ### AuthzAuditTests
- A denied admin action is recorded - A denied admin action is recorded
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data'; import { RemoteData, map2, map, successOf } from './remote-data';
import { loading, failure, success } from '../testing/remote-data'; import { loading, failure, empty, success } from '../testing/remote-data';
const loadingRd: RemoteData<string, number> = loading(); const loadingRd: RemoteData<string, number> = loading();
const failureRd: RemoteData<string, number> = failure('x'); const failureRd: RemoteData<string, number> = failure('x');
@@ -21,3 +21,15 @@ describe('RemoteData combinators', () => {
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 }); 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();
});
});
@@ -80,3 +80,12 @@ export function andThen<E, A, B>(
): RemoteData<E, B> { ): RemoteData<E, B> {
return rd.tag === 'Success' ? f(rd.value) : rd; return rd.tag === 'Success' ? f(rd.value) : rd;
} }
/** Unwrap a Success value, or `undefined` for every other state. Used to narrow
an `<app-async>` loaded slot: `<ng-template>` 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<E, T>(rd: RemoteData<E, T>): T | undefined {
return rd.tag === 'Success' ? rd.value : undefined;
}
+71 -71
View File
@@ -637,8 +637,8 @@ export class ApiClient {
/** /**
* @return OK * @return OK
*/ */
applicationsAll(): Promise<ApplicationSummaryDto[]> { aanvragenAll(): Promise<AanvraagSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/applications"; let url_ = this.baseUrl + "/api/v1/aanvragen";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = { let options_: RequestInit = {
@@ -649,17 +649,17 @@ export class ApiClient {
}; };
return this.http.fetch(url_, options_).then((_response: Response) => { return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsAll(_response); return this.processAanvragenAll(_response);
}); });
} }
protected processApplicationsAll(response: Response): Promise<ApplicationSummaryDto[]> { protected processAanvragenAll(response: Response): Promise<AanvraagSummaryDto[]> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status !== 200 && status !== 204) { } else if (status !== 200 && status !== 204) {
@@ -667,14 +667,14 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}); });
} }
return Promise.resolve<ApplicationSummaryDto[]>(null as any); return Promise.resolve<AanvraagSummaryDto[]>(null as any);
} }
/** /**
* @return Created * @return Created
*/ */
applicationsPOST(body: CreateApplicationRequest): Promise<ApplicationDetailDto> { aanvragenPOST(body: CreateAanvraagRequest): Promise<AanvraagDetailDto> {
let url_ = this.baseUrl + "/api/v1/applications"; let url_ = this.baseUrl + "/api/v1/aanvragen";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body); const content_ = JSON.stringify(body);
@@ -689,17 +689,17 @@ export class ApiClient {
}; };
return this.http.fetch(url_, options_).then((_response: Response) => { return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsPOST(_response); return this.processAanvragenPOST(_response);
}); });
} }
protected processApplicationsPOST(response: Response): Promise<ApplicationDetailDto> { protected processAanvragenPOST(response: Response): Promise<AanvraagDetailDto> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 201) { if (status === 201) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result201: any = null; 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; return result201;
}); });
} else if (status === 409) { } else if (status === 409) {
@@ -713,14 +713,14 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}); });
} }
return Promise.resolve<ApplicationDetailDto>(null as any); return Promise.resolve<AanvraagDetailDto>(null as any);
} }
/** /**
* @return OK * @return OK
*/ */
applicationsGET(id: string): Promise<ApplicationDetailDto> { aanvragenGET(id: string): Promise<AanvraagDetailDto> {
let url_ = this.baseUrl + "/api/v1/applications/{id}"; let url_ = this.baseUrl + "/api/v1/aanvragen/{id}";
if (id === undefined || id === null) if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined."); throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id)); url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -734,17 +734,17 @@ export class ApiClient {
}; };
return this.http.fetch(url_, options_).then((_response: Response) => { return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsGET(_response); return this.processAanvragenGET(_response);
}); });
} }
protected processApplicationsGET(response: Response): Promise<ApplicationDetailDto> { protected processAanvragenGET(response: Response): Promise<AanvraagDetailDto> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 404) { } else if (status === 404) {
@@ -756,14 +756,14 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}); });
} }
return Promise.resolve<ApplicationDetailDto>(null as any); return Promise.resolve<AanvraagDetailDto>(null as any);
} }
/** /**
* @return No Content * @return No Content
*/ */
applicationsPUT(id: string, body: DraftSyncRequest): Promise<void> { aanvragenPUT(id: string, body: DraftSyncRequest): Promise<void> {
let url_ = this.baseUrl + "/api/v1/applications/{id}"; let url_ = this.baseUrl + "/api/v1/aanvragen/{id}";
if (id === undefined || id === null) if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined."); throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id)); url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -780,11 +780,11 @@ export class ApiClient {
}; };
return this.http.fetch(url_, options_).then((_response: Response) => { return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsPUT(_response); return this.processAanvragenPUT(_response);
}); });
} }
protected processApplicationsPUT(response: Response): Promise<void> { protected processAanvragenPUT(response: Response): Promise<void> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 204) { if (status === 204) {
@@ -812,8 +812,8 @@ export class ApiClient {
/** /**
* @return No Content * @return No Content
*/ */
applicationsDELETE(id: string): Promise<void> { aanvragenDELETE(id: string): Promise<void> {
let url_ = this.baseUrl + "/api/v1/applications/{id}"; let url_ = this.baseUrl + "/api/v1/aanvragen/{id}";
if (id === undefined || id === null) if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined."); throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id)); url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -826,11 +826,11 @@ export class ApiClient {
}; };
return this.http.fetch(url_, options_).then((_response: Response) => { return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsDELETE(_response); return this.processAanvragenDELETE(_response);
}); });
} }
protected processApplicationsDELETE(response: Response): Promise<void> { protected processAanvragenDELETE(response: Response): Promise<void> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 204) { if (status === 204) {
@@ -858,8 +858,8 @@ export class ApiClient {
/** /**
* @return OK * @return OK
*/ */
submit(id: string, body: SubmitApplicationRequest): Promise<SubmitApplicationResponse> { submit(id: string, body: AanvraagIndienenRequest): Promise<AanvraagIndienenResponse> {
let url_ = this.baseUrl + "/api/v1/applications/{id}/submit"; let url_ = this.baseUrl + "/api/v1/aanvragen/{id}/submit";
if (id === undefined || id === null) if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined."); throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id)); url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -881,13 +881,13 @@ export class ApiClient {
}); });
} }
protected processSubmit(response: Response): Promise<SubmitApplicationResponse> { protected processSubmit(response: Response): Promise<AanvraagIndienenResponse> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 400) { } else if (status === 400) {
@@ -911,13 +911,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}); });
} }
return Promise.resolve<SubmitApplicationResponse>(null as any); return Promise.resolve<AanvraagIndienenResponse>(null as any);
} }
/** /**
* @return OK * @return OK
*/ */
casesAll(): Promise<ApplicationSummaryDto[]> { casesAll(): Promise<AanvraagSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/admin/cases"; let url_ = this.baseUrl + "/api/v1/admin/cases";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
@@ -933,13 +933,13 @@ export class ApiClient {
}); });
} }
protected processCasesAll(response: Response): Promise<ApplicationSummaryDto[]> { protected processCasesAll(response: Response): Promise<AanvraagSummaryDto[]> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 403) { } else if (status === 403) {
@@ -953,7 +953,7 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}); });
} }
return Promise.resolve<ApplicationSummaryDto[]>(null as any); return Promise.resolve<AanvraagSummaryDto[]>(null as any);
} }
/** /**
@@ -1047,7 +1047,7 @@ export class ApiClient {
/** /**
* @return OK * @return OK
*/ */
werkvoorraad(): Promise<ApplicationSummaryDto[]> { werkvoorraad(): Promise<AanvraagSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/werkvoorraad"; let url_ = this.baseUrl + "/api/v1/werkvoorraad";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
@@ -1063,13 +1063,13 @@ export class ApiClient {
}); });
} }
protected processWerkvoorraad(response: Response): Promise<ApplicationSummaryDto[]> { protected processWerkvoorraad(response: Response): Promise<AanvraagSummaryDto[]> {
const status = response.status; const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) { if (status === 200) {
return response.text().then((_responseText) => { return response.text().then((_responseText) => {
let result200: any = null; 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; return result200;
}); });
} else if (status === 403) { } else if (status === 403) {
@@ -1083,7 +1083,7 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}); });
} }
return Promise.resolve<ApplicationSummaryDto[]>(null as any); return Promise.resolve<AanvraagSummaryDto[]>(null as any);
} }
/** /**
@@ -1898,22 +1898,7 @@ export interface AantekeningDto {
datum?: string | undefined; datum?: string | undefined;
} }
export interface AanvraagStatusDto { export interface AanvraagDetailDto {
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 {
id?: string | undefined; id?: string | undefined;
type?: string | undefined; type?: string | undefined;
status?: AanvraagStatusDto; status?: AanvraagStatusDto;
@@ -1924,7 +1909,29 @@ export interface ApplicationDetailDto {
submittedAt?: string | undefined; 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; id?: string | undefined;
type?: string | undefined; type?: string | undefined;
status?: AanvraagStatusDto; status?: AanvraagStatusDto;
@@ -1935,6 +1942,12 @@ export interface ApplicationSummaryDto {
owner?: string | undefined; owner?: string | undefined;
} }
export interface AdresDto {
straat?: string | undefined;
postcode?: string | undefined;
woonplaats?: string | undefined;
}
export interface AuthzAuditDto { export interface AuthzAuditDto {
at?: string | undefined; at?: string | undefined;
action?: string | undefined; action?: string | undefined;
@@ -1955,7 +1968,7 @@ export interface BeoordelingDocumentDto {
} }
export interface BeoordelingViewDto { export interface BeoordelingViewDto {
aanvraag?: ApplicationSummaryDto; aanvraag?: AanvraagSummaryDto;
documenten?: BeoordelingDocumentDto[] | undefined; documenten?: BeoordelingDocumentDto[] | undefined;
decisions?: BeoordelingDecisionsDto; decisions?: BeoordelingDecisionsDto;
} }
@@ -2014,7 +2027,7 @@ export interface ChangeRequestRequest {
telefoon?: string | undefined; telefoon?: string | undefined;
} }
export interface CreateApplicationRequest { export interface CreateAanvraagRequest {
type?: string | undefined; type?: string | undefined;
} }
@@ -2275,19 +2288,6 @@ export interface SubOrgSummaryDto {
publishedVersion?: number; 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 { export interface UploadCategoriesDto {
categories?: DocumentCategoryDto[] | undefined; categories?: DocumentCategoryDto[] | undefined;
} }
+2
View File
@@ -11,3 +11,5 @@ export const loading = <E = never, T = never>(): RemoteData<E, T> => ({ tag: 'Lo
export const success = <T, E = never>(value: T): RemoteData<E, T> => ({ tag: 'Success', value }); export const success = <T, E = never>(value: T): RemoteData<E, T> => ({ tag: 'Success', value });
export const failure = <E, T = never>(error: E): RemoteData<E, T> => ({ tag: 'Failure', error }); export const failure = <E, T = never>(error: E): RemoteData<E, T> => ({ tag: 'Failure', error });
export const empty = <E = never, T = never>(): RemoteData<E, T> => ({ tag: 'Empty' });