diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts index c06f4a3..c48346b 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client'; +import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client'; import { WerkvoorraadItem, WerkvoorraadStatus, @@ -18,7 +18,7 @@ import { export class WerkvoorraadAdapter { private client = inject(ApiClient); - list(): Promise { + list(): Promise { return this.client.werkvoorraad(); } } @@ -26,7 +26,7 @@ export class WerkvoorraadAdapter { const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake']; function parseWerkvoorraadStatus( - s: ApplicationSummaryDto['status'] | undefined, + s: AanvraagSummaryDto['status'] | undefined, ): Result { if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status'); switch (s.tag) { @@ -44,7 +44,7 @@ function parseWerkvoorraadStatus( export function parseWerkvoorraadItem(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object'); - const dto = json as ApplicationSummaryDto; + const dto = json as AanvraagSummaryDto; if (typeof dto.id !== 'string') return err('werkvoorraad: missing id'); if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) return err(`werkvoorraad: bad type ${dto.type}`); diff --git a/apps/ssp/src/app/registratie/application/applications.store.spec.ts b/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts similarity index 83% rename from apps/ssp/src/app/registratie/application/applications.store.spec.ts rename to apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts index f8860af..8147917 100644 --- a/apps/ssp/src/app/registratie/application/applications.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/aanvragen.store.spec.ts @@ -1,8 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi } from 'vitest'; import { SUBMIT_FAILED } from '@shared/application/submit'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; -import { ApplicationsStore } from './applications.store'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; +import { AanvragenStore } from './aanvragen.store'; const summary = (id: string) => ({ id, @@ -13,20 +13,20 @@ const summary = (id: string) => ({ updatedAt: '2026-07-23T10:00:00Z', }); -function setup(adapter: Partial): ApplicationsStore { +function setup(adapter: Partial): AanvragenStore { TestBed.configureTestingModule({ - providers: [{ provide: ApplicationsAdapter, useValue: adapter }], + providers: [{ provide: AanvragenAdapter, useValue: adapter }], }); // The store's own constructor kicks off `load()` (dashboard revisit refresh) — // give every test a `list` so that initial call has something to resolve. - return TestBed.inject(ApplicationsStore); + return TestBed.inject(AanvragenStore); } -describe('ApplicationsStore', () => { +describe('AanvragenStore', () => { it('loads and parses the list', async () => { const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) }); await store.load(); - const s = store.applications(); + const s = store.aanvragen(); expect(s.tag).toBe('Success'); expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']); }); @@ -41,7 +41,7 @@ describe('ApplicationsStore', () => { await store.cancel('a'); expect(cancel).toHaveBeenCalledWith('a'); - const s = store.applications(); + const s = store.aanvragen(); expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']); expect(store.lastError()).toBeNull(); }); @@ -55,7 +55,7 @@ describe('ApplicationsStore', () => { await store.load(); await store.cancel('a'); - const s = store.applications(); + const s = store.aanvragen(); expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears expect(store.lastError()).toBe(SUBMIT_FAILED); }); diff --git a/apps/ssp/src/app/registratie/application/applications.store.ts b/apps/ssp/src/app/registratie/application/aanvragen.store.ts similarity index 86% rename from apps/ssp/src/app/registratie/application/applications.store.ts rename to apps/ssp/src/app/registratie/application/aanvragen.store.ts index 5157dad..0095cc7 100644 --- a/apps/ssp/src/app/registratie/application/applications.store.ts +++ b/apps/ssp/src/app/registratie/application/aanvragen.store.ts @@ -2,15 +2,12 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter'; type Err = Error | undefined; /** - * The dashboard's view of the user's applications (aanvragen) — the backend is the + * The dashboard's view of the user's aanvragen — the backend is the * system of record (PRD 0001). One root singleton OWNS the list as a writable * RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes * the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on @@ -20,11 +17,11 @@ type Err = Error | undefined; * surfaces `lastError` on failure (RB-20). */ @Injectable({ providedIn: 'root' }) -export class ApplicationsStore { - private adapter = inject(ApplicationsAdapter); +export class AanvragenStore { + private adapter = inject(AanvragenAdapter); private state = signal>({ tag: 'Loading' }); - readonly applications = this.state.asReadonly(); + readonly aanvragen = this.state.asReadonly(); /** Set on a failed cancel (RB-20): the optimistic removal already rolled back by then, this is only the message for the alert the page renders above the list. */ @@ -40,7 +37,7 @@ export class ApplicationsStore { async load() { if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); try { - const parsed = parseApplications(await this.adapter.list()); + const parsed = parseAanvragen(await this.adapter.list()); this.state.set( parsed.ok ? { tag: 'Success', value: parsed.value } diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts index ffab1b2..851368d 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi } from 'vitest'; import { SUBMIT_FAILED } from '@shared/application/submit'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { AdminCasesStore } from './admin-cases.store'; const summary = (id: string) => ({ @@ -14,9 +14,9 @@ const summary = (id: string) => ({ owner: '19012345601', }); -function setup(adapter: Partial): AdminCasesStore { +function setup(adapter: Partial): AdminCasesStore { TestBed.configureTestingModule({ - providers: [{ provide: ApplicationsAdapter, useValue: adapter }], + providers: [{ provide: AanvragenAdapter, useValue: adapter }], }); return TestBed.inject(AdminCasesStore); } diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.ts index b4580b9..a3b0ad5 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.ts @@ -2,16 +2,13 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter'; type Err = Error | undefined; /** * Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office - * counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton + * counterpart of the user-facing `AanvragenStore`. Same shape: one root singleton * owns the list as a writable RemoteData signal, delete removes the row synchronously * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on * failure (RB-20). Admin delete removes any case (any owner, submitted or not — the @@ -19,7 +16,7 @@ type Err = Error | undefined; */ @Injectable({ providedIn: 'root' }) export class AdminCasesStore { - private adapter = inject(ApplicationsAdapter); + private adapter = inject(AanvragenAdapter); private state = signal>({ tag: 'Loading' }); readonly cases = this.state.asReadonly(); @@ -34,7 +31,7 @@ export class AdminCasesStore { async load() { if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); try { - const parsed = parseApplications(await this.adapter.listAll()); + const parsed = parseAanvragen(await this.adapter.listAll()); this.state.set( parsed.ok ? { tag: 'Success', value: parsed.value } diff --git a/apps/ssp/src/app/registratie/application/big-profile.store.ts b/apps/ssp/src/app/registratie/application/big-profile.store.ts index 704429c..c590e96 100644 --- a/apps/ssp/src/app/registratie/application/big-profile.store.ts +++ b/apps/ssp/src/app/registratie/application/big-profile.store.ts @@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { RemoteData, fromResource, map } from '@shared/application/remote-data'; import { Aantekening } from '../domain/registration'; import { BigProfile } from '../domain/big-profile'; -import { HerregistratieDecisions } from '../contracts/dashboard-view.dto'; +import { HerregistratieDecisions } from '../domain/registration'; import { BigRegisterAdapter } from '../infrastructure/big-register.adapter'; import { DashboardView, diff --git a/apps/ssp/src/app/registratie/application/draft-sync.spec.ts b/apps/ssp/src/app/registratie/application/draft-sync.spec.ts index 3b16cf5..c69ee82 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.spec.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.spec.ts @@ -2,14 +2,14 @@ import { ApplicationRef, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { createDraftSync, DraftSnapshot } from './draft-sync'; -function setup(adapter: Partial) { +function setup(adapter: Partial) { const navigate = vi.fn().mockResolvedValue(true); TestBed.configureTestingModule({ providers: [ - { provide: ApplicationsAdapter, useValue: adapter }, + { provide: AanvragenAdapter, useValue: adapter }, { provide: Router, useValue: { navigate } }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } }, ], diff --git a/apps/ssp/src/app/registratie/application/draft-sync.ts b/apps/ssp/src/app/registratie/application/draft-sync.ts index eec4426..07b5b18 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.ts @@ -4,11 +4,11 @@ import { Result } from '@shared/kernel/fp'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { registerPendingSave } from '@shared/application/pending-saves'; import type { - SubmitApplicationRequest, - SubmitApplicationResponse, + AanvraagIndienenRequest, + AanvraagIndienenResponse, } from '@shared/infrastructure/api-client'; import { AanvraagType } from '@registratie/domain/aanvraag'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { findConcept, loadConcept } from './find-concept'; /** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ @@ -46,7 +46,7 @@ const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels lag * Inert without a Router (stories) or when `enabled()` is false — no network, no resume. */ export function createDraftSync(deps: DraftSyncDeps) { - const adapter = inject(ApplicationsAdapter); + const adapter = inject(AanvragenAdapter); const router = inject(Router, { optional: true }); const route = inject(ActivatedRoute, { optional: true }); const active = () => deps.enabled() && !!router && !!route; @@ -190,7 +190,7 @@ export function createDraftSync(deps: DraftSyncDeps) { /** Submit through the aanvraag lifecycle: ensure the Concept exists, then `POST /applications/{id}/submit` (server sets autoApprovable + transitions). Folded into a Result like the old submit-* commands. */ - submit(body: SubmitApplicationRequest): Promise> { + submit(body: AanvraagIndienenRequest): Promise> { return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED); }, diff --git a/apps/ssp/src/app/registratie/application/find-concept.spec.ts b/apps/ssp/src/app/registratie/application/find-concept.spec.ts index c617ad0..d29363a 100644 --- a/apps/ssp/src/app/registratie/application/find-concept.spec.ts +++ b/apps/ssp/src/app/registratie/application/find-concept.spec.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter'; import { findConcept, loadConcept } from './find-concept'; // Free functions taking the adapter as a parameter (no inject()) — a plain fake // object is enough, no Angular TestBed needed. -function fakeAdapter(overrides: Partial): ApplicationsAdapter { - return overrides as ApplicationsAdapter; +function fakeAdapter(overrides: Partial): AanvragenAdapter { + return overrides as AanvragenAdapter; } describe('findConcept', () => { diff --git a/apps/ssp/src/app/registratie/application/find-concept.ts b/apps/ssp/src/app/registratie/application/find-concept.ts index 6b0cf75..dfbe364 100644 --- a/apps/ssp/src/app/registratie/application/find-concept.ts +++ b/apps/ssp/src/app/registratie/application/find-concept.ts @@ -1,8 +1,5 @@ import { AanvraagType } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { AanvragenAdapter, parseAanvragen } from '@registratie/infrastructure/aanvragen.adapter'; /** * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs @@ -14,11 +11,11 @@ import { /** Find the user's existing Concept of a given type (at most one), if any. */ export async function findConcept( - adapter: ApplicationsAdapter, + adapter: AanvragenAdapter, type: AanvraagType, ): Promise { try { - const parsed = parseApplications(await adapter.list()); + const parsed = parseAanvragen(await adapter.list()); return parsed.ok ? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id : undefined; @@ -34,10 +31,7 @@ export async function findConcept( export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' }; /** Load a specific Concept by id and report whether it is still editable. */ -export async function loadConcept( - adapter: ApplicationsAdapter, - id: string, -): Promise { +export async function loadConcept(adapter: AanvragenAdapter, id: string): Promise { try { const dto = await adapter.detail(id); if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' }; diff --git a/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts b/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts index 3b93480..65abebb 100644 --- a/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts +++ b/apps/ssp/src/app/registratie/application/registratie-lookup.store.ts @@ -41,7 +41,7 @@ export class RegistratieLookupStore { const json = this.adresRes.value(); if (json === undefined) return null; const parsed = parseBrpAddress(json); - return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null; + return parsed.ok && parsed.value.adres ? parsed.value.adres : null; }, ); diff --git a/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts b/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts deleted file mode 100644 index d681a7d..0000000 --- a/apps/ssp/src/app/registratie/contracts/brp-address.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * WIRE CONTRACT for the BRP address lookup ("BFF-lite" — one screen-shaped call). - * - * In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our - * own backend, which talks to the BRP behind an adapter. The frontend never sees - * the BRP's own wire format. See docs/reference/architecture/0001-bff-lite-decision-dtos.md. - * - * "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error — - * the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy - * path (gevonden: true). - */ -export interface BrpAddressDto { - gevonden: boolean; - adres?: { straat: string; postcode: string; woonplaats: string }; -} diff --git a/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts b/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts deleted file mode 100644 index d156ec7..0000000 --- a/apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * WIRE CONTRACT for the dashboard screen — the "BFF-lite" response. - * - * PURE wire shapes: this file imports NOTHING (CLAUDE.md §1, ADR-0001). Enums are - * inlined string-literal unions that describe the wire, not the domain. The - * adapter's `parseDashboardView` validates this untrusted shape and MAPS it onto - * the FE domain model (Registration/Person/BigProfile) — that map is the - * decoupling seam: the wire can change without the domain following. - * - * In production these types are GENERATED from the OpenAPI/TypeSpec spec (one - * source of truth for both sides), and the `decisions` block is computed BY THE - * BACKEND — never recomputed on the client. The frontend renders decisions; it - * does not own the rules. See docs/reference/architecture/0001-bff-lite-decision-dtos.md. - * - * One screen-shaped call replaces the previous three (BIG-register + BRP + …), - * so the page always sees one consistent snapshot instead of three independently - * loading/erroring resources. - */ - -/** Registration status on the wire: the discriminant tags as they arrive. */ -export type RegistrationStatusDto = - | { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date - | { tag: 'Geschorst'; geschorstTot: string; reden: string } - | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string }; - -export interface RegistrationDto { - bigNummer: string; - naam: string; - beroep: string; - registratiedatum: string; // ISO date - geboortedatum: string; - status: RegistrationStatusDto; -} - -export interface AdresDto { - straat: string; - postcode: string; - woonplaats: string; -} - -export interface PersonDto { - naam: string; - geboortedatum: string; // ISO date - adres: AdresDto; -} - -/** Server-computed decisions. Rendered by the FE as-is (decision DTO, ADR-0001): - the eligibility rule lives on the backend; the optional reason lets the UI - explain itself without knowing the rule. */ -export interface HerregistratieDecisions { - eligibleForHerregistratie: boolean; - herregistratieReason?: string; -} - -export interface DashboardViewDto { - registration: RegistrationDto; - person: PersonDto; - decisions: HerregistratieDecisions; -} diff --git a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts index 17a3234..03eb010 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view'; +import { + submittedRow, + detailRows, + purposeLabel, + statusLabel, + TYPE_LABELS, + sortForDashboard, + concepten, + ingediend, +} from './aanvraag-view'; import { Aanvraag } from './aanvraag'; const base: Omit = { @@ -114,3 +123,40 @@ describe('detailRows', () => { expect(rows.length).toBe(5); }); }); + +describe('sortForDashboard / concepten / ingediend', () => { + const withStatus = (id: string, tag: Aanvraag['status']['tag']): Aanvraag => ({ + ...base, + id, + status: + tag === 'Concept' + ? { tag, stepIndex: 0, stepCount: 1 } + : tag === 'Afgewezen' || tag === 'MeerInfoGevraagd' + ? { tag, referentie: 'R', reden: 'x' } + : tag === 'InBehandeling' + ? { tag, referentie: 'R', manual: false } + : { tag, referentie: 'R' }, + }); + + it('sorts Concept, then still-open, then resolved last', () => { + const goedgekeurd = withStatus('1', 'Goedgekeurd'); + const concept = withStatus('2', 'Concept'); + const inBehandeling = withStatus('3', 'InBehandeling'); + const sorted = sortForDashboard([goedgekeurd, concept, inBehandeling]); + expect(sorted.map((a) => a.id)).toEqual(['2', '3', '1']); + }); + + it('does not mutate the input array', () => { + const list = [withStatus('1', 'Goedgekeurd'), withStatus('2', 'Concept')]; + const copy = [...list]; + sortForDashboard(list); + expect(list).toEqual(copy); + }); + + it('concepten/ingediend split on the Concept tag', () => { + const concept = withStatus('1', 'Concept'); + const ingediendItem = withStatus('2', 'Ingediend'); + expect(concepten([concept, ingediendItem])).toEqual([concept]); + expect(ingediend([concept, ingediendItem])).toEqual([ingediendItem]); + }); +}); diff --git a/apps/ssp/src/app/registratie/domain/aanvraag-view.ts b/apps/ssp/src/app/registratie/domain/aanvraag-view.ts index bcf26bc..ab734d3 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag-view.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag-view.ts @@ -106,3 +106,30 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] { } return rows; } + +/** Dashboard sort order: still-open work first (Concept, then submitted-and-pending), + resolved aanvragen (Goedgekeurd/Afgewezen) last. Within a group, order is stable + (the sort is by rank only). */ +const SORT_RANK: Record = { + Concept: 0, + Ingediend: 1, + InBehandeling: 1, + MeerInfoGevraagd: 1, + Goedgekeurd: 2, + Afgewezen: 2, +}; + +/** The dashboard's "Mijn aanvragen" ordering: open work before resolved cases. */ +export function sortForDashboard(aanvragen: Aanvraag[]): Aanvraag[] { + return aanvragen.slice().sort((a, b) => SORT_RANK[a.status.tag] - SORT_RANK[b.status.tag]); +} + +/** A Concept ("lopende aanvraag") renders as a melding above the list; a submitted + aanvraag as a keuzelijst item — the two shapes need different HTML contexts. */ +export function concepten(aanvragen: Aanvraag[]): Aanvraag[] { + return aanvragen.filter((a) => a.status.tag === 'Concept'); +} + +export function ingediend(aanvragen: Aanvraag[]): Aanvraag[] { + return aanvragen.filter((a) => a.status.tag !== 'Concept'); +} diff --git a/apps/ssp/src/app/registratie/domain/registration.ts b/apps/ssp/src/app/registratie/domain/registration.ts index 2dba1c3..94af1fb 100644 --- a/apps/ssp/src/app/registratie/domain/registration.ts +++ b/apps/ssp/src/app/registratie/domain/registration.ts @@ -33,3 +33,11 @@ export interface Aantekening { omschrijving: string; datum: string; } + +/** Server-computed eligibility for herregistratie (ADR-0001 decision DTO): the FE + renders this as-is, it never recomputes the rule. The optional reason lets the + UI explain a "not eligible" outcome without knowing why. */ +export interface HerregistratieDecisions { + eligibleForHerregistratie: boolean; + herregistratieReason?: string; +} diff --git a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.spec.ts b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.spec.ts similarity index 72% rename from apps/ssp/src/app/registratie/infrastructure/applications.adapter.spec.ts rename to apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.spec.ts index 29b7683..905daf0 100644 --- a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.spec.ts +++ b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.spec.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { parseAanvraagStatus, - parseApplicationSummary, - parseApplications, - parseApplicationDetail, -} from './applications.adapter'; + parseAanvraagSummary, + parseAanvragen, + parseAanvraagDetail, +} from './aanvragen.adapter'; const concept = { id: 'a1', @@ -42,29 +42,29 @@ describe('parseAanvraagStatus', () => { }); }); -describe('parseApplicationSummary', () => { +describe('parseAanvraagSummary', () => { it('maps a valid DTO to domain', () => { - const r = parseApplicationSummary(concept); + const r = parseAanvraagSummary(concept); expect(r.ok && r.value.type).toBe('registratie'); expect(r.ok && r.value.status.tag).toBe('Concept'); }); it('rejects a bad type and non-objects', () => { - expect(parseApplicationSummary({ ...concept, type: 'onbekend' }).ok).toBe(false); - expect(parseApplicationSummary(null).ok).toBe(false); - expect(parseApplicationSummary({ ...concept, id: 42 }).ok).toBe(false); + expect(parseAanvraagSummary({ ...concept, type: 'onbekend' }).ok).toBe(false); + expect(parseAanvraagSummary(null).ok).toBe(false); + expect(parseAanvraagSummary({ ...concept, id: 42 }).ok).toBe(false); }); }); -describe('parseApplications / parseApplicationDetail', () => { +describe('parseAanvragen / parseAanvraagDetail', () => { it('parses a list and fails fast on a bad element', () => { - expect(parseApplications([concept, concept]).ok).toBe(true); - expect(parseApplications([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false); - expect(parseApplications({}).ok).toBe(false); + expect(parseAanvragen([concept, concept]).ok).toBe(true); + expect(parseAanvragen([concept, { ...concept, status: { tag: 'x' } }]).ok).toBe(false); + expect(parseAanvragen({}).ok).toBe(false); }); it('carries the opaque draft through detail', () => { - const r = parseApplicationDetail({ ...concept, draft: { beroep: 'arts' } }); + const r = parseAanvraagDetail({ ...concept, draft: { beroep: 'arts' } }); expect(r.ok && (r.value.draft as { beroep: string }).beroep).toBe('arts'); }); }); diff --git a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts similarity index 77% rename from apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts rename to apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts index 2e8d9d8..a0fa92f 100644 --- a/apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/aanvragen.adapter.ts @@ -3,11 +3,11 @@ import { Result, ok, err } from '@shared/kernel/fp'; import { ApiClient, AanvraagStatusDto, - ApplicationSummaryDto, - ApplicationDetailDto, + AanvraagSummaryDto, + AanvraagDetailDto, DraftSyncRequest, - SubmitApplicationRequest, - SubmitApplicationResponse, + AanvraagIndienenRequest, + AanvraagIndienenResponse, } from '@shared/infrastructure/api-client'; import { Aanvraag, @@ -19,21 +19,21 @@ import { /** * Infrastructure adapter for the backend-owned Aanvraag aggregate — the only place * its HTTP lives (ADR-0001 anti-corruption boundary). The list is a resource; the - * mutations (create/sync/cancel/submit) are thin commands the ApplicationsStore + * mutations (create/sync/cancel/submit) are thin commands the AanvragenStore * orchestrates optimistically. The untrusted response is validated + mapped to * domain by the hand-written parse* boundary below. */ @Injectable({ providedIn: 'root' }) -export class ApplicationsAdapter { +export class AanvragenAdapter { private client = inject(ApiClient); - /** The dashboard's application list (raw DTOs; the store parses at the boundary). */ - list(): Promise { - return this.client.applicationsAll(); + /** The dashboard's aanvraag list (raw DTOs; the store parses at the boundary). */ + list(): Promise { + return this.client.aanvragenAll(); } /** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ - listAll(): Promise { + listAll(): Promise { return this.client.casesAll(); } @@ -42,26 +42,26 @@ export class ApplicationsAdapter { return this.client.cases(id); } - detail(id: string): Promise { - return this.client.applicationsGET(id); + detail(id: string): Promise { + return this.client.aanvragenGET(id); } /** Create a Concept for a wizard type; resolves to the new aanvraag id. */ create(type: AanvraagType): Promise { - return this.client.applicationsPOST({ type }).then((d) => d.id ?? ''); + return this.client.aanvragenPOST({ type }).then((d) => d.id ?? ''); } /** Draft sync per step (idempotent). Keep it debounced at the call site — it is chatty. */ syncDraft(id: string, body: DraftSyncRequest): Promise { - return this.client.applicationsPUT(id, body); + return this.client.aanvragenPUT(id, body); } /** Cancel a Concept (cascades to its unlinked documents server-side). */ cancel(id: string): Promise { - return this.client.applicationsDELETE(id); + return this.client.aanvragenDELETE(id); } - submit(id: string, body: SubmitApplicationRequest): Promise { + submit(id: string, body: AanvraagIndienenRequest): Promise { return this.client.submit(id, body); } } @@ -101,7 +101,7 @@ export function parseAanvraagStatus( } } -function parseCommon(dto: ApplicationSummaryDto): Result { +function parseCommon(dto: AanvraagSummaryDto): Result { if (typeof dto.id !== 'string') return err('aanvraag: missing id'); if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type)) return err(`aanvraag: bad type ${dto.type}`); @@ -121,25 +121,25 @@ function parseCommon(dto: ApplicationSummaryDto): Result { }); } -export function parseApplicationSummary(json: unknown): Result { +export function parseAanvraagSummary(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('aanvraag: not an object'); - return parseCommon(json as ApplicationSummaryDto); + return parseCommon(json as AanvraagSummaryDto); } -export function parseApplications(json: unknown): Result { +export function parseAanvragen(json: unknown): Result { if (!Array.isArray(json)) return err('aanvragen: not an array'); const out: Aanvraag[] = []; for (const item of json) { - const parsed = parseApplicationSummary(item); + const parsed = parseAanvraagSummary(item); if (!parsed.ok) return parsed; out.push(parsed.value); } return ok(out); } -export function parseApplicationDetail(json: unknown): Result { +export function parseAanvraagDetail(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('aanvraag: not an object'); - const base = parseCommon(json as ApplicationDetailDto); + const base = parseCommon(json as AanvraagDetailDto); if (!base.ok) return base; - return ok({ ...base.value, draft: (json as ApplicationDetailDto).draft ?? null }); + return ok({ ...base.value, draft: (json as AanvraagDetailDto).draft ?? null }); } diff --git a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts index c343ba1..ebae5c0 100644 --- a/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/brp.adapter.ts @@ -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 { +export function parseBrpAddress(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('brp-address: not an object'); const dto = json as Partial; if (typeof dto.gevonden !== 'boolean') return err('brp-address: missing/invalid gevonden'); - if (dto.gevonden) { - const a = dto.adres; - if ( - !a || - typeof a.straat !== 'string' || - typeof a.postcode !== 'string' || - typeof a.woonplaats !== 'string' - ) { - return err('brp-address: missing/invalid adres'); - } + if (!dto.gevonden) return ok({ gevonden: false }); + const a = dto.adres; + if ( + !a || + typeof a.straat !== 'string' || + typeof a.postcode !== 'string' || + typeof a.woonplaats !== 'string' + ) { + return err('brp-address: missing/invalid adres'); } - return ok({ gevonden: dto.gevonden, adres: dto.adres }); + return ok({ + gevonden: true, + adres: { straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats }, + }); } diff --git a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts index e2fe78d..2ad78d7 100644 --- a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts +++ b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.spec.ts @@ -35,4 +35,43 @@ describe('parseDashboardView (trust boundary)', () => { parseDashboardView({ ...valid, decisions: { eligibleForHerregistratie: 'yes' } }).ok, ).toBe(false); }); + + it('rejects a status whose tag is present but its required fields are missing', () => { + // The generated RegistrationStatusDto flattens the union — every field is + // optional, so a wire bug (a Geregistreerd row with no herregistratieDatum) + // must be caught here, not by the compiler. + expect( + parseDashboardView({ + ...valid, + registration: { ...valid.registration, status: { tag: 'Geregistreerd' } }, + }).ok, + ).toBe(false); + expect( + parseDashboardView({ + ...valid, + registration: { + ...valid.registration, + status: { tag: 'Geschorst', geschorstTot: '2027-01-01' }, // missing reden + }, + }).ok, + ).toBe(false); + }); + + it('rejects an unknown status tag', () => { + expect( + parseDashboardView({ + ...valid, + registration: { ...valid.registration, status: { tag: 'Ingetrokken' } }, + }).ok, + ).toBe(false); + }); + + it('rejects a person with an incomplete adres', () => { + expect( + parseDashboardView({ + ...valid, + person: { ...valid.person, adres: { straat: 'X 1' } }, + }).ok, + ).toBe(false); + }); }); diff --git a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts index 1a55742..7791ea8 100644 --- a/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts +++ b/apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts @@ -1,18 +1,24 @@ import { Injectable, inject, resource } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { + ApiClient, DashboardViewDto, + RegistrationDto, + RegistrationStatusDto, + PersonDto, +} from '@shared/infrastructure/api-client'; +import { + Registration, + RegistrationStatus, HerregistratieDecisions, -} from '@registratie/contracts/dashboard-view.dto'; -import { Registration } from '@registratie/domain/registration'; +} from '@registratie/domain/registration'; import { Person } from '@registratie/domain/person'; import { BigProfile } from '@registratie/domain/big-profile'; -import { ApiClient } from '@shared/infrastructure/api-client'; /** * The parsed, frontend-side view: the wire DTO mapped onto our own domain model. - * Lives HERE, not in contracts/, because it references domain types — contracts - * stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001). + * Lives HERE, not in a `contracts/*.dto.ts` file, because it references domain + * types — that split is the decoupling seam (CLAUDE.md §1, ADR-0001). */ export interface DashboardView { profile: BigProfile; @@ -31,17 +37,80 @@ export class DashboardViewAdapter { // The value is still untrusted JSON — parseDashboardView validates it at the // boundary and maps DTO → domain before the app uses it. - // - // SEAM (G5): retry-with-backoff for non-mutating reads wraps the loader here — - // e.g. `loader: () => withBackoff(() => this.client.dashboardView())` — since the - // adapter is the single place HTTP lives. Reads are safe to retry; MUTATING calls - // (the submit-* commands) must NEVER auto-retry — and don't. Manual retry - // (resource.reload via ) covers the UX today, so backoff stays unbuilt. dashboardViewResource() { return resource({ loader: () => this.client.dashboardView() }); } } +/** Trust-boundary parse of the status union — the generated `RegistrationStatusDto` + flattens all three variants into one object with every field optional (NSwag + can't express a discriminated union), so the tag drives which fields must + actually be present. Mirrors `parseAanvraagStatus` in `aanvragen.adapter.ts`. */ +export function parseRegistrationStatus( + s: RegistrationStatusDto | undefined, +): Result { + if (!s || typeof s.tag !== 'string') return err('registration: missing status'); + switch (s.tag) { + case 'Geregistreerd': + if (typeof s.herregistratieDatum !== 'string') + return err('registration: bad Geregistreerd status'); + return ok({ tag: 'Geregistreerd', herregistratieDatum: s.herregistratieDatum }); + case 'Geschorst': + if (typeof s.geschorstTot !== 'string' || typeof s.reden !== 'string') + return err('registration: bad Geschorst status'); + return ok({ tag: 'Geschorst', geschorstTot: s.geschorstTot, reden: s.reden }); + case 'Doorgehaald': + if (typeof s.doorgehaaldOp !== 'string' || typeof s.reden !== 'string') + return err('registration: bad Doorgehaald status'); + return ok({ tag: 'Doorgehaald', doorgehaaldOp: s.doorgehaaldOp, reden: s.reden }); + default: + return err(`registration: unknown status tag ${s.tag}`); + } +} + +function parseRegistration(dto: RegistrationDto | undefined): Result { + if ( + !dto || + typeof dto.bigNummer !== 'string' || + typeof dto.naam !== 'string' || + typeof dto.beroep !== 'string' || + typeof dto.registratiedatum !== 'string' || + typeof dto.geboortedatum !== 'string' + ) { + return err('dashboard-view: missing/invalid registration'); + } + const status = parseRegistrationStatus(dto.status); + if (!status.ok) return status; + return ok({ + bigNummer: dto.bigNummer, + naam: dto.naam, + beroep: dto.beroep, + registratiedatum: dto.registratiedatum, + geboortedatum: dto.geboortedatum, + status: status.value, + }); +} + +function parsePerson(dto: PersonDto | undefined): Result { + const a = dto?.adres; + if ( + !dto || + typeof dto.naam !== 'string' || + typeof dto.geboortedatum !== 'string' || + !a || + typeof a.straat !== 'string' || + typeof a.postcode !== 'string' || + typeof a.woonplaats !== 'string' + ) { + return err('dashboard-view: missing/invalid person'); + } + return ok({ + naam: dto.naam, + geboortedatum: dto.geboortedatum, + adres: { straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats }, + }); +} + /** * Trust-boundary parse: validate the untrusted response shape and map the DTO * onto our own domain model. Hand-written on purpose — no Zod for a single @@ -51,43 +120,17 @@ export function parseDashboardView(json: unknown): Result if (typeof json !== 'object' || json === null) return err('dashboard-view: not an object'); const dto = json as Partial; - const reg = dto.registration; - if ( - !reg || - typeof reg.bigNummer !== 'string' || - !reg.status || - typeof reg.status.tag !== 'string' - ) { - return err('dashboard-view: missing/invalid registration'); - } - const person = dto.person; - if (!person || !person.adres || typeof person.adres.postcode !== 'string') { - return err('dashboard-view: missing/invalid person'); - } + const registration = parseRegistration(dto.registration); + if (!registration.ok) return registration; + const person = parsePerson(dto.person); + if (!person.ok) return person; const d = dto.decisions; if (!d || typeof d.eligibleForHerregistratie !== 'boolean') { return err('dashboard-view: missing/invalid decisions'); } - // Map wire → domain. The shapes are identical today, so this reads as an - // identity copy — but the TYPES differ (wire DTO vs domain), so the moment the - // wire diverges the compiler forces a real mapping here. That's the seam. - const registration: Registration = { - bigNummer: reg.bigNummer, - naam: reg.naam, - beroep: reg.beroep, - registratiedatum: reg.registratiedatum, - geboortedatum: reg.geboortedatum, - status: reg.status, - }; - const persoon: Person = { - naam: person.naam, - geboortedatum: person.geboortedatum, - adres: person.adres, - }; - return ok({ - profile: { registration, person: persoon }, + profile: { registration: registration.value, person: person.value }, decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason, diff --git a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts index 59c8efe..c23353e 100644 --- a/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts +++ b/apps/ssp/src/app/registratie/ui/aanvraag-detail.page.ts @@ -1,4 +1,5 @@ import { Component, computed, inject } from '@angular/core'; +import { successOf } from '@shared/application/remote-data'; import { ActivatedRoute } from '@angular/router'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; @@ -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" > - + - @if (applications(); as list) { + @if (aanvragen(); as list) { @let a = find(list); @if (a) { list.find((a) => a.id === this.id); protected rows = detailRows; - /** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */ - protected readonly applications = computed(() => { - const rd = this.store.applications(); - return rd.tag === 'Success' ? rd.value : undefined; - }); + /** `successOf`: `` can't inherit a generic from a sibling host input, + so the Success value is unwrapped here instead of through `let-`. */ + protected readonly aanvragen = computed(() => successOf(this.store.aanvragen())); } diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/registratie/ui/dashboard.page.ts index 86c5a35..0d4f177 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard.page.ts @@ -1,47 +1,25 @@ -import { Component, computed, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { Component } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; -import { HeadingComponent } from '@shared/ui/heading/heading.component'; -import { AlertComponent } from '@shared/ui/alert/alert.component'; -import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; -import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; -import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; -import { TaskListComponent } from '@shared/ui/task-list/task-list.component'; -import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; -import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; -import { ASYNC } from '@shared/ui/async/async.component'; -import { AccessStore } from '@shared/application/access.store'; -import { FeatureFlagStore } from '@shared/application/feature-flags.store'; -import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; -import { ADMIN_LINKS } from '../../shell/nav.config'; -import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; -import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; -import { AanvraagBlockComponent } from '@registratie/ui/aanvraag-block/aanvraag-block.component'; -import { BigProfileStore } from '@registratie/application/big-profile.store'; -import { 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: `
- @if (cancelError(); as err) { - {{ err }} - } - @if (aanvragen().length) { -
- @for (a of concepten(); track a.id) { - - } - @if (ingediend().length) { - Mijn aanvragen - - @for (a of ingediend(); track a.id) { - @let row = submittedRow(a); -
  • - } -
    - } -
    - } - - @if (store.pendingHerregistratie()) { - Uw herregistratie-aanvraag is in behandeling. - } - - - - @if (profile(); as p) { - @let tasks = tasksFor(p.registration); - -
    - @if (tasks.length) { - - } @else { - Wat moet ik regelen -

    - U heeft op dit moment niets openstaan. -

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

    - U heeft nog geen specialismen of aantekeningen. -

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

    + U heeft nog geen specialismen of aantekeningen. +

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

    + U heeft op dit moment niets openstaan. +

    + } +
    + } +
    + + + +
    + `, +}) +export class WatMoetIkRegelenSection { + protected store = inject(BigProfileStore); + + private eligible = computed(() => { + const d = successOf(this.store.decisions()); + return d?.eligibleForHerregistratie ?? false; + }); + + protected tasks = computed(() => { + const p = successOf(this.store.profile()); + return p ? tasksFromProfile(p.registration, this.eligible()) : undefined; + }); +} diff --git a/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts b/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts new file mode 100644 index 0000000..7f39f57 --- /dev/null +++ b/apps/ssp/src/app/registratie/ui/dashboard/wat-wilt-u-doen.section.ts @@ -0,0 +1,79 @@ +import { Component, computed, inject } from '@angular/core'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; +import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; +import { FeatureFlagStore } from '@shared/application/feature-flags.store'; +import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; + +/** Section: "Wat wilt u doen?" — the portal's primary transactional actions (see + CIBG's componenten/aanvragen). The core pages live in the header nav now; the + teaching pages (concepts/brief) are only reachable from here. */ +@Component({ + selector: 'app-wat-wilt-u-doen-section', + imports: [HeadingComponent, ApplicationListComponent, ApplicationLinkComponent], + template: ` +
    + Wat wilt u doen? + + @for (a of acties(); track a.to) { +
  • + } +
    +
    + `, +}) +export class WatWiltUDoenSection { + private flags = inject(FeatureFlagStore); + + private readonly allActies = [ + { + to: '/registreren', + titel: $localize`:@@dashboard.actie.inschrijven.titel:Inschrijven`, + tekst: $localize`:@@dashboard.actie.inschrijven.tekst:Schrijf u in in het BIG-register via de registratiewizard.`, + actie: $localize`:@@dashboard.actie.inschrijven.actie:Start inschrijving`, + }, + { + to: '/herregistratie', + titel: $localize`:@@dashboard.actie.herregistratie.titel:Herregistratie aanvragen`, + tekst: $localize`:@@dashboard.actie.herregistratie.tekst:Verleng uw registratie voor de komende periode.`, + actie: $localize`:@@dashboard.actie.herregistratie.actie:Vraag aan`, + }, + { + to: '/intake', + titel: $localize`:@@dashboard.actie.intake.titel:Herregistratie-intake`, + tekst: $localize`:@@dashboard.actie.intake.tekst:Vragenlijst met vertakkingen.`, + actie: $localize`:@@dashboard.actie.intake.actie:Start intake`, + }, + { + to: '/registratie', + titel: $localize`:@@dashboard.actie.wijzigen.titel:Gegevens wijzigen`, + tekst: $localize`:@@dashboard.actie.wijzigen.tekst:Bekijk uw gegevens of geef een wijziging door.`, + actie: $localize`:@@dashboard.actie.wijzigen.actie:Bekijk gegevens`, + }, + { + to: '/concepts', + titel: $localize`:@@dashboard.actie.concepten.titel:Functionele patronen`, + tekst: $localize`:@@dashboard.actie.concepten.tekst:Bekijk de FP/TEA-bouwstenen van deze POC.`, + actie: $localize`:@@dashboard.actie.concepten.actie:Bekijk patronen`, + }, + { + to: '/brief', + titel: $localize`:@@dashboard.actie.brief.titel:Brief opstellen`, + tekst: $localize`:@@dashboard.actie.brief.tekst:Stel een brief samen uit vaste en vrije onderdelen.`, + actie: $localize`:@@dashboard.actie.brief.actie:Start brief`, + }, + ]; + + /** Hide "Inschrijven" when self-service registration is flagged off. */ + protected readonly acties = computed(() => + this.allActies.filter( + (a) => a.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN), + ), + ); +} diff --git a/apps/ssp/src/app/registratie/ui/registration-detail.page.ts b/apps/ssp/src/app/registratie/ui/registration-detail.page.ts index 25425f4..01f0063 100644 --- a/apps/ssp/src/app/registratie/ui/registration-detail.page.ts +++ b/apps/ssp/src/app/registratie/ui/registration-detail.page.ts @@ -1,4 +1,5 @@ import { Component, computed, inject } from '@angular/core'; +import { successOf } from '@shared/application/remote-data'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { ASYNC } from '@shared/ui/async/async.component'; @@ -41,9 +42,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store'; export class RegistrationDetailPage { protected store = inject(BigProfileStore); - /** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */ - protected readonly profile = computed(() => { - const rd = this.store.profile(); - return rd.tag === 'Success' ? rd.value : undefined; - }); + /** `successOf`: `` can't inherit a generic from a sibling host input, + so the Success value is unwrapped here instead of through `let-`. */ + protected readonly profile = computed(() => successOf(this.store.profile())); } diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 211b5c0..953e999 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -99,19 +99,19 @@ public sealed record AanvraagStatusDto( bool? Manual = null, string? Reden = null); -public sealed record ApplicationSummaryDto( +public sealed record AanvraagSummaryDto( string Id, string Type, AanvraagStatusDto Status, IReadOnlyList DocumentIds, string CreatedAt, string UpdatedAt, string? SubmittedAt, string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it -public sealed record ApplicationDetailDto( +public sealed record AanvraagDetailDto( string Id, string Type, AanvraagStatusDto Status, System.Text.Json.JsonElement? Draft, IReadOnlyList DocumentIds, string CreatedAt, string UpdatedAt, string? SubmittedAt); -public sealed record CreateApplicationRequest(string Type); +public sealed record CreateAanvraagRequest(string Type); public sealed record DraftSyncRequest( System.Text.Json.JsonElement Draft, int StepIndex, int StepCount, @@ -120,12 +120,12 @@ public sealed record DraftSyncRequest( // Submit carries only the fields the server re-validates per wizard type. // AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by // IntakePolicy.RejectIncompleteScholing's caller), null for the others. -public sealed record SubmitApplicationRequest( +public sealed record AanvraagIndienenRequest( string? DiplomaHerkomst = null, int? Uren = null, IReadOnlyList? Documents = null, bool? AanvullendeScholing = null, int? ScholingPunten = null); -public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status); +public sealed record AanvraagIndienenResponse(string Referentie, AanvraagStatusDto Status); // --- Beoordeling (WP-65): the behandelportal's case-detail screen. --- @@ -137,7 +137,7 @@ public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId public sealed record BeoordelingDecisionsDto(bool CanBesluiten); public sealed record BeoordelingViewDto( - ApplicationSummaryDto Aanvraag, + AanvraagSummaryDto Aanvraag, IReadOnlyList Documenten, BeoordelingDecisionsDto Decisions); diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index ae32125..ce020c9 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -65,7 +65,7 @@ public static class Mappers /// reads it past that point; see AanvraagMapper.ApplyTo's Submitted branch). private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null; - public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( + public static AanvraagSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); @@ -74,10 +74,10 @@ public static class Mappers /// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked /// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner /// list cannot be added that forgets to. - public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => + public static AanvraagSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) }; - public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( + public static AanvraagDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds, a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); } diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 82054f1..96c6c0b 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -43,7 +43,7 @@ public static class DocumentStore /// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer, /// a different Dutch identifier scheme). Previously this constant reused that BigNummer value /// as a stand-in BSN, which is invalid Dutch-BSN shape: harmless against the local store, but - /// a real OpenZaak instance rejects it outright — GET /api/v1/applications 500s (`inpBsn` query + /// a real OpenZaak instance rejects it outright — GET /api/v1/aanvragen 500s (`inpBsn` query /// filter validation) and every submit's rol-creation POST fails (`inpBsn` max_length) once /// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture /// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen"). diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index 22da698..71403da 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -7,7 +7,7 @@ namespace BigRegister.Api.Data; /// /// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an /// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak) -/// replace the local SQLite store behind the same +/// replace the local SQLite store behind the same /// contract — so the frontend never changes (BFF-lite anti-corruption, ADR-0001). /// /// Default binding is (offline). Setting Zgw:Enabled=true @@ -20,7 +20,7 @@ public interface IZaakSource { /// Every case across every owner, newest-first (the admin cross-owner list, /// WP-36) — cases:manage only, deliberately NOT citizen-scoped. - IReadOnlyList ListCases(DateTimeOffset now); + IReadOnlyList ListCases(DateTimeOffset now); /// /// Only 's own cases (WP-53) — the citizen-scoped counterpart of @@ -29,7 +29,7 @@ public interface IZaakSource /// rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn query filter so a citizen /// can never see another citizen's zaken. /// - IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now); + IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now); /// /// Register a just-submitted as a zaak (WP-50). The aanvraag is diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index bce8453..13f59a0 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -12,12 +12,12 @@ namespace BigRegister.Api.Data; /// public sealed class LocalZaakSource : IZaakSource { - public IReadOnlyList ListCases(DateTimeOffset now) => + public IReadOnlyList ListCases(DateTimeOffset now) => ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); - /// Citizen-scoped (WP-53) — exactly what GET /applications used to compute + /// Citizen-scoped (WP-53) — exactly what GET /aanvragen used to compute /// inline before it was routed through this seam. - public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => ApplicationStore.List(caller.Bsn) .OrderByDescending(a => a.UpdatedAt) .Select(a => a.ToSummaryDto(now)).ToList(); diff --git a/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs b/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs index 4132c03..4a517a2 100644 --- a/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs +++ b/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs @@ -9,7 +9,7 @@ public sealed record FeatureFlagDef(string Key, string Description, bool Default public static class FeatureFlags { /// Whether self-service registration (inschrijving) is open. When off, the FE hides the - /// "Inschrijven" action and POST /applications for a `registratie` is refused (server-enforced). + /// "Inschrijven" action and POST /aanvragen for a `registratie` is refused (server-enforced). public const string InschrijvingOpen = "inschrijving-open"; public static readonly IReadOnlyList Catalog = new[] diff --git a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs index 3d1a37d..c058af9 100644 --- a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs +++ b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs @@ -6,7 +6,7 @@ namespace BigRegister.Domain.Intake; /// (GET /intake/policy) and applies it for instant UX feedback /// (intake.machine.ts's lageUren); is the /// backend re-validating it as the authority on submit (WP-69) — -/// POST /applications/{id}/submit (intake-typed aanvragen only) calls it before +/// POST /aanvragen/{id}/submit (intake-typed aanvragen only) calls it before /// writing anything, and a violation 400s (ProblemDetails), never silently accepts /// an incomplete answer. /// diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index f0f6ae8..b59f6ff 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -342,19 +342,19 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx // ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from // OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap // openzaak-integration.md's ACL caveat used to flag for this endpoint. -api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) => +api.MapGet("/aanvragen", (HttpContext ctx, IZaakSource zaken) => zaken.ListMyCases(ctx.Zorgverlener(), DateTimeOffset.UtcNow)); -api.MapGet("/applications/{id}", (string id, HttpContext ctx) => +api.MapGet("/aanvragen/{id}", (string id, HttpContext ctx) => ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) : Results.NotFound()) -.Produces() +.Produces() .Produces(StatusCodes.Status404NotFound); // --- writes --- -api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => +api.MapPost("/aanvragen", (CreateAanvraagRequest req, HttpContext ctx) => { // Feature flag (WP-47): self-service registration can be closed by an admin. if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen)) @@ -364,13 +364,13 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => return Results.Problem( detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", statusCode: StatusCodes.Status409Conflict); - return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); + return Results.Created($"/api/v1/aanvragen/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); }) -.Produces(StatusCodes.Status201Created) +.Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status409Conflict); // Draft sync per step — idempotent; keep it debounced on the client (it is chatty). -api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => +api.MapPut("/aanvragen/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => { var owner = ctx.Zorgverlener().Bsn; // A citizen may only reference their own uploads in a draft — reject before the sync @@ -388,7 +388,7 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext c // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot // be withdrawn (out of scope — no "intrekken"). -api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => +api.MapDelete("/aanvragen/{id}", (string id, HttpContext ctx) => { var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (a is null) return Results.NotFound(); @@ -403,7 +403,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => // Submit runs the server-owned rules, sets autoApprovable, and transitions the // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. -api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => +api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => { var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (existing is null) return Results.NotFound(); @@ -480,9 +480,9 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re } } - return Results.Ok(new SubmitApplicationResponse(referentie, status)); + return Results.Ok(new AanvraagIndienenResponse(referentie, status)); }) -.Produces() +.Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); @@ -494,7 +494,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) .Gate("CasesAdmin") -.Produces>() +.Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated @@ -510,7 +510,7 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => // --- writes --- // Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing -// DELETE /applications/{id}. A missing id is a 404. +// DELETE /aanvragen/{id}. A missing id is a 404. api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => { if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); @@ -531,14 +531,14 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") .ToList()))) .Gate("Beoordelen") -.Produces>() +.Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording // a decision is WP-65's second half). Reads through IZaakSource.ListCases (no new seam method: // adding one now would force an OpenZaak get-by-id + mapper, which is WP-66's surface) — O(n) // over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here -// same as an unknown id (only /applications/{id}, citizen-scoped, shows a Concept). +// same as an unknown id (only /aanvragen/{id}, citizen-scoped, shows a Concept). api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, $"aanvraag/{id}", () => { diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index 2780998..2e9b260 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -18,9 +18,9 @@ public sealed record ZgwPage( /// The backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50 /// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the /// internal aanvraag-type key via Zgw:ZaaktypeUrls (a local lookup — NOT OpenZaak's -/// human zaaktype label, which isn't a value 's +/// human zaaktype label, which isn't a value 's /// contract accepts; see ), and maps into -/// via . Creates a zaak + +/// via . Creates a zaak + /// status + rol for a just-submitted aanvraag. Selected only when Zgw:Enabled=true; /// the default stays . /// @@ -36,16 +36,16 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, // sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the // default and this blocking call shows up under load. - public IReadOnlyList ListCases(DateTimeOffset now) => + public IReadOnlyList ListCases(DateTimeOffset now) => ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult(); /// WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param /// (see ) — and minted with that citizen's identity, not the /// system-level one uses. - public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult(); - private async Task> ListCasesAsync(string? bsn, CallerIdentity? caller) + private async Task> ListCasesAsync(string? bsn, CallerIdentity? caller) { var url = $"{options.ZrcBaseUrl}/zaken"; if (bsn is not null) @@ -55,7 +55,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, } /// Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse): - /// ApplicationSummaryDto.Type's contract is the internal aanvraag-type key (e.g. + /// AanvraagSummaryDto.Type's contract is the internal aanvraag-type key (e.g. /// "herregistratie" — what /Mappers.ToSummaryDto send, /// and what the FE's AANVRAAG_TYPES trust boundary accepts), NOT OpenZaak's human /// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round diff --git a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs index e54d478..430ebb5 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs @@ -6,7 +6,7 @@ namespace BigRegister.Api.Zgw; /// /// The subset of a ZGW Zaak (Zaken API / ZRC) the read slice needs. The full resource has -/// dozens of fields; we bind only what maps to . Note the +/// dozens of fields; we bind only what maps to . Note the /// two ZGW traits that force an anti-corruption layer: is the resource's /// identity (not a bare id), and is a URL into another service /// (Catalogi/ZTC) that must be resolved to a human label. @@ -20,7 +20,7 @@ public sealed record ZgwZaak( [property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum); /// -/// Anti-corruption map: ZGW Zaak → the existing the FE +/// Anti-corruption map: ZGW Zaak → the existing the FE /// already renders (WP-49). This is where "URL as identity" and the cross-service zaaktype /// join get flattened away, so nothing downstream (the FE) sees ZGW shapes. /// @@ -29,7 +29,7 @@ public static class ZgwZaakMapper /// Last path segment of a ZGW resource URL — the uuid that identifies it. public static string Uuid(string url) => url.TrimEnd('/').Split('/').Last(); - public static ApplicationSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel) + public static AanvraagSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel) { // ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed // one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the @@ -41,7 +41,7 @@ public static class ZgwZaakMapper var created = Iso(z.Registratiedatum ?? z.Startdatum); var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum); - return new ApplicationSummaryDto( + return new AanvraagSummaryDto( Id: Uuid(z.Url), Type: zaaktypeLabel, Status: status, diff --git a/backend/swagger.json b/backend/swagger.json index e0ccac2..7145b22 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -425,7 +425,7 @@ } } }, - "/api/v1/applications": { + "/api/v1/aanvragen": { "get": { "tags": [ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" @@ -438,7 +438,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" } } } @@ -454,7 +454,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApplicationRequest" + "$ref": "#/components/schemas/CreateAanvraagRequest" } } }, @@ -466,7 +466,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApplicationDetailDto" + "$ref": "#/components/schemas/AanvraagDetailDto" } } } @@ -484,7 +484,7 @@ } } }, - "/api/v1/applications/{id}": { + "/api/v1/aanvragen/{id}": { "get": { "tags": [ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" @@ -505,7 +505,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApplicationDetailDto" + "$ref": "#/components/schemas/AanvraagDetailDto" } } } @@ -592,7 +592,7 @@ } } }, - "/api/v1/applications/{id}/submit": { + "/api/v1/aanvragen/{id}/submit": { "post": { "tags": [ "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" @@ -611,7 +611,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubmitApplicationRequest" + "$ref": "#/components/schemas/AanvraagIndienenRequest" } } }, @@ -623,7 +623,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubmitApplicationResponse" + "$ref": "#/components/schemas/AanvraagIndienenResponse" } } } @@ -667,7 +667,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" } } } @@ -766,7 +766,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" } } } @@ -1484,57 +1484,7 @@ }, "additionalProperties": false }, - "AanvraagStatusDto": { - "type": "object", - "properties": { - "tag": { - "type": "string", - "nullable": true - }, - "stepIndex": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "stepCount": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "referentie": { - "type": "string", - "nullable": true - }, - "manual": { - "type": "boolean", - "nullable": true - }, - "reden": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "AdresDto": { - "type": "object", - "properties": { - "straat": { - "type": "string", - "nullable": true - }, - "postcode": { - "type": "string", - "nullable": true - }, - "woonplaats": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "ApplicationDetailDto": { + "AanvraagDetailDto": { "type": "object", "properties": { "id": { @@ -1573,7 +1523,83 @@ }, "additionalProperties": false }, - "ApplicationSummaryDto": { + "AanvraagIndienenRequest": { + "type": "object", + "properties": { + "diplomaHerkomst": { + "type": "string", + "nullable": true + }, + "uren": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentRefDto" + }, + "nullable": true + }, + "aanvullendeScholing": { + "type": "boolean", + "nullable": true + }, + "scholingPunten": { + "type": "integer", + "format": "int32", + "nullable": true + } + }, + "additionalProperties": false + }, + "AanvraagIndienenResponse": { + "type": "object", + "properties": { + "referentie": { + "type": "string", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/AanvraagStatusDto" + } + }, + "additionalProperties": false + }, + "AanvraagStatusDto": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "nullable": true + }, + "stepIndex": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "stepCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "referentie": { + "type": "string", + "nullable": true + }, + "manual": { + "type": "boolean", + "nullable": true + }, + "reden": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "AanvraagSummaryDto": { "type": "object", "properties": { "id": { @@ -1613,6 +1639,24 @@ }, "additionalProperties": false }, + "AdresDto": { + "type": "object", + "properties": { + "straat": { + "type": "string", + "nullable": true + }, + "postcode": { + "type": "string", + "nullable": true + }, + "woonplaats": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "AuthzAuditDto": { "type": "object", "properties": { @@ -1674,7 +1718,7 @@ "type": "object", "properties": { "aanvraag": { - "$ref": "#/components/schemas/ApplicationSummaryDto" + "$ref": "#/components/schemas/AanvraagSummaryDto" }, "documenten": { "type": "array", @@ -1860,7 +1904,7 @@ }, "additionalProperties": false }, - "CreateApplicationRequest": { + "CreateAanvraagRequest": { "type": "object", "properties": { "type": { @@ -2678,50 +2722,6 @@ }, "additionalProperties": false }, - "SubmitApplicationRequest": { - "type": "object", - "properties": { - "diplomaHerkomst": { - "type": "string", - "nullable": true - }, - "uren": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DocumentRefDto" - }, - "nullable": true - }, - "aanvullendeScholing": { - "type": "boolean", - "nullable": true - }, - "scholingPunten": { - "type": "integer", - "format": "int32", - "nullable": true - } - }, - "additionalProperties": false - }, - "SubmitApplicationResponse": { - "type": "object", - "properties": { - "referentie": { - "type": "string", - "nullable": true - }, - "status": { - "$ref": "#/components/schemas/AanvraagStatusDto" - } - }, - "additionalProperties": false - }, "UploadCategoriesDto": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/AanvraagTests.cs similarity index 72% rename from backend/tests/BigRegister.Tests/ApplicationTests.cs rename to backend/tests/BigRegister.Tests/AanvraagTests.cs index 551a6ca..551abdc 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/AanvraagTests.cs @@ -8,27 +8,27 @@ using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; -public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture +public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); - private async Task Create(string type = "registratie") + private async Task Create(string type = "registratie") { // WP-35: one Concept per type is now server-enforced, and these tests share one DB // (IClassFixture). Clear any leftover Concept so each test starts from a clean slate. var existing = await List(); Assert.NotNull(existing); foreach (var s in existing.Where(x => x.Status.Tag == "Concept")) - await _client.DeleteAsync($"/api/v1/applications/{s.Id}"); - var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); + await _client.DeleteAsync($"/api/v1/aanvragen/{s.Id}"); + var res = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type }); Assert.Equal(HttpStatusCode.Created, res.StatusCode); - var created = await res.Content.ReadFromJsonAsync(); + var created = await res.Content.ReadFromJsonAsync(); Assert.NotNull(created); return created; } - private Task?> List() => - _client.GetFromJsonAsync>("/api/v1/applications"); + private Task?> List() => + _client.GetFromJsonAsync>("/api/v1/aanvragen"); // --- Lifecycle over HTTP --- @@ -36,7 +36,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Create_then_list_shows_a_concept_with_step_progress() { var a = await Create(); - await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}", new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 }); var list = await List(); @@ -51,10 +51,10 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Draft_sync_is_readable_back_from_detail() { var a = await Create(); - await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}", new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 }); - var detail = await _client.GetFromJsonAsync($"/api/v1/applications/{a.Id}"); + var detail = await _client.GetFromJsonAsync($"/api/v1/aanvragen/{a.Id}"); Assert.NotNull(detail); Assert.NotNull(detail.Draft); Assert.Equal("verpleegkundige", detail.Draft.Value.GetProperty("beroep").GetString()); @@ -64,9 +64,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submit_duo_registratie_is_in_behandeling_and_auto() { var a = await Create("registratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" }); res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.StartsWith("BIG-2026-", body.Referentie); Assert.Equal("InBehandeling", body.Status.Tag); Assert.False(body.Status.Manual); // auto-approvable → not a manual case @@ -76,9 +76,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submit_handmatig_registratie_succeeds_as_manual_case() { var a = await Create("registratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" }); + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "handmatig" }); res.EnsureSuccessStatusCode(); // no longer a 422 - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("InBehandeling", body.Status.Tag); Assert.True(body.Status.Manual); } @@ -87,9 +87,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submit_herregistratie_with_zero_uren_is_afgewezen() { var a = await Create("herregistratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 }); + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 0 }); res.EnsureSuccessStatusCode(); // the submission is accepted... - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected Assert.NotNull(body.Status.Reden); } @@ -98,8 +98,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Submitting_twice_conflicts() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); - var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + var again = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" }); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); } @@ -109,7 +109,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Creating_a_second_concept_of_the_same_type_conflicts() { await Create("herregistratie"); - var dup = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); + var dup = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode); } @@ -117,7 +117,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task A_concept_of_a_different_type_is_allowed() { await Create("registratie"); - var other = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); + var other = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); Assert.Equal(HttpStatusCode.Created, other.StatusCode); } @@ -125,8 +125,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task A_new_concept_is_allowed_once_the_previous_one_is_submitted() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); - var next = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + var next = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); Assert.Equal(HttpStatusCode.Created, next.StatusCode); } @@ -134,38 +134,38 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public async Task Cancel_concept_removes_it() { var a = await Create(); - Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); } [Fact] public async Task Cancel_submitted_aanvraag_conflicts() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); - Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); } - // --- WP-53: citizen-scoping — GET /applications must never leak across identities. --- + // --- WP-53: citizen-scoping — GET /aanvragen must never leak across identities. --- [Fact] public async Task Applications_are_scoped_to_the_caller_bsn() { var mine = await Create("intake"); - var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/applications") + var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/aanvragen") { Content = JsonContent.Create(new { type = "intake" }), Headers = { { "X-Subject", "999888777" } }, }; var otherRes = await _client.SendAsync(createOther); Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode); - var other = (await otherRes.Content.ReadFromJsonAsync())!; + var other = (await otherRes.Content.ReadFromJsonAsync())!; try { - var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } }; - var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync>())!; + var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/aanvragen") { Headers = { { "X-Subject", "999888777" } } }; + var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync>())!; Assert.Contains(theirCases, c => c.Id == other.Id); Assert.DoesNotContain(theirCases, c => c.Id == mine.Id); @@ -175,9 +175,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture } finally { - var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/applications/{other.Id}") { Headers = { { "X-Subject", "999888777" } } }; + var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/aanvragen/{other.Id}") { Headers = { { "X-Subject", "999888777" } } }; await _client.SendAsync(deleteOther); - await _client.DeleteAsync($"/api/v1/applications/{mine.Id}"); + await _client.DeleteAsync($"/api/v1/aanvragen/{mine.Id}"); } } @@ -205,7 +205,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString()); var a = await Create("registratie"); - var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", + var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } }); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); @@ -221,7 +221,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString()); var a = await Create("registratie"); - var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + var res = await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}", new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } }); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); } diff --git a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs index d70ee6c..bdb0bc7 100644 --- a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs +++ b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs @@ -9,7 +9,7 @@ namespace BigRegister.Tests.Acceptance; /// /// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over -/// POST /applications/{id}/submit (the wizard's real path — WP-72 deleted the legacy +/// POST /aanvragen/{id}/submit (the wizard's real path — WP-72 deleted the legacy /// POST /intakes endpoint this once also covered). Built through the type-state builder, mirroring rather /// than the full wizard/upload dance — the builder's default owner IS Submit(string id, object body) => - _client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", body); + _client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", body); [Fact] public async Task Below_threshold_without_an_answer_is_rejected_and_stays_a_concept() @@ -117,7 +117,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi // Then the submission is accepted and resolves to Afgewezen — not a 400. res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("Afgewezen", body.Status.Tag); } } diff --git a/backend/tests/BigRegister.Tests/AdminCasesTests.cs b/backend/tests/BigRegister.Tests/AdminCasesTests.cs index 475778a..afd1816 100644 --- a/backend/tests/BigRegister.Tests/AdminCasesTests.cs +++ b/backend/tests/BigRegister.Tests/AdminCasesTests.cs @@ -18,11 +18,11 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< return req; } - private async Task Create(string type) + private async Task Create(string type) { - var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); + var res = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type }); Assert.Equal(HttpStatusCode.Created, res.StatusCode); - return (await res.Content.ReadFromJsonAsync())!; + return (await res.Content.ReadFromJsonAsync())!; } [Fact] @@ -33,7 +33,7 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< { var list = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases")); list.EnsureSuccessStatusCode(); - var cases = (await list.Content.ReadFromJsonAsync>())!; + var cases = (await list.Content.ReadFromJsonAsync>())!; var mine = cases.Single(x => x.Id == a.Id); // RB-03/BIO-003: the owner is carried, but masked — it is a BSN, and this list is // read by someone who is not the subject. @@ -57,13 +57,13 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< public async Task Admin_can_delete_a_submitted_case() { var a = await Create("registratie"); - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })) + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })) .EnsureSuccessStatusCode(); // The user-facing DELETE refuses a submitted case (409); admin delete removes it. var del = await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}")); Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); } [Fact] diff --git a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs index 812c86a..378258c 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs @@ -17,12 +17,12 @@ namespace BigRegister.Tests; file sealed class IdMismatchZaakSource : IZaakSource { private readonly LocalZaakSource inner = new(); - private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" }; + private static AanvraagSummaryDto Rekey(AanvraagSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" }; - public IReadOnlyList ListCases(DateTimeOffset now) => + public IReadOnlyList ListCases(DateTimeOffset now) => inner.ListCases(now).Select(Rekey).ToList(); - public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => inner.ListMyCases(caller, now).Select(Rekey).ToList(); public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak( @@ -67,13 +67,13 @@ public class BeoordelingIdMismatchTests using var factory = Factory(); using var client = factory.CreateClient(); - var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); - var app = (await created.Content.ReadFromJsonAsync())!; - var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" }); + var created = await client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var app = (await created.Content.ReadFromJsonAsync())!; + var submit = await client.PostAsJsonAsync($"/api/v1/aanvragen/{app.Id}/submit", new { diplomaHerkomst = "handmatig" }); submit.EnsureSuccessStatusCode(); var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad")); - var items = (await werkvoorraad.Content.ReadFromJsonAsync>())!; + var items = (await werkvoorraad.Content.ReadFromJsonAsync>())!; var caseId = Assert.Single(items).Id; // Sanity: the id divergence this test exists for is real, not accidentally absent. Assert.NotEqual(app.Id, caseId); diff --git a/backend/tests/BigRegister.Tests/BeoordelingTests.cs b/backend/tests/BigRegister.Tests/BeoordelingTests.cs index f79ffbb..23334f9 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingTests.cs @@ -35,17 +35,17 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture /// A manual (never auto-approved) case with one linked document, so it stays /// InBehandeling/decidable regardless of test timing (the 8s auto-approval window /// would otherwise make a duo-registratie/herregistratie fixture flaky). - private async Task<(ApplicationDetailDto App, string DocumentId)> CreateManualCaseWithDocument() + private async Task<(AanvraagDetailDto App, string DocumentId)> CreateManualCaseWithDocument() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); - var a = (await created.Content.ReadFromJsonAsync())!; + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var a = (await created.Content.ReadFromJsonAsync())!; var localId = Guid.NewGuid().ToString(); var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf")); upload.EnsureSuccessStatusCode(); var doc = (await upload.Content.ReadFromJsonAsync())!; - var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new + var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "handmatig", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } }, @@ -88,8 +88,8 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture [Fact] public async Task Concept_and_unknown_id_are_not_found() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); - var concept = (await created.Content.ReadFromJsonAsync())!; + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var concept = (await created.Content.ReadFromJsonAsync())!; try { var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}")); @@ -100,7 +100,7 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture } finally { - await _client.DeleteAsync($"/api/v1/applications/{concept.Id}"); + await _client.DeleteAsync($"/api/v1/aanvragen/{concept.Id}"); } } diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index b8fb2d7..cd74ca3 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -202,9 +202,9 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture())!; - var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit", + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); + var aanvraag = (await created.Content.ReadFromJsonAsync())!; + var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{aanvraag.Id}/submit", new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } }); submit.EnsureSuccessStatusCode(); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); diff --git a/backend/tests/BigRegister.Tests/FeatureFlagTests.cs b/backend/tests/BigRegister.Tests/FeatureFlagTests.cs index 6502e78..af3b68b 100644 --- a/backend/tests/BigRegister.Tests/FeatureFlagTests.cs +++ b/backend/tests/BigRegister.Tests/FeatureFlagTests.cs @@ -51,16 +51,16 @@ public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture { try { - // Off → POST /applications for a registratie is refused. + // Off → POST /aanvragen for a registratie is refused. (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false }))) .EnsureSuccessStatusCode(); - var blocked = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + var blocked = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode); // On → allowed again. (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true }))) .EnsureSuccessStatusCode(); - var ok = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + var ok = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); Assert.Equal(HttpStatusCode.Created, ok.StatusCode); } finally diff --git a/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs b/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs index 84017e5..8989a29 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs @@ -63,7 +63,7 @@ public class OpenZaakIntegrationTests using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage) - var cases = await client.GetFromJsonAsync>("/api/v1/admin/cases"); + var cases = await client.GetFromJsonAsync>("/api/v1/admin/cases"); Assert.NotNull(cases); // bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123. diff --git a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs index a010096..f4cff09 100644 --- a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs +++ b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs @@ -17,7 +17,7 @@ namespace BigRegister.Tests; /// - it is named, with a reason, in below. /// /// The allow-list is deliberately not "public routes" — most of its entries are NOT public. -/// `GET /applications/{id}` requires a caller identity and is scoped to that caller's own BSN +/// `GET /aanvragen/{id}` requires a caller identity and is scoped to that caller's own BSN /// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the /// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point /// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down") @@ -58,12 +58,12 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."), new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."), new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."), - new("GET", "/api/v1/applications", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), - new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), - new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), - new("PUT", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."), - new("DELETE", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."), - new("POST", "/api/v1/applications/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."), + new("GET", "/api/v1/aanvragen", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), + new("GET", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), + new("POST", "/api/v1/aanvragen", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), + new("PUT", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."), + new("DELETE", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."), + new("POST", "/api/v1/aanvragen/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."), // --- External caller, not a Principal at all. --- new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."), diff --git a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs index c665f08..022ed5f 100644 --- a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs +++ b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs @@ -18,11 +18,11 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur return req; } - private async Task CreateAndSubmitHerregistratie() + private async Task CreateAndSubmitHerregistratie() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); - var a = (await created.Content.ReadFromJsonAsync())!; - (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 200 })) + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); + var a = (await created.Content.ReadFromJsonAsync())!; + (await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 200 })) .EnsureSuccessStatusCode(); return a; } @@ -35,7 +35,7 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur { var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad")); res.EnsureSuccessStatusCode(); - var queue = (await res.Content.ReadFromJsonAsync>())!; + var queue = (await res.Content.ReadFromJsonAsync>())!; var mine = queue.Single(x => x.Id == a.Id); Assert.Equal("InBehandeling", mine.Status.Tag); // RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto. @@ -53,18 +53,18 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur [Fact] public async Task Queue_excludes_concepts() { - var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" }); - var a = (await created.Content.ReadFromJsonAsync())!; + var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" }); + var a = (await created.Content.ReadFromJsonAsync())!; try { var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad")); res.EnsureSuccessStatusCode(); - var queue = (await res.Content.ReadFromJsonAsync>())!; + var queue = (await res.Content.ReadFromJsonAsync>())!; Assert.DoesNotContain(queue, x => x.Id == a.Id); } finally { - await _client.DeleteAsync($"/api/v1/applications/{a.Id}"); + await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}"); } } diff --git a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs index be845bb..42353b6 100644 --- a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs +++ b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs @@ -39,14 +39,14 @@ public class ZgwDivergenceTests b.ConfigurePrimaryHttpMessageHandler(() => stub)))); } - /// Doesn't call GET /applications first (unlike ApplicationTests.Create) — under + /// Doesn't call GET /aanvragen first (unlike ApplicationTests.Create) — under /// Zgw:Enabled=true that route goes through IZaakSource too, which this test's stub doesn't /// need to answer since every test here uses a fresh db and creates exactly one aanvraag. private static async Task CreateConcept(HttpClient client, string type = "registratie") { - var res = await client.PostAsJsonAsync("/api/v1/applications", new { type }); + var res = await client.PostAsJsonAsync("/api/v1/aanvragen", new { type }); res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; return body.Id; } @@ -78,11 +78,11 @@ public class ZgwDivergenceTests using var client = factory.CreateClient(); var id = await CreateConcept(client); - var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }); + var res = await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" }); // The local write is still authoritative: 200 with a real reference, not a 500. res.EnsureSuccessStatusCode(); - var body = (await res.Content.ReadFromJsonAsync())!; + var body = (await res.Content.ReadFromJsonAsync())!; Assert.NotEmpty(body.Referentie); var stored = ApplicationStore.ListAll().Single(a => a.Id == id); @@ -103,7 +103,7 @@ public class ZgwDivergenceTests using var client = factory.CreateClient(); var id = await CreateConcept(client); - var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }); + var res = await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" }); res.EnsureSuccessStatusCode(); var stored = ApplicationStore.ListAll().Single(a => a.Id == id); @@ -126,7 +126,7 @@ public class ZgwDivergenceTests using var client = factory.CreateClient(); var id = await CreateConcept(client); - (await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" })) + (await client.PostAsJsonAsync($"/api/v1/aanvragen/{id}/submit", new { diplomaHerkomst = "duo" })) .EnsureSuccessStatusCode(); var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError; diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 2d7c71d..725d977 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,7 +20,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 497 frontend behaviours across +**is** the suite, reshaped for a business reader. 505 frontend behaviours across 9 contexts; 261 backend behaviours across 42 test classes. @@ -418,6 +418,13 @@ classes. ### registratie +#### AanvragenStore + +- loads and parses the list +- cancels optimistically and confirms via the DELETE endpoint +- rolls back the removal and surfaces the error when the cancel fails +- clears a stale error on the next cancel attempt + #### AdminCasesStore - loads and parses the cross-owner list @@ -425,13 +432,6 @@ classes. - rolls back the removal and surfaces the error when the delete fails - clears a stale error on the next delete attempt -#### ApplicationsStore - -- loads and parses the list -- cancels optimistically and confirms via the DELETE endpoint -- rolls back the removal and surfaces the error when the cancel fails -- clears a stale error on the next cancel attempt - #### STEPS (fixed) - always has the same three steps @@ -544,12 +544,12 @@ classes. - parses each tag with its required fields - rejects a missing status, unknown tag, and wrong-typed fields -#### parseApplicationSummary +#### parseAanvraagSummary - maps a valid DTO to domain - rejects a bad type and non-objects -#### parseApplications / parseApplicationDetail +#### parseAanvragen / parseAanvraagDetail - parses a list and fails fast on a bad element - carries the opaque draft through detail @@ -569,6 +569,9 @@ classes. - maps a valid response into a DashboardView - rejects malformed responses instead of trusting them +- rejects a status whose tag is present but its required fields are missing +- rejects an unknown status tag +- rejects a person with an incomplete adres #### parseDuoLookup (trust boundary) @@ -617,6 +620,12 @@ classes. - statusColor is total over the union - herregistratieDeadline is only set for an active registration +#### sortForDashboard / concepten / ingediend + +- sorts Concept, then still-open, then resolved last +- does not mutate the input array +- concepten/ingediend split on the Concept tag + #### submit - stays in Invullen when the draft is incomplete (no diploma) @@ -923,6 +932,11 @@ classes. - leaves a non-API request untouched even when a subject is known - sends no header at all when no subject has ever been seen +#### successOf + +- unwraps a Success value +- is undefined for every other state + #### upload lifecycle messages - queued → progress → complete @@ -963,18 +977,7 @@ classes. ## Backend (by test class) -### AdminCasesTests - -- Admin lists every case with its owner -- Non admin is forbidden -- Admin can delete a submitted case -- Deleting a missing case is not found - -### ApplicationRuleTests - -- AanvraagStatusTag covers the published lifecycle - -### ApplicationTests +### AanvraagTests - Create then list shows a concept with step progress - Draft sync is readable back from detail @@ -993,6 +996,17 @@ classes. - AutoApprovable flips to goedgekeurd after the window - Manual case never auto advances +### AdminCasesTests + +- Admin lists every case with its owner +- Non admin is forbidden +- Admin can delete a submitted case +- Deleting a missing case is not found + +### ApplicationRuleTests + +- AanvraagStatusTag covers the published lifecycle + ### AuthzAuditTests - A denied admin action is recorded diff --git a/libs/shared/src/application/remote-data.spec.ts b/libs/shared/src/application/remote-data.spec.ts index ee7bd64..45e2d05 100644 --- a/libs/shared/src/application/remote-data.spec.ts +++ b/libs/shared/src/application/remote-data.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { RemoteData, map2, map } from './remote-data'; -import { loading, failure, success } from '../testing/remote-data'; +import { RemoteData, map2, map, successOf } from './remote-data'; +import { loading, failure, empty, success } from '../testing/remote-data'; const loadingRd: RemoteData = loading(); const failureRd: RemoteData = failure('x'); @@ -21,3 +21,15 @@ describe('RemoteData combinators', () => { expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 }); }); }); + +describe('successOf', () => { + it('unwraps a Success value', () => { + expect(successOf(ok(2))).toBe(2); + }); + + it('is undefined for every other state', () => { + expect(successOf(loadingRd)).toBeUndefined(); + expect(successOf(failureRd)).toBeUndefined(); + expect(successOf(empty())).toBeUndefined(); + }); +}); diff --git a/libs/shared/src/application/remote-data.ts b/libs/shared/src/application/remote-data.ts index 45828d8..2b9b4c1 100644 --- a/libs/shared/src/application/remote-data.ts +++ b/libs/shared/src/application/remote-data.ts @@ -80,3 +80,12 @@ export function andThen( ): RemoteData { return rd.tag === 'Success' ? f(rd.value) : rd; } + +/** Unwrap a Success value, or `undefined` for every other state. Used to narrow + an `` loaded slot: `` can't inherit a generic from a + sibling host input (Angular only infers a structural directive's type + parameter from an input on that same node), so the caller unwraps here + instead of through `let-`. */ +export function successOf(rd: RemoteData): T | undefined { + return rd.tag === 'Success' ? rd.value : undefined; +} diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 835788c..268d9c3 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -637,8 +637,8 @@ export class ApiClient { /** * @return OK */ - applicationsAll(): Promise { - let url_ = this.baseUrl + "/api/v1/applications"; + aanvragenAll(): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen"; url_ = url_.replace(/[?&]$/, ""); let options_: RequestInit = { @@ -649,17 +649,17 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsAll(_response); + return this.processAanvragenAll(_response); }); } - protected processApplicationsAll(response: Response): Promise { + protected processAanvragenAll(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[]; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[]; return result200; }); } else if (status !== 200 && status !== 204) { @@ -667,14 +667,14 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return Created */ - applicationsPOST(body: CreateApplicationRequest): Promise { - let url_ = this.baseUrl + "/api/v1/applications"; + aanvragenPOST(body: CreateAanvraagRequest): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen"; url_ = url_.replace(/[?&]$/, ""); const content_ = JSON.stringify(body); @@ -689,17 +689,17 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsPOST(_response); + return this.processAanvragenPOST(_response); }); } - protected processApplicationsPOST(response: Response): Promise { + protected processAanvragenPOST(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 201) { return response.text().then((_responseText) => { let result201: any = null; - result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationDetailDto; + result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagDetailDto; return result201; }); } else if (status === 409) { @@ -713,14 +713,14 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return OK */ - applicationsGET(id: string): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}"; + aanvragenGET(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -734,17 +734,17 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsGET(_response); + return this.processAanvragenGET(_response); }); } - protected processApplicationsGET(response: Response): Promise { + protected processAanvragenGET(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationDetailDto; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagDetailDto; return result200; }); } else if (status === 404) { @@ -756,14 +756,14 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return No Content */ - applicationsPUT(id: string, body: DraftSyncRequest): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}"; + aanvragenPUT(id: string, body: DraftSyncRequest): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -780,11 +780,11 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsPUT(_response); + return this.processAanvragenPUT(_response); }); } - protected processApplicationsPUT(response: Response): Promise { + protected processAanvragenPUT(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 204) { @@ -812,8 +812,8 @@ export class ApiClient { /** * @return No Content */ - applicationsDELETE(id: string): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}"; + aanvragenDELETE(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -826,11 +826,11 @@ export class ApiClient { }; return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processApplicationsDELETE(_response); + return this.processAanvragenDELETE(_response); }); } - protected processApplicationsDELETE(response: Response): Promise { + protected processAanvragenDELETE(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 204) { @@ -858,8 +858,8 @@ export class ApiClient { /** * @return OK */ - submit(id: string, body: SubmitApplicationRequest): Promise { - let url_ = this.baseUrl + "/api/v1/applications/{id}/submit"; + submit(id: string, body: AanvraagIndienenRequest): Promise { + let url_ = this.baseUrl + "/api/v1/aanvragen/{id}/submit"; if (id === undefined || id === null) throw new globalThis.Error("The parameter 'id' must be defined."); url_ = url_.replace("{id}", encodeURIComponent("" + id)); @@ -881,13 +881,13 @@ export class ApiClient { }); } - protected processSubmit(response: Response): Promise { + protected processSubmit(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubmitApplicationResponse; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagIndienenResponse; return result200; }); } else if (status === 400) { @@ -911,13 +911,13 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** * @return OK */ - casesAll(): Promise { + casesAll(): Promise { let url_ = this.baseUrl + "/api/v1/admin/cases"; url_ = url_.replace(/[?&]$/, ""); @@ -933,13 +933,13 @@ export class ApiClient { }); } - protected processCasesAll(response: Response): Promise { + protected processCasesAll(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[]; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[]; return result200; }); } else if (status === 403) { @@ -953,7 +953,7 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -1047,7 +1047,7 @@ export class ApiClient { /** * @return OK */ - werkvoorraad(): Promise { + werkvoorraad(): Promise { let url_ = this.baseUrl + "/api/v1/werkvoorraad"; url_ = url_.replace(/[?&]$/, ""); @@ -1063,13 +1063,13 @@ export class ApiClient { }); } - protected processWerkvoorraad(response: Response): Promise { + protected processWerkvoorraad(response: Response): Promise { const status = response.status; let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; if (status === 200) { return response.text().then((_responseText) => { let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[]; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AanvraagSummaryDto[]; return result200; }); } else if (status === 403) { @@ -1083,7 +1083,7 @@ export class ApiClient { return throwException("An unexpected server error occurred.", status, _responseText, _headers); }); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -1898,22 +1898,7 @@ export interface AantekeningDto { datum?: string | undefined; } -export interface AanvraagStatusDto { - tag?: string | undefined; - stepIndex?: number | undefined; - stepCount?: number | undefined; - referentie?: string | undefined; - manual?: boolean | undefined; - reden?: string | undefined; -} - -export interface AdresDto { - straat?: string | undefined; - postcode?: string | undefined; - woonplaats?: string | undefined; -} - -export interface ApplicationDetailDto { +export interface AanvraagDetailDto { id?: string | undefined; type?: string | undefined; status?: AanvraagStatusDto; @@ -1924,7 +1909,29 @@ export interface ApplicationDetailDto { submittedAt?: string | undefined; } -export interface ApplicationSummaryDto { +export interface AanvraagIndienenRequest { + diplomaHerkomst?: string | undefined; + uren?: number | undefined; + documents?: DocumentRefDto[] | undefined; + aanvullendeScholing?: boolean | undefined; + scholingPunten?: number | undefined; +} + +export interface AanvraagIndienenResponse { + referentie?: string | undefined; + status?: AanvraagStatusDto; +} + +export interface AanvraagStatusDto { + tag?: string | undefined; + stepIndex?: number | undefined; + stepCount?: number | undefined; + referentie?: string | undefined; + manual?: boolean | undefined; + reden?: string | undefined; +} + +export interface AanvraagSummaryDto { id?: string | undefined; type?: string | undefined; status?: AanvraagStatusDto; @@ -1935,6 +1942,12 @@ export interface ApplicationSummaryDto { owner?: string | undefined; } +export interface AdresDto { + straat?: string | undefined; + postcode?: string | undefined; + woonplaats?: string | undefined; +} + export interface AuthzAuditDto { at?: string | undefined; action?: string | undefined; @@ -1955,7 +1968,7 @@ export interface BeoordelingDocumentDto { } export interface BeoordelingViewDto { - aanvraag?: ApplicationSummaryDto; + aanvraag?: AanvraagSummaryDto; documenten?: BeoordelingDocumentDto[] | undefined; decisions?: BeoordelingDecisionsDto; } @@ -2014,7 +2027,7 @@ export interface ChangeRequestRequest { telefoon?: string | undefined; } -export interface CreateApplicationRequest { +export interface CreateAanvraagRequest { type?: string | undefined; } @@ -2275,19 +2288,6 @@ export interface SubOrgSummaryDto { publishedVersion?: number; } -export interface SubmitApplicationRequest { - diplomaHerkomst?: string | undefined; - uren?: number | undefined; - documents?: DocumentRefDto[] | undefined; - aanvullendeScholing?: boolean | undefined; - scholingPunten?: number | undefined; -} - -export interface SubmitApplicationResponse { - referentie?: string | undefined; - status?: AanvraagStatusDto; -} - export interface UploadCategoriesDto { categories?: DocumentCategoryDto[] | undefined; } diff --git a/libs/shared/src/testing/remote-data.ts b/libs/shared/src/testing/remote-data.ts index 1ed22f2..5889dd2 100644 --- a/libs/shared/src/testing/remote-data.ts +++ b/libs/shared/src/testing/remote-data.ts @@ -11,3 +11,5 @@ export const loading = (): RemoteData => ({ tag: 'Lo export const success = (value: T): RemoteData => ({ tag: 'Success', value }); export const failure = (error: E): RemoteData => ({ tag: 'Failure', error }); + +export const empty = (): RemoteData => ({ tag: 'Empty' });