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 { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client';
import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client';
import {
WerkvoorraadItem,
WerkvoorraadStatus,
@@ -18,7 +18,7 @@ import {
export class WerkvoorraadAdapter {
private client = inject(ApiClient);
list(): Promise<ApplicationSummaryDto[]> {
list(): Promise<AanvraagSummaryDto[]> {
return this.client.werkvoorraad();
}
}
@@ -26,7 +26,7 @@ export class WerkvoorraadAdapter {
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
function parseWerkvoorraadStatus(
s: ApplicationSummaryDto['status'] | undefined,
s: AanvraagSummaryDto['status'] | undefined,
): Result<string, WerkvoorraadStatus> {
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
switch (s.tag) {
@@ -44,7 +44,7 @@ function parseWerkvoorraadStatus(
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
const dto = json as ApplicationSummaryDto;
const dto = json as AanvraagSummaryDto;
if (typeof dto.id !== 'string') return err('werkvoorraad: missing id');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
return err(`werkvoorraad: bad type ${dto.type}`);
@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import { SUBMIT_FAILED } from '@shared/application/submit';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { ApplicationsStore } from './applications.store';
import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter';
import { AanvragenStore } from './aanvragen.store';
const summary = (id: string) => ({
id,
@@ -13,20 +13,20 @@ const summary = (id: string) => ({
updatedAt: '2026-07-23T10:00:00Z',
});
function setup(adapter: Partial<ApplicationsAdapter>): ApplicationsStore {
function setup(adapter: Partial<AanvragenAdapter>): AanvragenStore {
TestBed.configureTestingModule({
providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
providers: [{ provide: AanvragenAdapter, useValue: adapter }],
});
// The store's own constructor kicks off `load()` (dashboard revisit refresh) —
// give every test a `list` so that initial call has something to resolve.
return TestBed.inject(ApplicationsStore);
return TestBed.inject(AanvragenStore);
}
describe('ApplicationsStore', () => {
describe('AanvragenStore', () => {
it('loads and parses the list', async () => {
const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) });
await store.load();
const s = store.applications();
const s = store.aanvragen();
expect(s.tag).toBe('Success');
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']);
});
@@ -41,7 +41,7 @@ describe('ApplicationsStore', () => {
await store.cancel('a');
expect(cancel).toHaveBeenCalledWith('a');
const s = store.applications();
const s = store.aanvragen();
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']);
expect(store.lastError()).toBeNull();
});
@@ -55,7 +55,7 @@ describe('ApplicationsStore', () => {
await store.load();
await store.cancel('a');
const s = store.applications();
const s = store.aanvragen();
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears
expect(store.lastError()).toBe(SUBMIT_FAILED);
});
@@ -2,15 +2,12 @@ import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter';
type Err = Error | undefined;
/**
* The dashboard's view of the user's applications (aanvragen) the backend is the
* The dashboard's view of the user's aanvragen the backend is the
* system of record (PRD 0001). One root singleton OWNS the list as a writable
* RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes
* the row SYNCHRONOUSLY, so the block disappears deterministically no dependence on
@@ -20,11 +17,11 @@ type Err = Error | undefined;
* surfaces `lastError` on failure (RB-20).
*/
@Injectable({ providedIn: 'root' })
export class ApplicationsStore {
private adapter = inject(ApplicationsAdapter);
export class AanvragenStore {
private adapter = inject(AanvragenAdapter);
private state = signal<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
then, this is only the message for the alert the page renders above the list. */
@@ -40,7 +37,7 @@ export class ApplicationsStore {
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseApplications(await this.adapter.list());
const parsed = parseAanvragen(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import { SUBMIT_FAILED } from '@shared/application/submit';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter';
import { AdminCasesStore } from './admin-cases.store';
const summary = (id: string) => ({
@@ -14,9 +14,9 @@ const summary = (id: string) => ({
owner: '19012345601',
});
function setup(adapter: Partial<ApplicationsAdapter>): AdminCasesStore {
function setup(adapter: Partial<AanvragenAdapter>): AdminCasesStore {
TestBed.configureTestingModule({
providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
providers: [{ provide: AanvragenAdapter, useValue: adapter }],
});
return TestBed.inject(AdminCasesStore);
}
@@ -2,16 +2,13 @@ import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter';
type Err = Error | undefined;
/**
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
* counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the
@@ -19,7 +16,7 @@ type Err = Error | undefined;
*/
@Injectable({ providedIn: 'root' })
export class AdminCasesStore {
private adapter = inject(ApplicationsAdapter);
private adapter = inject(AanvragenAdapter);
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly();
@@ -34,7 +31,7 @@ export class AdminCasesStore {
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseApplications(await this.adapter.listAll());
const parsed = parseAanvragen(await this.adapter.listAll());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
@@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
import { HerregistratieDecisions } from '../domain/registration';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import {
DashboardView,
@@ -2,14 +2,14 @@ import { ApplicationRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter';
import { createDraftSync, DraftSnapshot } from './draft-sync';
function setup(adapter: Partial<ApplicationsAdapter>) {
function setup(adapter: Partial<AanvragenAdapter>) {
const navigate = vi.fn().mockResolvedValue(true);
TestBed.configureTestingModule({
providers: [
{ provide: ApplicationsAdapter, useValue: adapter },
{ provide: AanvragenAdapter, useValue: adapter },
{ provide: Router, useValue: { navigate } },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
],
@@ -4,11 +4,11 @@ import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { registerPendingSave } from '@shared/application/pending-saves';
import type {
SubmitApplicationRequest,
SubmitApplicationResponse,
AanvraagIndienenRequest,
AanvraagIndienenResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter';
import { findConcept, loadConcept } from './find-concept';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
@@ -46,7 +46,7 @@ const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels lag
* Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
*/
export function createDraftSync(deps: DraftSyncDeps) {
const adapter = inject(ApplicationsAdapter);
const adapter = inject(AanvragenAdapter);
const router = inject(Router, { optional: true });
const route = inject(ActivatedRoute, { optional: true });
const active = () => deps.enabled() && !!router && !!route;
@@ -190,7 +190,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
`POST /applications/{id}/submit` (server sets autoApprovable + transitions).
Folded into a Result like the old submit-* commands. */
submit(body: SubmitApplicationRequest): Promise<Result<string, SubmitApplicationResponse>> {
submit(body: AanvraagIndienenRequest): Promise<Result<string, AanvraagIndienenResponse>> {
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
},
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter';
import { findConcept, loadConcept } from './find-concept';
// Free functions taking the adapter as a parameter (no inject()) — a plain fake
// object is enough, no Angular TestBed needed.
function fakeAdapter(overrides: Partial<ApplicationsAdapter>): ApplicationsAdapter {
return overrides as ApplicationsAdapter;
function fakeAdapter(overrides: Partial<AanvragenAdapter>): AanvragenAdapter {
return overrides as AanvragenAdapter;
}
describe('findConcept', () => {
@@ -1,8 +1,5 @@
import { AanvraagType } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter';
/**
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
@@ -14,11 +11,11 @@ import {
/** Find the user's existing Concept of a given type (at most one), if any. */
export async function findConcept(
adapter: ApplicationsAdapter,
adapter: AanvragenAdapter,
type: AanvraagType,
): Promise<string | undefined> {
try {
const parsed = parseApplications(await adapter.list());
const parsed = parseAanvragen(await adapter.list());
return parsed.ok
? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id
: undefined;
@@ -34,10 +31,7 @@ export async function findConcept(
export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' };
/** Load a specific Concept by id and report whether it is still editable. */
export async function loadConcept(
adapter: ApplicationsAdapter,
id: string,
): Promise<LoadedConcept> {
export async function loadConcept(adapter: AanvragenAdapter, id: string): Promise<LoadedConcept> {
try {
const dto = await adapter.detail(id);
if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' };
@@ -41,7 +41,7 @@ export class RegistratieLookupStore {
const json = this.adresRes.value();
if (json === undefined) return null;
const parsed = parseBrpAddress(json);
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
return parsed.ok && parsed.value.adres ? parsed.value.adres : null;
},
);
@@ -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 { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view';
import {
submittedRow,
detailRows,
purposeLabel,
statusLabel,
TYPE_LABELS,
sortForDashboard,
concepten,
ingediend,
} from './aanvraag-view';
import { Aanvraag } from './aanvraag';
const base: Omit<Aanvraag, 'status'> = {
@@ -114,3 +123,40 @@ describe('detailRows', () => {
expect(rows.length).toBe(5);
});
});
describe('sortForDashboard / concepten / ingediend', () => {
const withStatus = (id: string, tag: Aanvraag['status']['tag']): Aanvraag => ({
...base,
id,
status:
tag === 'Concept'
? { tag, stepIndex: 0, stepCount: 1 }
: tag === 'Afgewezen' || tag === 'MeerInfoGevraagd'
? { tag, referentie: 'R', reden: 'x' }
: tag === 'InBehandeling'
? { tag, referentie: 'R', manual: false }
: { tag, referentie: 'R' },
});
it('sorts Concept, then still-open, then resolved last', () => {
const goedgekeurd = withStatus('1', 'Goedgekeurd');
const concept = withStatus('2', 'Concept');
const inBehandeling = withStatus('3', 'InBehandeling');
const sorted = sortForDashboard([goedgekeurd, concept, inBehandeling]);
expect(sorted.map((a) => a.id)).toEqual(['2', '3', '1']);
});
it('does not mutate the input array', () => {
const list = [withStatus('1', 'Goedgekeurd'), withStatus('2', 'Concept')];
const copy = [...list];
sortForDashboard(list);
expect(list).toEqual(copy);
});
it('concepten/ingediend split on the Concept tag', () => {
const concept = withStatus('1', 'Concept');
const ingediendItem = withStatus('2', 'Ingediend');
expect(concepten([concept, ingediendItem])).toEqual([concept]);
expect(ingediend([concept, ingediendItem])).toEqual([ingediendItem]);
});
});
@@ -106,3 +106,30 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
}
return rows;
}
/** Dashboard sort order: still-open work first (Concept, then submitted-and-pending),
resolved aanvragen (Goedgekeurd/Afgewezen) last. Within a group, order is stable
(the sort is by rank only). */
const SORT_RANK: Record<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;
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 {
parseAanvraagStatus,
parseApplicationSummary,
parseApplications,
parseApplicationDetail,
} from './applications.adapter';
parseAanvraagSummary,
parseAanvragen,
parseAanvraagDetail,
} from './aanvragen.adapter';
const concept = {
id: 'a1',
@@ -42,29 +42,29 @@ describe('parseAanvraagStatus', () => {
});
});
describe('parseApplicationSummary', () => {
describe('parseAanvraagSummary', () => {
it('maps a valid DTO to domain', () => {
const r = parseApplicationSummary(concept);
const r = parseAanvraagSummary(concept);
expect(r.ok && r.value.type).toBe('registratie');
expect(r.ok && r.value.status.tag).toBe('Concept');
});
it('rejects a bad type and non-objects', () => {
expect(parseApplicationSummary({ ...concept, type: 'onbekend' }).ok).toBe(false);
expect(parseApplicationSummary(null).ok).toBe(false);
expect(parseApplicationSummary({ ...concept, id: 42 }).ok).toBe(false);
expect(parseAanvraagSummary({ ...concept, type: 'onbekend' }).ok).toBe(false);
expect(parseAanvraagSummary(null).ok).toBe(false);
expect(parseAanvraagSummary({ ...concept, id: 42 }).ok).toBe(false);
});
});
describe('parseApplications / parseApplicationDetail', () => {
describe('parseAanvragen / parseAanvraagDetail', () => {
it('parses a list and fails fast on a bad element', () => {
expect(parseApplications([concept, concept]).ok).toBe(true);
expect(parseApplications([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false);
expect(parseApplications({}).ok).toBe(false);
expect(parseAanvragen([concept, concept]).ok).toBe(true);
expect(parseAanvragen([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false);
expect(parseAanvragen({}).ok).toBe(false);
});
it('carries the opaque draft through detail', () => {
const r = parseApplicationDetail({ ...concept, draft: { beroep: 'arts' } });
const r = parseAanvraagDetail({ ...concept, draft: { beroep: 'arts' } });
expect(r.ok && (r.value.draft as { beroep: string }).beroep).toBe('arts');
});
});
@@ -3,11 +3,11 @@ import { Result, ok, err } from '@shared/kernel/fp';
import {
ApiClient,
AanvraagStatusDto,
ApplicationSummaryDto,
ApplicationDetailDto,
AanvraagSummaryDto,
AanvraagDetailDto,
DraftSyncRequest,
SubmitApplicationRequest,
SubmitApplicationResponse,
AanvraagIndienenRequest,
AanvraagIndienenResponse,
} from '@shared/infrastructure/api-client';
import {
Aanvraag,
@@ -19,21 +19,21 @@ import {
/**
* Infrastructure adapter for the backend-owned Aanvraag aggregate the only place
* its HTTP lives (ADR-0001 anti-corruption boundary). The list is a resource; the
* mutations (create/sync/cancel/submit) are thin commands the ApplicationsStore
* mutations (create/sync/cancel/submit) are thin commands the AanvragenStore
* orchestrates optimistically. The untrusted response is validated + mapped to
* domain by the hand-written parse* boundary below.
*/
@Injectable({ providedIn: 'root' })
export class ApplicationsAdapter {
export class AanvragenAdapter {
private client = inject(ApiClient);
/** The dashboard's application list (raw DTOs; the store parses at the boundary). */
list(): Promise<ApplicationSummaryDto[]> {
return this.client.applicationsAll();
/** The dashboard's aanvraag list (raw DTOs; the store parses at the boundary). */
list(): Promise<AanvraagSummaryDto[]> {
return this.client.aanvragenAll();
}
/** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */
listAll(): Promise<ApplicationSummaryDto[]> {
listAll(): Promise<AanvraagSummaryDto[]> {
return this.client.casesAll();
}
@@ -42,26 +42,26 @@ export class ApplicationsAdapter {
return this.client.cases(id);
}
detail(id: string): Promise<ApplicationDetailDto> {
return this.client.applicationsGET(id);
detail(id: string): Promise<AanvraagDetailDto> {
return this.client.aanvragenGET(id);
}
/** Create a Concept for a wizard type; resolves to the new aanvraag id. */
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. */
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(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);
}
}
@@ -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.type !== 'string' || !AANVRAAG_TYPES.includes(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');
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');
const out: Aanvraag[] = [];
for (const item of json) {
const parsed = parseApplicationSummary(item);
const parsed = parseAanvraagSummary(item);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
export function parseApplicationDetail(json: unknown): Result<string, AanvraagDetail> {
export function parseAanvraagDetail(json: unknown): Result<string, AanvraagDetail> {
if (typeof json !== 'object' || json === null) return err('aanvraag: not an object');
const base = parseCommon(json as ApplicationDetailDto);
const base = parseCommon(json as AanvraagDetailDto);
if (!base.ok) return base;
return ok({ ...base.value, draft: (json as ApplicationDetailDto).draft ?? null });
return ok({ ...base.value, draft: (json as AanvraagDetailDto).draft ?? null });
}
@@ -1,8 +1,15 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
import { BrpAddressDto } from '@shared/infrastructure/api-client';
import { ApiClient } from '@shared/infrastructure/api-client';
/** BRP address lookup, narrowed from the generated (all-optional) `BrpAddressDto`
to what `gevonden` actually guarantees. */
export interface BrpAddress {
gevonden: boolean;
adres?: { straat: string; postcode: string; woonplaats: string };
}
/**
* Infrastructure adapter for the BRP address lookup, reached only through our own
* ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET
@@ -21,20 +28,22 @@ export class BrpAdapter {
/** Trust-boundary parse: validate the untrusted response shape. "Geen adres" is a
valid outcome (gevonden: false), not a malformed response. ponytail: hand-written;
reach for a schema lib once the contract count grows. */
export function parseBrpAddress(json: unknown): Result<string, BrpAddressDto> {
export function parseBrpAddress(json: unknown): Result<string, BrpAddress> {
if (typeof json !== 'object' || json === null) return err('brp-address: not an object');
const dto = json as Partial<BrpAddressDto>;
if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden');
if (dto.gevonden) {
const a = dto.adres;
if (
!a ||
typeof a.straat !== 'string' ||
typeof a.postcode !== 'string' ||
typeof a.woonplaats !== 'string'
) {
return err('brp-address: missing/invalid adres');
}
if (!dto.gevonden) return ok({ gevonden: false });
const a = dto.adres;
if (
!a ||
typeof a.straat !== 'string' ||
typeof a.postcode !== 'string' ||
typeof a.woonplaats !== 'string'
) {
return err('brp-address: missing/invalid adres');
}
return ok({ gevonden: dto.gevonden, adres: dto.adres });
return ok({
gevonden: true,
adres: { straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats },
});
}
@@ -35,4 +35,43 @@ describe('parseDashboardView (trust boundary)', () => {
parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok,
).toBe(false);
});
it('rejects a status whose tag is present but its required fields are missing', () => {
// The generated RegistrationStatusDto flattens the union — every field is
// optional, so a wire bug (a Geregistreerd row with no herregistratieDatum)
// must be caught here, not by the compiler.
expect(
parseDashboardView({
...valid,
registration: { ...valid.registration, status: { tag: 'Geregistreerd' } },
}).ok,
).toBe(false);
expect(
parseDashboardView({
...valid,
registration: {
...valid.registration,
status: { tag: 'Geschorst', geschorstTot: '2027-01-01' }, // missing reden
},
}).ok,
).toBe(false);
});
it('rejects an unknown status tag', () => {
expect(
parseDashboardView({
...valid,
registration: { ...valid.registration, status: { tag: 'Ingetrokken' } },
}).ok,
).toBe(false);
});
it('rejects a person with an incomplete adres', () => {
expect(
parseDashboardView({
...valid,
person: { ...valid.person, adres: { straat: 'X 1' } },
}).ok,
).toBe(false);
});
});
@@ -1,18 +1,24 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import {
ApiClient,
DashboardViewDto,
RegistrationDto,
RegistrationStatusDto,
PersonDto,
} from '@shared/infrastructure/api-client';
import {
Registration,
RegistrationStatus,
HerregistratieDecisions,
} from '@registratie/contracts/dashboard-view.dto';
import { Registration } from '@registratie/domain/registration';
} from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
* The parsed, frontend-side view: the wire DTO mapped onto our own domain model.
* Lives HERE, not in contracts/, because it references domain types — contracts
* stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001).
* Lives HERE, not in a `contracts/*.dto.ts` file, because it references domain
* types — that split is the decoupling seam (CLAUDE.md §1, ADR-0001).
*/
export interface DashboardView {
profile: BigProfile;
@@ -31,17 +37,80 @@ export class DashboardViewAdapter {
// The value is still untrusted JSON — parseDashboardView validates it at the
// boundary and maps DTO → domain before the app uses it.
//
// SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here —
// e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the
// adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls
// (the submit-* commands) must NEVER auto-retry — and don't. Manual retry
// (resource.reload via <app-async>) covers the UX today, so backoff stays unbuilt.
dashboardViewResource() {
return resource({ loader: () => this.client.dashboardView() });
}
}
/** Trust-boundary parse of the status union — the generated `RegistrationStatusDto`
flattens all three variants into one object with every field optional (NSwag
can't express a discriminated union), so the tag drives which fields must
actually be present. Mirrors `parseAanvraagStatus` in `aanvragen.adapter.ts`. */
export function parseRegistrationStatus(
s: RegistrationStatusDto | undefined,
): Result<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
* 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');
const dto = json as Partial<DashboardViewDto>;
const reg = dto.registration;
if (
!reg ||
typeof reg.bigNummer !== 'string' ||
!reg.status ||
typeof reg.status.tag !== 'string'
) {
return err('dashboard-view: missing/invalid registration');
}
const person = dto.person;
if (!person || !person.adres || typeof person.adres.postcode !== 'string') {
return err('dashboard-view: missing/invalid person');
}
const registration = parseRegistration(dto.registration);
if (!registration.ok) return registration;
const person = parsePerson(dto.person);
if (!person.ok) return person;
const d = dto.decisions;
if (!d || typeof d.eligibleForHerregistratie !== 'boolean') {
return err('dashboard-view: missing/invalid decisions');
}
// Map wire → domain. The shapes are identical today, so this reads as an
// identity copy — but the TYPES differ (wire DTO vs domain), so the moment the
// wire diverges the compiler forces a real mapping here. That's the seam.
const registration: Registration = {
bigNummer: reg.bigNummer,
naam: reg.naam,
beroep: reg.beroep,
registratiedatum: reg.registratiedatum,
geboortedatum: reg.geboortedatum,
status: reg.status,
};
const persoon: Person = {
naam: person.naam,
geboortedatum: person.geboortedatum,
adres: person.adres,
};
return ok({
profile: { registration, person: persoon },
profile: { registration: registration.value, person: person.value },
decisions: {
eligibleForHerregistratie: d.eligibleForHerregistratie,
herregistratieReason: d.herregistratieReason,
@@ -1,4 +1,5 @@
import { Component, computed, inject } from '@angular/core';
import { successOf } from '@shared/application/remote-data';
import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
@@ -6,7 +7,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { ApplicationsStore } from '@registratie/application/applications.store';
import { AanvragenStore } from '@registratie/application/aanvragen.store';
import { Aanvraag } from '@registratie/domain/aanvraag';
import { detailRows } from '@registratie/domain/aanvraag-view';
@@ -29,9 +30,9 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
heading="Aanvraag"
backLink="/dashboard"
>
<app-async [data]="store.applications()">
<app-async [data]="store.aanvragen()">
<ng-template appAsyncLoaded>
@if (applications(); as list) {
@if (aanvragen(); as list) {
@let a = find(list);
@if (a) {
<app-data-block
@@ -60,15 +61,13 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
`,
})
export class AanvraagDetailPage {
protected store = inject(ApplicationsStore);
protected store = inject(AanvragenStore);
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows;
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly applications = computed(() => {
const rd = this.store.applications();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
so the Success value is unwrapped here instead of through `let-`. */
protected readonly aanvragen = computed(() => successOf(this.store.aanvragen()));
}
+23 -321
View File
@@ -1,47 +1,25 @@
import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag';
import { ADMIN_LINKS } from '../../shell/nav.config';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
import { AanvraagBlockComponent } from '@registratie/ui/aanvraag-block/aanvraag-block.component';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { 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';
import { MijnAanvragenSection } from './dashboard/mijn-aanvragen.section';
import { WatMoetIkRegelenSection } from './dashboard/wat-moet-ik-regelen.section';
import { MijnRegistratieSection } from './dashboard/mijn-registratie.section';
import { SpecialismenSection } from './dashboard/specialismen.section';
import { WatWiltUDoenSection } from './dashboard/wat-wilt-u-doen.section';
import { BeheerLinksSection } from './dashboard/beheer-links.section';
/** Page:"Mijn overzicht" the portal home, following the NL Design System
"Mijn omgeving" pattern (side nav +"Wat moet ik regelen" +"Mijn zaken"). */
/** Page: "Mijn overzicht" the portal home, following the NL Design System "Mijn
omgeving" pattern. Composition only: each section below answers its own data
question (own store, own async state) see `ui/dashboard/*.section.ts`. */
@Component({
selector: 'app-dashboard-page',
imports: [
PageShellComponent,
HeadingComponent,
AlertComponent,
SkeletonComponent,
DataRowComponent,
DataBlockComponent,
TaskListComponent,
ApplicationListComponent,
ApplicationLinkComponent,
...ASYNC,
RegistrationSummaryComponent,
RegistrationTableComponent,
AanvraagBlockComponent,
MijnAanvragenSection,
WatMoetIkRegelenSection,
MijnRegistratieSection,
SpecialismenSection,
WatWiltUDoenSection,
BeheerLinksSection,
],
template: `
<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."
>
<div class="app-stack">
@if (cancelError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@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)="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>
}
<app-mijn-aanvragen-section />
<app-wat-moet-ik-regelen-section />
<app-mijn-registratie-section />
<app-specialismen-section />
<app-wat-wilt-u-doen-section />
<app-beheer-links-section />
</div>
</app-page-shell>
`,
})
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),
),
);
}
export class DashboardPage {}
@@ -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 { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
@@ -41,9 +42,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
export class RegistrationDetailPage {
protected store = inject(BigProfileStore);
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** `successOf`: `<ng-template>` can't inherit a generic from a sibling host input,
so the Success value is unwrapped here instead of through `let-`. */
protected readonly profile = computed(() => successOf(this.store.profile()));
}
@@ -99,19 +99,19 @@ public sealed record AanvraagStatusDto(
bool? Manual = null,
string? Reden = null);
public sealed record ApplicationSummaryDto(
public sealed record AanvraagSummaryDto(
string Id, string Type, AanvraagStatusDto Status,
IReadOnlyList<string> DocumentIds,
string CreatedAt, string UpdatedAt, string? SubmittedAt,
string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it
public sealed record ApplicationDetailDto(
public sealed record AanvraagDetailDto(
string Id, string Type, AanvraagStatusDto Status,
System.Text.Json.JsonElement? Draft,
IReadOnlyList<string> DocumentIds,
string CreatedAt, string UpdatedAt, string? SubmittedAt);
public sealed record CreateApplicationRequest(string Type);
public sealed record CreateAanvraagRequest(string Type);
public sealed record DraftSyncRequest(
System.Text.Json.JsonElement Draft, int StepIndex, int StepCount,
@@ -120,12 +120,12 @@ public sealed record DraftSyncRequest(
// Submit carries only the fields the server re-validates per wizard type.
// AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by
// IntakePolicy.RejectIncompleteScholing's caller), null for the others.
public sealed record SubmitApplicationRequest(
public sealed record AanvraagIndienenRequest(
string? DiplomaHerkomst = null, int? Uren = null,
IReadOnlyList<DocumentRefDto>? Documents = null,
bool? AanvullendeScholing = null, int? ScholingPunten = null);
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
public sealed record AanvraagIndienenResponse(string Referentie, AanvraagStatusDto Status);
// --- Beoordeling (WP-65): the behandelportal's case-detail screen. ---
@@ -137,7 +137,7 @@ public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId
public sealed record BeoordelingDecisionsDto(bool CanBesluiten);
public sealed record BeoordelingViewDto(
ApplicationSummaryDto Aanvraag,
AanvraagSummaryDto Aanvraag,
IReadOnlyList<BeoordelingDocumentDto> Documenten,
BeoordelingDecisionsDto Decisions);
@@ -65,7 +65,7 @@ public static class Mappers
/// 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;
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
public static AanvraagSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
@@ -74,10 +74,10 @@ public static class Mappers
/// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked
/// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner
/// list cannot be added that forgets to.
public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
public static AanvraagSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) };
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
public static AanvraagDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
}
@@ -43,7 +43,7 @@ public static class DocumentStore
/// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer,
/// a different Dutch identifier scheme). Previously this constant reused that BigNummer value
/// as a stand-in BSN, which is invalid Dutch-BSN shape: harmless against the local store, but
/// a real OpenZaak instance rejects it outright — GET /api/v1/applications 500s (`inpBsn` query
/// a real OpenZaak instance rejects it outright — GET /api/v1/aanvragen 500s (`inpBsn` query
/// filter validation) and every submit's rol-creation POST fails (`inpBsn` max_length) once
/// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture
/// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen").
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
/// <summary>
/// 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)
/// 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).
///
/// 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,
/// WP-36) — cases:manage only, deliberately NOT citizen-scoped.</summary>
IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now);
IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now);
/// <summary>
/// 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
/// can never see another citizen's zaken.
/// </summary>
IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
/// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
@@ -12,12 +12,12 @@ namespace BigRegister.Api.Data;
/// </summary>
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();
/// <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>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
ApplicationStore.List(caller.Bsn)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
@@ -9,7 +9,7 @@ public sealed record FeatureFlagDef(string Key, string Description, bool Default
public static class FeatureFlags
{
/// Whether self-service registration (inschrijving) is open. When off, the FE hides the
/// "Inschrijven" action and POST /applications for a `registratie` is refused (server-enforced).
/// "Inschrijven" action and POST /aanvragen for a `registratie` is refused (server-enforced).
public const string InschrijvingOpen = "inschrijving-open";
public static readonly IReadOnlyList<FeatureFlagDef> Catalog = new[]
@@ -6,7 +6,7 @@ namespace BigRegister.Domain.Intake;
/// (<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
/// 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
/// an incomplete answer.
/// </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
// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
// openzaak-integration.md's ACL caveat used to flag for this endpoint.
api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) =>
api.MapGet("/aanvragen", (HttpContext ctx, IZaakSource zaken) =>
zaken.ListMyCases(ctx.Zorgverlener(), DateTimeOffset.UtcNow));
api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
api.MapGet("/aanvragen/{id}", (string id, HttpContext ctx) =>
ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a
? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
: Results.NotFound())
.Produces<ApplicationDetailDto>()
.Produces<AanvraagDetailDto>()
.Produces(StatusCodes.Status404NotFound);
// --- writes ---
api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
api.MapPost("/aanvragen", (CreateAanvraagRequest req, HttpContext ctx) =>
{
// Feature flag (WP-47): self-service registration can be closed by an admin.
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
@@ -364,13 +364,13 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
return Results.Problem(
detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.",
statusCode: StatusCodes.Status409Conflict);
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
return Results.Created($"/api/v1/aanvragen/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
})
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created)
.Produces<AanvraagDetailDto>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status409Conflict);
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
api.MapPut("/aanvragen/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
{
var owner = ctx.Zorgverlener().Bsn;
// A citizen may only reference their own uploads in a draft — reject before the sync
@@ -388,7 +388,7 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext c
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
// be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id, HttpContext ctx) =>
api.MapDelete("/aanvragen/{id}", (string id, HttpContext ctx) =>
{
var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
if (a is null) return Results.NotFound();
@@ -403,7 +403,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) =>
// Submit runs the server-owned rules, sets autoApprovable, and transitions the
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) =>
api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) =>
{
var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn);
if (existing is null) return Results.NotFound();
@@ -480,9 +480,9 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
}
}
return Results.Ok(new SubmitApplicationResponse(referentie, status));
return Results.Ok(new AanvraagIndienenResponse(referentie, status));
})
.Produces<SubmitApplicationResponse>()
.Produces<AanvraagIndienenResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
@@ -494,7 +494,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
.Gate("CasesAdmin")
.Produces<List<ApplicationSummaryDto>>()
.Produces<List<AanvraagSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
@@ -510,7 +510,7 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
// --- writes ---
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
// DELETE /applications/{id}. A missing id is a 404.
// DELETE /aanvragen/{id}. A missing id is a 404.
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
{
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
@@ -531,14 +531,14 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
.ToList())))
.Gate("Beoordelen")
.Produces<List<ApplicationSummaryDto>>()
.Produces<List<AanvraagSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording
// a decision is WP-65's second half). Reads through IZaakSource.ListCases (no new seam method:
// adding one now would force an OpenZaak get-by-id + mapper, which is WP-66's surface) — O(n)
// over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here
// same as an unknown id (only /applications/{id}, citizen-scoped, shows a Concept).
// same as an unknown id (only /aanvragen/{id}, citizen-scoped, shows a Concept).
api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) =>
Beoordelen(ctx, $"aanvraag/{id}", () =>
{
@@ -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
/// 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
/// 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
/// <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>;
/// 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
// whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the
// 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();
/// <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
/// 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();
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";
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):
/// <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,
/// 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
@@ -6,7 +6,7 @@ namespace BigRegister.Api.Zgw;
/// <summary>
/// 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
/// 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.
@@ -20,7 +20,7 @@ public sealed record ZgwZaak(
[property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum);
/// <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
/// join get flattened away, so nothing downstream (the FE) sees ZGW shapes.
/// </summary>
@@ -29,7 +29,7 @@ public static class ZgwZaakMapper
/// <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 ApplicationSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel)
public static AanvraagSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel)
{
// ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed
// one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the
@@ -41,7 +41,7 @@ public static class ZgwZaakMapper
var created = Iso(z.Registratiedatum ?? z.Startdatum);
var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
return new ApplicationSummaryDto(
return new AanvraagSummaryDto(
Id: Uuid(z.Url),
Type: zaaktypeLabel,
Status: status,
+109 -109
View File
@@ -425,7 +425,7 @@
}
}
},
"/api/v1/applications": {
"/api/v1/aanvragen": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -438,7 +438,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApplicationSummaryDto"
"$ref": "#/components/schemas/AanvraagSummaryDto"
}
}
}
@@ -454,7 +454,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateApplicationRequest"
"$ref": "#/components/schemas/CreateAanvraagRequest"
}
}
},
@@ -466,7 +466,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplicationDetailDto"
"$ref": "#/components/schemas/AanvraagDetailDto"
}
}
}
@@ -484,7 +484,7 @@
}
}
},
"/api/v1/applications/{id}": {
"/api/v1/aanvragen/{id}": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -505,7 +505,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplicationDetailDto"
"$ref": "#/components/schemas/AanvraagDetailDto"
}
}
}
@@ -592,7 +592,7 @@
}
}
},
"/api/v1/applications/{id}/submit": {
"/api/v1/aanvragen/{id}/submit": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
@@ -611,7 +611,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmitApplicationRequest"
"$ref": "#/components/schemas/AanvraagIndienenRequest"
}
}
},
@@ -623,7 +623,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmitApplicationResponse"
"$ref": "#/components/schemas/AanvraagIndienenResponse"
}
}
}
@@ -667,7 +667,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApplicationSummaryDto"
"$ref": "#/components/schemas/AanvraagSummaryDto"
}
}
}
@@ -766,7 +766,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApplicationSummaryDto"
"$ref": "#/components/schemas/AanvraagSummaryDto"
}
}
}
@@ -1484,57 +1484,7 @@
},
"additionalProperties": false
},
"AanvraagStatusDto": {
"type": "object",
"properties": {
"tag": {
"type": "string",
"nullable": true
},
"stepIndex": {
"type": "integer",
"format": "int32",
"nullable": true
},
"stepCount": {
"type": "integer",
"format": "int32",
"nullable": true
},
"referentie": {
"type": "string",
"nullable": true
},
"manual": {
"type": "boolean",
"nullable": true
},
"reden": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"AdresDto": {
"type": "object",
"properties": {
"straat": {
"type": "string",
"nullable": true
},
"postcode": {
"type": "string",
"nullable": true
},
"woonplaats": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"ApplicationDetailDto": {
"AanvraagDetailDto": {
"type": "object",
"properties": {
"id": {
@@ -1573,7 +1523,83 @@
},
"additionalProperties": false
},
"ApplicationSummaryDto": {
"AanvraagIndienenRequest": {
"type": "object",
"properties": {
"diplomaHerkomst": {
"type": "string",
"nullable": true
},
"uren": {
"type": "integer",
"format": "int32",
"nullable": true
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
},
"aanvullendeScholing": {
"type": "boolean",
"nullable": true
},
"scholingPunten": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
},
"AanvraagIndienenResponse": {
"type": "object",
"properties": {
"referentie": {
"type": "string",
"nullable": true
},
"status": {
"$ref": "#/components/schemas/AanvraagStatusDto"
}
},
"additionalProperties": false
},
"AanvraagStatusDto": {
"type": "object",
"properties": {
"tag": {
"type": "string",
"nullable": true
},
"stepIndex": {
"type": "integer",
"format": "int32",
"nullable": true
},
"stepCount": {
"type": "integer",
"format": "int32",
"nullable": true
},
"referentie": {
"type": "string",
"nullable": true
},
"manual": {
"type": "boolean",
"nullable": true
},
"reden": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"AanvraagSummaryDto": {
"type": "object",
"properties": {
"id": {
@@ -1613,6 +1639,24 @@
},
"additionalProperties": false
},
"AdresDto": {
"type": "object",
"properties": {
"straat": {
"type": "string",
"nullable": true
},
"postcode": {
"type": "string",
"nullable": true
},
"woonplaats": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"AuthzAuditDto": {
"type": "object",
"properties": {
@@ -1674,7 +1718,7 @@
"type": "object",
"properties": {
"aanvraag": {
"$ref": "#/components/schemas/ApplicationSummaryDto"
"$ref": "#/components/schemas/AanvraagSummaryDto"
},
"documenten": {
"type": "array",
@@ -1860,7 +1904,7 @@
},
"additionalProperties": false
},
"CreateApplicationRequest": {
"CreateAanvraagRequest": {
"type": "object",
"properties": {
"type": {
@@ -2678,50 +2722,6 @@
},
"additionalProperties": false
},
"SubmitApplicationRequest": {
"type": "object",
"properties": {
"diplomaHerkomst": {
"type": "string",
"nullable": true
},
"uren": {
"type": "integer",
"format": "int32",
"nullable": true
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
},
"aanvullendeScholing": {
"type": "boolean",
"nullable": true
},
"scholingPunten": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
},
"SubmitApplicationResponse": {
"type": "object",
"properties": {
"referentie": {
"type": "string",
"nullable": true
},
"status": {
"$ref": "#/components/schemas/AanvraagStatusDto"
}
},
"additionalProperties": false
},
"UploadCategoriesDto": {
"type": "object",
"properties": {
@@ -8,27 +8,27 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
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
// (IClassFixture). Clear any leftover Concept so each test starts from a clean slate.
var existing = await List();
Assert.NotNull(existing);
foreach (var s in existing.Where(x => x.Status.Tag == "Concept"))
await _client.DeleteAsync($"/api/v1/applications/{s.Id}");
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
await _client.DeleteAsync($"/api/v1/aanvragen/{s.Id}");
var res = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type });
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
var created = await res.Content.ReadFromJsonAsync<ApplicationDetailDto>();
var created = await res.Content.ReadFromJsonAsync<AanvraagDetailDto>();
Assert.NotNull(created);
return created;
}
private Task<List<ApplicationSummaryDto>?> List() =>
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
private Task<List<AanvraagSummaryDto>?> List() =>
_client.GetFromJsonAsync<List<AanvraagSummaryDto>>("/api/v1/aanvragen");
// --- Lifecycle over HTTP ---
@@ -36,7 +36,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Create_then_list_shows_a_concept_with_step_progress()
{
var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}",
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
var list = await List();
@@ -51,10 +51,10 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Draft_sync_is_readable_back_from_detail()
{
var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}",
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
var detail = await _client.GetFromJsonAsync<AanvraagDetailDto>($"/api/v1/aanvragen/{a.Id}");
Assert.NotNull(detail);
Assert.NotNull(detail.Draft);
Assert.Equal("verpleegkundige", detail.Draft.Value.GetProperty("beroep").GetString());
@@ -64,9 +64,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
{
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.StartsWith("BIG-2026-", body.Referentie);
Assert.Equal("InBehandeling", body.Status.Tag);
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
@@ -76,9 +76,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
{
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
res.EnsureSuccessStatusCode(); // no longer a 422
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.Equal("InBehandeling", body.Status.Tag);
Assert.True(body.Status.Manual);
}
@@ -87,9 +87,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
{
var a = await Create("herregistratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 0 });
res.EnsureSuccessStatusCode(); // the submission is accepted...
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
Assert.NotNull(body.Status.Reden);
}
@@ -98,8 +98,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Submitting_twice_conflicts()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var again = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
@@ -109,7 +109,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Creating_a_second_concept_of_the_same_type_conflicts()
{
await Create("herregistratie");
var dup = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" });
var dup = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode);
}
@@ -117,7 +117,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task A_concept_of_a_different_type_is_allowed()
{
await Create("registratie");
var other = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" });
var other = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
Assert.Equal(HttpStatusCode.Created, other.StatusCode);
}
@@ -125,8 +125,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task A_new_concept_is_allowed_once_the_previous_one_is_submitted()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var next = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var next = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
Assert.Equal(HttpStatusCode.Created, next.StatusCode);
}
@@ -134,38 +134,38 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Cancel_concept_removes_it()
{
var a = await Create();
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
}
[Fact]
public async Task Cancel_submitted_aanvraag_conflicts()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
}
// --- WP-53: citizen-scoping — GET /applications must never leak across identities. ---
// --- WP-53: citizen-scoping — GET /aanvragen must never leak across identities. ---
[Fact]
public async Task Applications_are_scoped_to_the_caller_bsn()
{
var mine = await Create("intake");
var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/applications")
var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/aanvragen")
{
Content = JsonContent.Create(new { type = "intake" }),
Headers = { { "X-Subject", "999888777" } },
};
var otherRes = await _client.SendAsync(createOther);
Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode);
var other = (await otherRes.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var other = (await otherRes.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
try
{
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } };
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/aanvragen") { Headers = { { "X-Subject", "999888777" } } };
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
Assert.Contains(theirCases, c => c.Id == other.Id);
Assert.DoesNotContain(theirCases, c => c.Id == mine.Id);
@@ -175,9 +175,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
}
finally
{
var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/applications/{other.Id}") { Headers = { { "X-Subject", "999888777" } } };
var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/aanvragen/{other.Id}") { Headers = { { "X-Subject", "999888777" } } };
await _client.SendAsync(deleteOther);
await _client.DeleteAsync($"/api/v1/applications/{mine.Id}");
await _client.DeleteAsync($"/api/v1/aanvragen/{mine.Id}");
}
}
@@ -205,7 +205,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit",
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit",
new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
@@ -221,7 +221,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie");
var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
var res = await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}",
new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
@@ -9,7 +9,7 @@ namespace BigRegister.Tests.Acceptance;
/// <summary>
/// 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
/// cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/> rather
/// 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) =>
_client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", body);
_client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", body);
[Fact]
public async Task Below_threshold_without_an_answer_is_rejected_and_stays_a_concept()
@@ -117,7 +117,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi
// Then the submission is accepted and resolves to Afgewezen — not a 400.
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
}
}
@@ -18,11 +18,11 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
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);
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
return (await res.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
}
[Fact]
@@ -33,7 +33,7 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
{
var list = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"));
list.EnsureSuccessStatusCode();
var cases = (await list.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var cases = (await list.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var mine = cases.Single(x => x.Id == a.Id);
// RB-03/BIO-003: the owner is carried, but masked — it is a BSN, and this list is
// read by someone who is not the subject.
@@ -57,13 +57,13 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
public async Task Admin_can_delete_a_submitted_case()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }))
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" }))
.EnsureSuccessStatusCode();
// The user-facing DELETE refuses a submitted case (409); admin delete removes it.
var del = await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}"));
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
}
[Fact]
@@ -17,12 +17,12 @@ namespace BigRegister.Tests;
file sealed class IdMismatchZaakSource : IZaakSource
{
private readonly LocalZaakSource inner = new();
private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" };
private static AanvraagSummaryDto Rekey(AanvraagSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" };
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) =>
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();
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(
@@ -67,13 +67,13 @@ public class BeoordelingIdMismatchTests
using var factory = Factory();
using var client = factory.CreateClient();
var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var app = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" });
var created = await client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var app = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
var submit = await client.PostAsJsonAsync($"/api/v1/aanvragen/{app.Id}/submit", new { diplomaHerkomst = "handmatig" });
submit.EnsureSuccessStatusCode();
var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad"));
var items = (await werkvoorraad.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var items = (await werkvoorraad.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var caseId = Assert.Single(items).Id;
// Sanity: the id divergence this test exists for is real, not accidentally absent.
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
/// InBehandeling/decidable regardless of test timing (the 8s auto-approval window
/// would otherwise make a duo-registratie/herregistratie fixture flaky).
private async Task<(ApplicationDetailDto App, string DocumentId)> CreateManualCaseWithDocument()
private async Task<(AanvraagDetailDto App, string DocumentId)> CreateManualCaseWithDocument()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var a = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
var localId = Guid.NewGuid().ToString();
var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf"));
upload.EnsureSuccessStatusCode();
var doc = (await upload.Content.ReadFromJsonAsync<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",
documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } },
@@ -88,8 +88,8 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
[Fact]
public async Task Concept_and_unknown_id_are_not_found()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var concept = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var concept = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
try
{
var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}"));
@@ -100,7 +100,7 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
}
finally
{
await _client.DeleteAsync($"/api/v1/applications/{concept.Id}");
await _client.DeleteAsync($"/api/v1/aanvragen/{concept.Id}");
}
}
@@ -202,9 +202,9 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
var doc = await Upload(Guid.NewGuid().ToString());
// 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).
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var aanvraag = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit",
var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var aanvraag = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{aanvraag.Id}/submit",
new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } });
submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
@@ -51,16 +51,16 @@ public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture
{
try
{
// Off → POST /applications for a registratie is refused.
// Off → POST /aanvragen for a registratie is refused.
(await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false })))
.EnsureSuccessStatusCode();
var blocked = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var blocked = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode);
// On → allowed again.
(await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true })))
.EnsureSuccessStatusCode();
var ok = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var ok = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
}
finally
@@ -63,7 +63,7 @@ public class OpenZaakIntegrationTests
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage)
var cases = await client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/admin/cases");
var cases = await client.GetFromJsonAsync<List<AanvraagSummaryDto>>("/api/v1/admin/cases");
Assert.NotNull(cases);
// 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.
///
/// The allow-list is deliberately not "public routes" — most of its entries are NOT public.
/// `GET /applications/{id}` requires a caller identity and is scoped to that caller's own BSN
/// `GET /aanvragen/{id}` requires a caller identity and is scoped to that caller's own BSN
/// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the
/// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point
/// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down")
@@ -58,12 +58,12 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt
new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."),
new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."),
new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."),
new("GET", "/api/v1/applications", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."),
new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."),
new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."),
new("PUT", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."),
new("DELETE", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."),
new("POST", "/api/v1/applications/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."),
new("GET", "/api/v1/aanvragen", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."),
new("GET", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."),
new("POST", "/api/v1/aanvragen", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."),
new("PUT", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."),
new("DELETE", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."),
new("POST", "/api/v1/aanvragen/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."),
// --- External caller, not a Principal at all. ---
new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."),
@@ -18,11 +18,11 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
return req;
}
private async Task<ApplicationDetailDto> CreateAndSubmitHerregistratie()
private async Task<AanvraagDetailDto> CreateAndSubmitHerregistratie()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 200 }))
var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 200 }))
.EnsureSuccessStatusCode();
return a;
}
@@ -35,7 +35,7 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
{
var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad"));
res.EnsureSuccessStatusCode();
var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var queue = (await res.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var mine = queue.Single(x => x.Id == a.Id);
Assert.Equal("InBehandeling", mine.Status.Tag);
// RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto.
@@ -53,18 +53,18 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
[Fact]
public async Task Queue_excludes_concepts()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
try
{
var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad"));
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);
}
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))));
}
/// <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
/// 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")
{
var res = await client.PostAsJsonAsync("/api/v1/applications", new { type });
var res = await client.PostAsJsonAsync("/api/v1/aanvragen", new { type });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var body = (await res.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
return body.Id;
}
@@ -78,11 +78,11 @@ public class ZgwDivergenceTests
using var client = factory.CreateClient();
var id = await CreateConcept(client);
var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" });
var res = await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" });
// The local write is still authoritative: 200 with a real reference, not a 500.
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
Assert.NotEmpty(body.Referentie);
var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
@@ -103,7 +103,7 @@ public class ZgwDivergenceTests
using var client = factory.CreateClient();
var id = await CreateConcept(client);
var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" });
var res = await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" });
res.EnsureSuccessStatusCode();
var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
@@ -126,7 +126,7 @@ public class ZgwDivergenceTests
using var client = factory.CreateClient();
var id = await CreateConcept(client);
(await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }))
(await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" }))
.EnsureSuccessStatusCode();
var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError;
+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
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 497 frontend behaviours across
**is** the suite, reshaped for a business reader. 505 frontend behaviours across
9 contexts; 261 backend behaviours across 42 test
classes.
@@ -418,6 +418,13 @@ classes.
### registratie
#### AanvragenStore
- loads and parses the list
- cancels optimistically and confirms via the DELETE endpoint
- rolls back the removal and surfaces the error when the cancel fails
- clears a stale error on the next cancel attempt
#### AdminCasesStore
- loads and parses the cross-owner list
@@ -425,13 +432,6 @@ classes.
- rolls back the removal and surfaces the error when the delete fails
- clears a stale error on the next delete attempt
#### ApplicationsStore
- loads and parses the list
- cancels optimistically and confirms via the DELETE endpoint
- rolls back the removal and surfaces the error when the cancel fails
- clears a stale error on the next cancel attempt
#### STEPS (fixed)
- always has the same three steps
@@ -544,12 +544,12 @@ classes.
- parses each tag with its required fields
- rejects a missing status, unknown tag, and wrong-typed fields
#### parseApplicationSummary
#### parseAanvraagSummary
- maps a valid DTO to domain
- rejects a bad type and non-objects
#### parseApplications / parseApplicationDetail
#### parseAanvragen / parseAanvraagDetail
- parses a list and fails fast on a bad element
- carries the opaque draft through detail
@@ -569,6 +569,9 @@ classes.
- maps a valid response into a DashboardView
- rejects malformed responses instead of trusting them
- rejects a status whose tag is present but its required fields are missing
- rejects an unknown status tag
- rejects a person with an incomplete adres
#### parseDuoLookup (trust boundary)
@@ -617,6 +620,12 @@ classes.
- statusColor is total over the union
- herregistratieDeadline is only set for an active registration
#### sortForDashboard / concepten / ingediend
- sorts Concept, then still-open, then resolved last
- does not mutate the input array
- concepten/ingediend split on the Concept tag
#### submit
- stays in Invullen when the draft is incomplete (no diploma)
@@ -923,6 +932,11 @@ classes.
- leaves a non-API request untouched even when a subject is known
- sends no header at all when no subject has ever been seen
#### successOf
- unwraps a Success value
- is undefined for every other state
#### upload lifecycle messages
- queued → progress → complete
@@ -963,18 +977,7 @@ classes.
## Backend (by test class)
### AdminCasesTests
- Admin lists every case with its owner
- Non admin is forbidden
- Admin can delete a submitted case
- Deleting a missing case is not found
### ApplicationRuleTests
- AanvraagStatusTag covers the published lifecycle
### ApplicationTests
### AanvraagTests
- Create then list shows a concept with step progress
- Draft sync is readable back from detail
@@ -993,6 +996,17 @@ classes.
- AutoApprovable flips to goedgekeurd after the window
- Manual case never auto advances
### AdminCasesTests
- Admin lists every case with its owner
- Non admin is forbidden
- Admin can delete a submitted case
- Deleting a missing case is not found
### ApplicationRuleTests
- AanvraagStatusTag covers the published lifecycle
### AuthzAuditTests
- A denied admin action is recorded
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data';
import { loading, failure, success } from '../testing/remote-data';
import { RemoteData, map2, map, successOf } from './remote-data';
import { loading, failure, empty, success } from '../testing/remote-data';
const loadingRd: RemoteData<string, number> = loading();
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 });
});
});
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> {
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
*/
applicationsAll(): Promise<ApplicationSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/applications";
aanvragenAll(): Promise<AanvraagSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/aanvragen";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
@@ -649,17 +649,17 @@ export class ApiClient {
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsAll(_response);
return this.processAanvragenAll(_response);
});
}
protected processApplicationsAll(response: Response): Promise<ApplicationSummaryDto[]> {
protected processAanvragenAll(response: Response): Promise<AanvraagSummaryDto[]> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[];
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[];
return result200;
});
} else if (status !== 200 && status !== 204) {
@@ -667,14 +667,14 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ApplicationSummaryDto[]>(null as any);
return Promise.resolve<AanvraagSummaryDto[]>(null as any);
}
/**
* @return Created
*/
applicationsPOST(body: CreateApplicationRequest): Promise<ApplicationDetailDto> {
let url_ = this.baseUrl + "/api/v1/applications";
aanvragenPOST(body: CreateAanvraagRequest): Promise<AanvraagDetailDto> {
let url_ = this.baseUrl + "/api/v1/aanvragen";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
@@ -689,17 +689,17 @@ export class ApiClient {
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsPOST(_response);
return this.processAanvragenPOST(_response);
});
}
protected processApplicationsPOST(response: Response): Promise<ApplicationDetailDto> {
protected processAanvragenPOST(response: Response): Promise<AanvraagDetailDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 201) {
return response.text().then((_responseText) => {
let result201: any = null;
result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationDetailDto;
result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagDetailDto;
return result201;
});
} else if (status === 409) {
@@ -713,14 +713,14 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ApplicationDetailDto>(null as any);
return Promise.resolve<AanvraagDetailDto>(null as any);
}
/**
* @return OK
*/
applicationsGET(id: string): Promise<ApplicationDetailDto> {
let url_ = this.baseUrl + "/api/v1/applications/{id}";
aanvragenGET(id: string): Promise<AanvraagDetailDto> {
let url_ = this.baseUrl + "/api/v1/aanvragen/{id}";
if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -734,17 +734,17 @@ export class ApiClient {
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsGET(_response);
return this.processAanvragenGET(_response);
});
}
protected processApplicationsGET(response: Response): Promise<ApplicationDetailDto> {
protected processAanvragenGET(response: Response): Promise<AanvraagDetailDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationDetailDto;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagDetailDto;
return result200;
});
} else if (status === 404) {
@@ -756,14 +756,14 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ApplicationDetailDto>(null as any);
return Promise.resolve<AanvraagDetailDto>(null as any);
}
/**
* @return No Content
*/
applicationsPUT(id: string, body: DraftSyncRequest): Promise<void> {
let url_ = this.baseUrl + "/api/v1/applications/{id}";
aanvragenPUT(id: string, body: DraftSyncRequest): Promise<void> {
let url_ = this.baseUrl + "/api/v1/aanvragen/{id}";
if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -780,11 +780,11 @@ export class ApiClient {
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsPUT(_response);
return this.processAanvragenPUT(_response);
});
}
protected processApplicationsPUT(response: Response): Promise<void> {
protected processAanvragenPUT(response: Response): Promise<void> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 204) {
@@ -812,8 +812,8 @@ export class ApiClient {
/**
* @return No Content
*/
applicationsDELETE(id: string): Promise<void> {
let url_ = this.baseUrl + "/api/v1/applications/{id}";
aanvragenDELETE(id: string): Promise<void> {
let url_ = this.baseUrl + "/api/v1/aanvragen/{id}";
if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -826,11 +826,11 @@ export class ApiClient {
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApplicationsDELETE(_response);
return this.processAanvragenDELETE(_response);
});
}
protected processApplicationsDELETE(response: Response): Promise<void> {
protected processAanvragenDELETE(response: Response): Promise<void> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 204) {
@@ -858,8 +858,8 @@ export class ApiClient {
/**
* @return OK
*/
submit(id: string, body: SubmitApplicationRequest): Promise<SubmitApplicationResponse> {
let url_ = this.baseUrl + "/api/v1/applications/{id}/submit";
submit(id: string, body: AanvraagIndienenRequest): Promise<AanvraagIndienenResponse> {
let url_ = this.baseUrl + "/api/v1/aanvragen/{id}/submit";
if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id));
@@ -881,13 +881,13 @@ export class ApiClient {
});
}
protected processSubmit(response: Response): Promise<SubmitApplicationResponse> {
protected processSubmit(response: Response): Promise<AanvraagIndienenResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagIndienenResponse;
return result200;
});
} else if (status === 400) {
@@ -911,13 +911,13 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<SubmitApplicationResponse>(null as any);
return Promise.resolve<AanvraagIndienenResponse>(null as any);
}
/**
* @return OK
*/
casesAll(): Promise<ApplicationSummaryDto[]> {
casesAll(): Promise<AanvraagSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/admin/cases";
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;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[];
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[];
return result200;
});
} else if (status === 403) {
@@ -953,7 +953,7 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ApplicationSummaryDto[]>(null as any);
return Promise.resolve<AanvraagSummaryDto[]>(null as any);
}
/**
@@ -1047,7 +1047,7 @@ export class ApiClient {
/**
* @return OK
*/
werkvoorraad(): Promise<ApplicationSummaryDto[]> {
werkvoorraad(): Promise<AanvraagSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/werkvoorraad";
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;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[];
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[];
return result200;
});
} else if (status === 403) {
@@ -1083,7 +1083,7 @@ export class ApiClient {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<ApplicationSummaryDto[]>(null as any);
return Promise.resolve<AanvraagSummaryDto[]>(null as any);
}
/**
@@ -1898,22 +1898,7 @@ export interface AantekeningDto {
datum?: string | undefined;
}
export interface AanvraagStatusDto {
tag?: string | undefined;
stepIndex?: number | undefined;
stepCount?: number | undefined;
referentie?: string | undefined;
manual?: boolean | undefined;
reden?: string | undefined;
}
export interface AdresDto {
straat?: string | undefined;
postcode?: string | undefined;
woonplaats?: string | undefined;
}
export interface ApplicationDetailDto {
export interface AanvraagDetailDto {
id?: string | undefined;
type?: string | undefined;
status?: AanvraagStatusDto;
@@ -1924,7 +1909,29 @@ export interface ApplicationDetailDto {
submittedAt?: string | undefined;
}
export interface ApplicationSummaryDto {
export interface AanvraagIndienenRequest {
diplomaHerkomst?: string | undefined;
uren?: number | undefined;
documents?: DocumentRefDto[] | undefined;
aanvullendeScholing?: boolean | undefined;
scholingPunten?: number | undefined;
}
export interface AanvraagIndienenResponse {
referentie?: string | undefined;
status?: AanvraagStatusDto;
}
export interface AanvraagStatusDto {
tag?: string | undefined;
stepIndex?: number | undefined;
stepCount?: number | undefined;
referentie?: string | undefined;
manual?: boolean | undefined;
reden?: string | undefined;
}
export interface AanvraagSummaryDto {
id?: string | undefined;
type?: string | undefined;
status?: AanvraagStatusDto;
@@ -1935,6 +1942,12 @@ export interface ApplicationSummaryDto {
owner?: string | undefined;
}
export interface AdresDto {
straat?: string | undefined;
postcode?: string | undefined;
woonplaats?: string | undefined;
}
export interface AuthzAuditDto {
at?: string | undefined;
action?: string | undefined;
@@ -1955,7 +1968,7 @@ export interface BeoordelingDocumentDto {
}
export interface BeoordelingViewDto {
aanvraag?: ApplicationSummaryDto;
aanvraag?: AanvraagSummaryDto;
documenten?: BeoordelingDocumentDto[] | undefined;
decisions?: BeoordelingDecisionsDto;
}
@@ -2014,7 +2027,7 @@ export interface ChangeRequestRequest {
telefoon?: string | undefined;
}
export interface CreateApplicationRequest {
export interface CreateAanvraagRequest {
type?: string | undefined;
}
@@ -2275,19 +2288,6 @@ export interface SubOrgSummaryDto {
publishedVersion?: number;
}
export interface SubmitApplicationRequest {
diplomaHerkomst?: string | undefined;
uren?: number | undefined;
documents?: DocumentRefDto[] | undefined;
aanvullendeScholing?: boolean | undefined;
scholingPunten?: number | undefined;
}
export interface SubmitApplicationResponse {
referentie?: string | undefined;
status?: AanvraagStatusDto;
}
export interface UploadCategoriesDto {
categories?: DocumentCategoryDto[] | undefined;
}
+2
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 failure = <E, T = never>(error: E): RemoteData<E, T> => ({ tag: 'Failure', error });
export const empty = <E = never, T = never>(): RemoteData<E, T> => ({ tag: 'Empty' });