From 4133b30e5d77beec816258c4be77df70a768ad2b Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Mon, 3 Aug 2026 09:01:09 +0200 Subject: [PATCH] feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a canBesluiten decision flag, gated by the same CanBeoordelen capability as the werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam method (WP-66 needs one anyway for the real write); owner BSN is masked. Fixes a real gap found while wiring this up: the behandelportal's login was still WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the werkvoorraad screen (WP-64) always denied in a real browser. A dev-only medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that. WP-65's own Risks note authorized splitting read from write across sessions given its size; this is the read half. The decision-recording mutation is next (65b). Co-Authored-By: Claude Sonnet 5 --- apps/behandelportal/src/app/app.config.ts | 7 +- apps/behandelportal/src/app/app.routes.ts | 8 + .../infrastructure/medewerker.interceptor.ts | 19 +++ .../src/app/auth/infrastructure/medewerker.ts | 23 +++ .../application/beoordeling.store.ts | 42 +++++ .../domain/beoordeling-view.spec.ts | 58 +++++++ .../behandeling/domain/beoordeling-view.ts | 50 ++++++ .../src/app/behandeling/domain/beoordeling.ts | 33 ++++ .../domain/werkvoorraad-item-view.ts | 25 +-- .../beoordeling.adapter.spec.ts | 68 ++++++++ .../infrastructure/beoordeling.adapter.ts | 99 ++++++++++++ .../beoordeling-documenten.component.ts | 30 ++++ .../beoordeling-documenten.stories.ts | 22 +++ .../app/behandeling/ui/beoordeling.page.ts | 81 ++++++++++ .../werkvoorraad-list.component.ts | 5 +- .../behandelportal/src/locale/messages.en.xlf | 60 ++++++++ apps/behandelportal/src/locale/messages.xlf | 145 +++++++++++++++--- backend/src/BigRegister.Api/Contracts/Dtos.cs | 14 ++ .../src/BigRegister.Api/Data/DocumentStore.cs | 13 ++ .../Domain/Beoordeling/BeoordelingRules.cs | 19 +++ backend/src/BigRegister.Api/Program.cs | 37 ++++- backend/swagger.json | 88 +++++++++++ .../BigRegister.Tests/BeoordelingTests.cs | 130 ++++++++++++++++ backend/tests/BigRegister.Tests/RuleTests.cs | 14 ++ docs/project/backlog/README.md | 4 +- .../WP-64-behandelportal-werkvoorraad.md | 7 + .../WP-65-behandelportal-beoordeling.md | 68 +++++++- docs/reference/roles-and-access.md | 19 ++- libs/shared/src/infrastructure/api-client.ts | 65 ++++++++ 29 files changed, 1195 insertions(+), 58 deletions(-) create mode 100644 apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts create mode 100644 apps/behandelportal/src/app/auth/infrastructure/medewerker.ts create mode 100644 apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts create mode 100644 apps/behandelportal/src/app/behandeling/domain/beoordeling-view.spec.ts create mode 100644 apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts create mode 100644 apps/behandelportal/src/app/behandeling/domain/beoordeling.ts create mode 100644 apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.spec.ts create mode 100644 apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts create mode 100644 apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts create mode 100644 apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.stories.ts create mode 100644 apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts create mode 100644 backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs create mode 100644 backend/tests/BigRegister.Tests/BeoordelingTests.cs diff --git a/apps/behandelportal/src/app/app.config.ts b/apps/behandelportal/src/app/app.config.ts index 879f854..bb59aa6 100644 --- a/apps/behandelportal/src/app/app.config.ts +++ b/apps/behandelportal/src/app/app.config.ts @@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en'; import { routes } from './app.routes'; import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor'; import { roleInterceptor } from '@shared/infrastructure/role.interceptor'; +import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor'; import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { SESSION_PORT } from '@shared/application/session.port'; import { SessionStore } from '@auth/application/session.store'; @@ -52,7 +53,11 @@ export const appConfig: ApplicationConfig = { ), // Dev-only: the ?scenario= toggle must never reach a production build, where // a query param could otherwise force errors on the live app. - provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])), + provideHttpClient( + withInterceptors( + isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [], + ), + ), provideApiClient(), { provide: SESSION_PORT, useExisting: SessionStore }, // Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the diff --git a/apps/behandelportal/src/app/app.routes.ts b/apps/behandelportal/src/app/app.routes.ts index cfabac4..96c85a2 100644 --- a/apps/behandelportal/src/app/app.routes.ts +++ b/apps/behandelportal/src/app/app.routes.ts @@ -18,6 +18,14 @@ export const routes: Routes = [ loadComponent: () => import('@behandeling/ui/werkvoorraad.page').then((m) => m.WerkvoorraadPage), }, + { + path: 'aanvraag/:id', + // Same capability the werkvoorraad list itself is gated by (WP-64/65) — the + // detail page is reachable only from a row already filtered to that capability. + canActivate: [capabilityGuard('aanvraag:beoordelen')], + loadComponent: () => + import('@behandeling/ui/beoordeling.page').then((m) => m.BeoordelingPage), + }, { path: 'beheer/stamdata', // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default diff --git a/apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts b/apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts new file mode 100644 index 0000000..3199ec1 --- /dev/null +++ b/apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts @@ -0,0 +1,19 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { MEDEWERKER_ID, currentRollen } from './medewerker'; + +/** + * Dev-only: stamps every API request as the fixed stand-in medewerker (`X-Medewerker`/ + * `X-Rollen`), so `StubIdentityProvider` resolves a `MedewerkerCaller` instead of falling + * through to its zorgverlener default. Unlike `roleInterceptor`'s allow-listed endpoints, + * this is the app's whole identity — every request needs it, since this app has no + * citizen-scoped screens to keep separate (see `CallerIdentity.Zorgverlener()`'s guard: a + * medewerker hitting a citizen-scoped SSP endpoint would 500, but no such endpoint exists + * here). Real employee-SSO login is out of scope for this POC (ADR-0002 §3 — the two + * apps' login flows are expected to diverge; this stand-in is that flow's placeholder). + */ +export const medewerkerInterceptor: HttpInterceptorFn = (req, next) => + req.url.includes('/api/v1/') + ? next( + req.clone({ setHeaders: { 'X-Medewerker': MEDEWERKER_ID, 'X-Rollen': currentRollen() } }), + ) + : next(req); diff --git a/apps/behandelportal/src/app/auth/infrastructure/medewerker.ts b/apps/behandelportal/src/app/auth/infrastructure/medewerker.ts new file mode 100644 index 0000000..92bd615 --- /dev/null +++ b/apps/behandelportal/src/app/auth/infrastructure/medewerker.ts @@ -0,0 +1,23 @@ +/** + * Dev-only medewerker rollen stand-in (the reading MECHANISM — mirrors + * `@shared/infrastructure/role.ts`'s `?role=` idiom, but app-local: `auth` is + * deliberately not shared between ssp and behandelportal, ADR-0002 §3). Until a real + * employee-SSO login exists, every request from this app identifies as one fixed + * medewerker; `?rollen=` lets a dev exercise the deny path (`?rollen=geen`) the same + * way `?role=` exercises ssp's role-gated pages. + * + * **Sticky within the tab (sessionStorage)**, same reasoning as `currentRole()`: a + * plain in-app navigation drops the query param, which would silently revert to the + * default and mask a deliberately-chosen `?rollen=geen`. + */ +const STORAGE_KEY = 'dev-rollen'; +export const MEDEWERKER_ID = 'medewerker-1'; + +export function currentRollen(): string { + const fromUrl = new URLSearchParams(window.location.search).get('rollen'); + if (fromUrl !== null) { + sessionStorage.setItem(STORAGE_KEY, fromUrl); + return fromUrl; + } + return sessionStorage.getItem(STORAGE_KEY) ?? 'behandelaar'; +} diff --git a/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts b/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts new file mode 100644 index 0000000..da5befc --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts @@ -0,0 +1,42 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { RemoteData } from '@shared/application/remote-data'; +import { BeoordelingView } from '@behandeling/domain/beoordeling'; +import { + BeoordelingAdapter, + parseBeoordelingView, +} from '@behandeling/infrastructure/beoordeling.adapter'; + +type Err = Error | undefined; + +/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`. + Keyed by id: navigating to a different case resets to Loading. */ +@Injectable({ providedIn: 'root' }) +export class BeoordelingStore { + private adapter = inject(BeoordelingAdapter); + + private id: string | undefined; + private state = signal>({ tag: 'Loading' }); + readonly view = this.state.asReadonly(); + + async load(id: string) { + if (this.id !== id) this.state.set({ tag: 'Loading' }); + this.id = id; + try { + const parsed = parseBeoordelingView(await this.adapter.get(id)); + // A navigation to a different case may have started while this one was in flight. + if (this.id !== id) return; + this.state.set( + parsed.ok + ? { tag: 'Success', value: parsed.value } + : { tag: 'Failure', error: new Error(parsed.error) }, + ); + } catch (e) { + if (this.id !== id) return; + this.state.set({ tag: 'Failure', error: e as Error }); + } + } + + reload() { + if (this.id) void this.load(this.id); + } +} diff --git a/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.spec.ts b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.spec.ts new file mode 100644 index 0000000..e6c30af --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { statusLabel, detailRows, TYPE_LABELS } from './beoordeling-view'; +import { BeoordelingView } from './beoordeling'; + +const base: Omit = { + id: '1', + type: 'herregistratie', + owner: '*****2333', + submittedAt: '2024-05-12', + documenten: [], + canBesluiten: true, +}; + +describe('statusLabel', () => { + it('labels every tag distinctly', () => { + const labels = [ + statusLabel({ tag: 'Ingediend', referentie: 'R1' }), + statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }), + statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true }), + statusLabel({ tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'x' }), + statusLabel({ tag: 'Goedgekeurd', referentie: 'R1' }), + statusLabel({ tag: 'Afgewezen', referentie: 'R1', reden: 'x' }), + ]; + expect(new Set(labels).size).toBe(labels.length); + }); +}); + +describe('detailRows', () => { + it('lists soort/status/referentie/eigenaar/ingediend', () => { + const rows = detailRows({ + ...base, + status: { tag: 'InBehandeling', referentie: 'R1', manual: false }, + }); + const values = rows.map((r) => r.value); + expect(values).toContain(TYPE_LABELS.herregistratie); + expect(values).toContain('R1'); + expect(values).toContain(base.owner); + expect(rows.length).toBe(5); + }); + + it('adds a reden row for Afgewezen and MeerInfoGevraagd only', () => { + const afgewezen = detailRows({ + ...base, + status: { tag: 'Afgewezen', referentie: 'R1', reden: 'Onvoldoende uren' }, + }); + expect(afgewezen.length).toBe(6); + expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren'); + + const meerInfo = detailRows({ + ...base, + status: { tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'Diploma ontbreekt' }, + }); + expect(meerInfo.length).toBe(6); + + const goedgekeurd = detailRows({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R1' } }); + expect(goedgekeurd.length).toBe(5); + }); +}); diff --git a/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts new file mode 100644 index 0000000..091ee01 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts @@ -0,0 +1,50 @@ +import { formatDatumNl } from '@shared/kernel/datum'; +import { AanvraagType } from './werkvoorraad-item'; +import { BeoordelingStatus, BeoordelingView } from './beoordeling'; + +/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling + detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in + `werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two + status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */ + +export const TYPE_LABELS: Record = { + registratie: $localize`:@@werkvoorraad.type.registratie:Inschrijving`, + herregistratie: $localize`:@@werkvoorraad.type.herregistratie:Herregistratie`, + intake: $localize`:@@werkvoorraad.type.intake:Herregistratie-intake`, +}; + +export function statusLabel(status: BeoordelingStatus): string { + switch (status.tag) { + case 'Ingediend': + return $localize`:@@werkvoorraad.status.ingediend:Ingediend`; + case 'InBehandeling': + return status.manual + ? $localize`:@@werkvoorraad.status.inBehandelingHandmatig:In behandeling (handmatig)` + : $localize`:@@werkvoorraad.status.inBehandeling:In behandeling`; + case 'MeerInfoGevraagd': + return $localize`:@@beoordeling.status.meerInfoGevraagd:Meer informatie gevraagd`; + case 'Goedgekeurd': + return $localize`:@@beoordeling.status.goedgekeurd:Goedgekeurd`; + case 'Afgewezen': + return $localize`:@@beoordeling.status.afgewezen:Afgewezen`; + } +} + +/** Key/value rows for the beoordeling detail page (CIBG Datablock). */ +export function detailRows(view: BeoordelingView): { key: string; value: string }[] { + const s = view.status; + const rows = [ + { key: $localize`:@@beoordeling.detail.soort:Soort aanvraag`, value: TYPE_LABELS[view.type] }, + { key: $localize`:@@beoordeling.detail.status:Status`, value: statusLabel(s) }, + { key: $localize`:@@beoordeling.detail.referentie:Referentie`, value: s.referentie }, + { key: $localize`:@@beoordeling.detail.eigenaar:Eigenaar (BSN)`, value: view.owner }, + { + key: $localize`:@@beoordeling.detail.ingediend:Ingediend op`, + value: view.submittedAt ? formatDatumNl(view.submittedAt) : '—', + }, + ]; + if (s.tag === 'Afgewezen' || s.tag === 'MeerInfoGevraagd') { + rows.push({ key: $localize`:@@beoordeling.detail.reden:Reden`, value: s.reden }); + } + return rows; +} diff --git a/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts b/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts new file mode 100644 index 0000000..5a1bb7e --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts @@ -0,0 +1,33 @@ +import { AanvraagType } from './werkvoorraad-item'; + +/** + * A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) — + * wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open" + * tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept` + * — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet). + */ +export type BeoordelingStatus = + | { tag: 'Ingediend'; referentie: string } + | { tag: 'InBehandeling'; referentie: string; manual: boolean } + | { tag: 'MeerInfoGevraagd'; referentie: string; reden: string } + | { tag: 'Goedgekeurd'; referentie: string } + | { tag: 'Afgewezen'; referentie: string; reden: string }; + +export interface BeoordelingDocument { + documentId: string; + categoryId: string; + fileName: string; +} + +export interface BeoordelingView { + id: string; + type: AanvraagType; + status: BeoordelingStatus; + /** The BSN of the citizen the aanvraag belongs to — masked by the server. */ + owner: string; + submittedAt?: string; + documenten: BeoordelingDocument[]; + /** Decision flag (ADR-0001): the server computes whether a decision may be recorded; + the FE renders it, it never recomputes the lifecycle. */ + canBesluiten: boolean; +} diff --git a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts index 7e4f01e..c91536b 100644 --- a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts +++ b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts @@ -1,25 +1,12 @@ import { formatDatumNl } from '@shared/kernel/datum'; -import { WerkvoorraadItem, WerkvoorraadStatus, AanvraagType } from './werkvoorraad-item'; +import { WerkvoorraadItem } from './werkvoorraad-item'; +import { TYPE_LABELS, statusLabel } from './beoordeling-view'; /** View-model mapping for a queue row: type/status → the fields for a CIBG - "aanvragen" row. Pure, no Angular — the UI renders these, it does not derive them. */ - -export const TYPE_LABELS: Record = { - registratie: $localize`:@@werkvoorraad.type.registratie:Inschrijving`, - herregistratie: $localize`:@@werkvoorraad.type.herregistratie:Herregistratie`, - intake: $localize`:@@werkvoorraad.type.intake:Herregistratie-intake`, -}; - -export function statusLabel(status: WerkvoorraadStatus): string { - switch (status.tag) { - case 'Ingediend': - return $localize`:@@werkvoorraad.status.ingediend:Ingediend`; - case 'InBehandeling': - return status.manual - ? $localize`:@@werkvoorraad.status.inBehandelingHandmatig:In behandeling (handmatig)` - : $localize`:@@werkvoorraad.status.inBehandeling:In behandeling`; - } -} + "aanvragen" row. Pure, no Angular — the UI renders these, it does not derive them. + `TYPE_LABELS`/`statusLabel` live in `./beoordeling-view` (the wider status union) and + are re-exported here so existing imports of this file keep working. */ +export { TYPE_LABELS, statusLabel }; export interface WerkvoorraadRow { heading: string; diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.spec.ts b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.spec.ts new file mode 100644 index 0000000..f367639 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { parseBeoordelingStatus, parseBeoordelingView } from './beoordeling.adapter'; + +const view = { + aanvraag: { + id: 'a1', + type: 'registratie', + status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: true }, + documentIds: ['d1'], + createdAt: '2026-07-01T10:00:00Z', + updatedAt: '2026-07-01T10:05:00Z', + submittedAt: '2026-07-01T10:05:00Z', + owner: '*****2333', + }, + documenten: [{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' }], + decisions: { canBesluiten: true }, +}; + +describe('parseBeoordelingStatus', () => { + it('parses each tag with its required fields', () => { + expect(parseBeoordelingStatus({ tag: 'Ingediend', referentie: 'BIG-1' }).ok).toBe(true); + expect( + parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: false }).ok, + ).toBe(true); + expect( + parseBeoordelingStatus({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'x' }).ok, + ).toBe(true); + expect(parseBeoordelingStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true); + expect(parseBeoordelingStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' }).ok).toBe( + true, + ); + }); + + it('rejects a missing status, unknown tag, and wrong-typed fields', () => { + expect(parseBeoordelingStatus(undefined).ok).toBe(false); + expect(parseBeoordelingStatus({ tag: 'Concept' } as never).ok).toBe(false); + expect(parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false); + }); +}); + +describe('parseBeoordelingView', () => { + it('maps a valid DTO to domain', () => { + const r = parseBeoordelingView(view); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.value.type).toBe('registratie'); + expect(r.value.documenten).toEqual([ + { documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' }, + ]); + expect(r.value.canBesluiten).toBe(true); + }); + + it('rejects a missing owner, bad type, missing decisions, and non-objects', () => { + expect(parseBeoordelingView(null).ok).toBe(false); + expect( + parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, owner: undefined } }).ok, + ).toBe(false); + expect( + parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, type: 'onbekend' } }).ok, + ).toBe(false); + expect(parseBeoordelingView({ ...view, decisions: {} }).ok).toBe(false); + }); + + it('defaults an absent documenten list to empty', () => { + const r = parseBeoordelingView({ ...view, documenten: undefined }); + expect(r.ok && r.value.documenten).toEqual([]); + }); +}); diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts new file mode 100644 index 0000000..fbb0d5d --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts @@ -0,0 +1,99 @@ +import { Injectable, inject } from '@angular/core'; +import { Result, ok, err } from '@shared/kernel/fp'; +import { + ApiClient, + BeoordelingViewDto, + AanvraagStatusDto, +} from '@shared/infrastructure/api-client'; +import { + BeoordelingView, + BeoordelingStatus, + BeoordelingDocument, +} from '@behandeling/domain/beoordeling'; +import { AanvraagType } from '@behandeling/domain/werkvoorraad-item'; + +/** + * Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its + * HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated + + * mapped to domain by the parse* boundary below. + */ +@Injectable({ providedIn: 'root' }) +export class BeoordelingAdapter { + private client = inject(ApiClient); + + get(id: string): Promise { + return this.client.beoordeling(id); + } +} + +const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake']; + +export function parseBeoordelingStatus( + s: AanvraagStatusDto | undefined, +): Result { + if (!s || typeof s.tag !== 'string') return err('beoordeling: missing status'); + switch (s.tag) { + case 'Ingediend': + if (typeof s.referentie !== 'string') return err('beoordeling: bad Ingediend status'); + return ok({ tag: 'Ingediend', referentie: s.referentie }); + case 'InBehandeling': + if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean') + return err('beoordeling: bad InBehandeling status'); + return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual }); + case 'MeerInfoGevraagd': + if (typeof s.referentie !== 'string' || typeof s.reden !== 'string') + return err('beoordeling: bad MeerInfoGevraagd status'); + return ok({ tag: 'MeerInfoGevraagd', referentie: s.referentie, reden: s.reden }); + case 'Goedgekeurd': + if (typeof s.referentie !== 'string') return err('beoordeling: bad Goedgekeurd status'); + return ok({ tag: 'Goedgekeurd', referentie: s.referentie }); + case 'Afgewezen': + if (typeof s.referentie !== 'string' || typeof s.reden !== 'string') + return err('beoordeling: bad Afgewezen status'); + return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden }); + default: + return err(`beoordeling: unknown status tag ${s.tag}`); + } +} + +function parseDocument(json: unknown): Result { + if (typeof json !== 'object' || json === null) return err('beoordeling: document not an object'); + const d = json as { documentId?: unknown; categoryId?: unknown; fileName?: unknown }; + if (typeof d.documentId !== 'string') return err('beoordeling: document missing documentId'); + if (typeof d.categoryId !== 'string') return err('beoordeling: document missing categoryId'); + if (typeof d.fileName !== 'string') return err('beoordeling: document missing fileName'); + return ok({ documentId: d.documentId, categoryId: d.categoryId, fileName: d.fileName }); +} + +export function parseBeoordelingView(json: unknown): Result { + if (typeof json !== 'object' || json === null) return err('beoordeling: not an object'); + const dto = json as BeoordelingViewDto; + const a = dto.aanvraag; + if (!a || typeof a.id !== 'string') return err('beoordeling: missing aanvraag.id'); + if (typeof a.type !== 'string' || !AANVRAAG_TYPES.includes(a.type)) + return err(`beoordeling: bad type ${a.type}`); + if (typeof a.owner !== 'string' || !a.owner) return err('beoordeling: missing owner'); + + const status = parseBeoordelingStatus(a.status); + if (!status.ok) return status; + + const documenten: BeoordelingDocument[] = []; + for (const item of dto.documenten ?? []) { + const parsed = parseDocument(item); + if (!parsed.ok) return parsed; + documenten.push(parsed.value); + } + + if (typeof dto.decisions?.canBesluiten !== 'boolean') + return err('beoordeling: missing decisions.canBesluiten'); + + return ok({ + id: a.id, + type: a.type as AanvraagType, + status: status.value, + owner: a.owner, + submittedAt: a.submittedAt, + documenten, + canBesluiten: dto.decisions.canBesluiten, + }); +} diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts new file mode 100644 index 0000000..d8c4210 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts @@ -0,0 +1,30 @@ +import { Component, input } from '@angular/core'; +import { BeoordelingDocument } from '@behandeling/domain/beoordeling'; + +/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing + (pre-existing, unauthenticated — same as ssp's own document previews) content + endpoint. No new shared atom: a context-local list, not a reusable building block. */ +@Component({ + selector: 'app-beoordeling-documenten', + template: ` + @if (documenten().length === 0) { +

Geen documenten.

+ } @else { + + } + `, +}) +export class BeoordelingDocumentenComponent { + documenten = input.required(); +} diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.stories.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.stories.ts new file mode 100644 index 0000000..4add142 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.stories.ts @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { BeoordelingDocumentenComponent } from './beoordeling-documenten.component'; + +const meta: Meta = { + title: 'Domein/Behandeling/Beoordeling Documenten', + component: BeoordelingDocumentenComponent, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + documenten: [ + { documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' }, + { documentId: 'd2', categoryId: 'identiteit', fileName: 'paspoort.pdf' }, + ], + }, +}; + +export const Empty: Story = { + args: { documenten: [] }, +}; diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts new file mode 100644 index 0000000..186901e --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts @@ -0,0 +1,81 @@ +import { Component, computed, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +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 { BeoordelingStore } from '@behandeling/application/beoordeling.store'; +import { detailRows } from '@behandeling/domain/beoordeling-view'; +import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component'; + +/** + * Page: one aanvraag's beoordeling detail (WP-65, read side). The werkvoorraad list + * (WP-64) links here. Recording a decision is this WP's second half — for now the + * page only shows status/documents; `canBesluiten` is already carried by the view so + * the decision form has zero further backend round-trip to add. + */ +@Component({ + selector: 'app-beoordeling-page', + imports: [ + PageShellComponent, + AlertComponent, + ButtonComponent, + SkeletonComponent, + DataBlockComponent, + DataRowComponent, + BeoordelingDocumentenComponent, + ...ASYNC, + ], + template: ` + + + + + + + {{ failedText }} + {{ retryText }} + + + @if (view(); as v) { + + @for (row of rows(v); track row.key) { +
+ } +
+ + + + } +
+
+
+ `, +}) +export class BeoordelingPage { + protected store = inject(BeoordelingStore); + private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? ''; + + protected heading = $localize`:@@beoordeling.heading:Aanvraag`; + protected detailHeading = $localize`:@@beoordeling.detail.heading:Aanvraaggegevens`; + protected documentenHeading = $localize`:@@beoordeling.documenten.heading:Documenten`; + protected failedText = $localize`:@@beoordeling.failed:De aanvraag kon niet worden geladen.`; + protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`; + + protected rows = detailRows; + protected readonly view = computed(() => { + const rd = this.store.view(); + return rd.tag === 'Success' ? rd.value : undefined; + }); + + constructor() { + void this.store.load(this.id); + } + + protected reload() { + this.store.reload(); + } +} diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts index ac51d08..c40cf68 100644 --- a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts @@ -5,8 +5,8 @@ import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view'; /** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition - of the two existing shared/ui molecules, no new atom. Rows are informational only - (no `to`): opening a case's detail is WP-65. */ + of the two existing shared/ui molecules, no new atom. Each row links to the + beoordeling detail page (WP-65). */ @Component({ selector: 'app-werkvoorraad-list', imports: [ApplicationListComponent, ApplicationLinkComponent], @@ -19,6 +19,7 @@ import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view'; [heading]="row.heading" [subtitle]="row.subtitle" [status]="row.status" + [to]="'/aanvraag/' + item.id" > } diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf index 581b99a..0626e77 100644 --- a/apps/behandelportal/src/locale/messages.en.xlf +++ b/apps/behandelportal/src/locale/messages.en.xlf @@ -2960,6 +2960,66 @@ Opnieuw proberen Try again + + Meer informatie gevraagd + More information requested + + + Goedgekeurd + Approved + + + Afgewezen + Rejected + + + Soort aanvraag + Application type + + + Status + Status + + + Referentie + Reference + + + Eigenaar (BSN) + Owner (BSN) + + + Ingediend op + Submitted on + + + Reden + Reason + + + Aanvraag + Application + + + Aanvraaggegevens + Application details + + + Documenten + Documents + + + Geen documenten. + No documents. + + + De aanvraag kon niet worden geladen. + The application could not be loaded. + + + Opnieuw proberen + Try again + Er is geen stamdata om te beheren. There is no stamdata to manage. diff --git a/apps/behandelportal/src/locale/messages.xlf b/apps/behandelportal/src/locale/messages.xlf index 00d1744..f14724c 100644 --- a/apps/behandelportal/src/locale/messages.xlf +++ b/apps/behandelportal/src/locale/messages.xlf @@ -54,99 +54,204 @@ Inschrijving - apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 8 + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 11 Herregistratie - apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 9 + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 12 Herregistratie-intake - apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 10 + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 13 Ingediend - apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 16 + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 19 In behandeling (handmatig) - apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 19 + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 22 In behandeling - apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 20 + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 23 + + + + Meer informatie gevraagd + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 25 + + + + Goedgekeurd + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 27 + + + + Afgewezen + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 29 + + + + Soort aanvraag + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 37 + + + + Status + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 38 + + + + Referentie + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 39 + + + + Eigenaar (BSN) + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 40 + + + + Ingediend op + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 42 + + + + Reden + + apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts + 47 ingediend op apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 36 + 23 BSN apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts - 41 + 28 + + + + Geen documenten. + + apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts + 11,13 + + + + Aanvraag + + apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts + 62 + + + + Aanvraaggegevens + + apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts + 63 + + + + Documenten + + apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts + 64 + + + + De aanvraag kon niet worden geladen. + + apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts + 65 + + + + Opnieuw proberen + + apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts + 66 Werkvoorraad apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts - 57 + 64 Aanvragen die op beoordeling wachten. apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts - 58 + 65 U hebt geen rechten om de werkvoorraad te bekijken. apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts - 59 + 66 De werkvoorraad kon niet worden geladen. apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts - 60 + 67 Er staan geen aanvragen open. apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts - 61 + 68 Opnieuw proberen apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts - 62 + 69 diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 62f6ff5..cd2d2fb 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -126,6 +126,20 @@ public sealed record SubmitApplicationRequest( public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status); +// --- Beoordeling (WP-65): the behandelportal's case-detail screen. --- + +public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId, string FileName); + +/// Decision flag (ADR-0001): the FE renders "may I decide", it never recomputes the +/// lifecycle. One flag today because all three decision actions share one rule +/// (BeoordelingRules.CanDecide); split into per-action flags if that ever diverges. +public sealed record BeoordelingDecisionsDto(bool CanBesluiten); + +public sealed record BeoordelingViewDto( + ApplicationSummaryDto Aanvraag, + IReadOnlyList Documenten, + BeoordelingDecisionsDto Decisions); + // --- Brief (letter composition) contracts --- // Rich text is a serialisable node tree; the node union is flattened with a `Type` // discriminator + nullable fields, the same wire convention as AanvraagStatusDto. diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 3737f12..ffcf436 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -83,6 +83,19 @@ public static class DocumentStore } } + /// Documents by DocumentId (WP-65's beoordeling detail reads an aanvraag's already- + /// linked documents) — the DocumentId-keyed counterpart of , which is + /// keyed by the wizard's own LocalId instead. + public static IReadOnlyList ByIds(IEnumerable documentIds) + { + var set = documentIds.ToHashSet(); + lock (_gate) + { + using var db = Db.Create(); + return db.Documents.Where(d => set.Contains(d.DocumentId)).ToList(); + } + } + /// Persist the DRC url an OpenZaak upload (WP-51) registered for a document. public static void SetDrcUrl(string documentId, string drcUrl) { diff --git a/backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs b/backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs new file mode 100644 index 0000000..d88d0a7 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs @@ -0,0 +1,19 @@ +using BigRegister.Api.Data; + +namespace BigRegister.Domain.Beoordeling; + +/// +/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Read-side +/// today ( only, backing the beoordeling detail screen's decision +/// flag) — the decision-recording rules (which besluit is legal, whether it needs a +/// toelichting) land alongside the mutation endpoint in this WP's second half. +/// +public static class BeoordelingRules +{ + /// A behandelaar may record a decision while the aanvraag is in an open, non-terminal + /// status. Concept never reaches here (the endpoint 404s it before calling this); a case + /// already `Goedgekeurd`/`Afgewezen` is final. + public static bool CanDecide(AanvraagStatusTag current) => + current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling + or AanvraagStatusTag.MeerInfoGevraagd; +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 86d481b..ebfef8d 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -5,6 +5,7 @@ using System.Text.Json.Serialization; using BigRegister.Api.Contracts; using BigRegister.Api.Data; using BigRegister.Domain.Authorization; +using BigRegister.Domain.Beoordeling; using BigRegister.Domain.Diplomas; using BigRegister.Domain.Documents; using BigRegister.Domain.Features; @@ -413,13 +414,34 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct // Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`, // WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags — // a behandelaar never needs to see a Concept (not their business yet) or a terminal case. -api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Werkvoorraad(ctx, () => +api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, "werkvoorraad", () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow) .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") .ToList()))) .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). +api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) => + Beoordelen(ctx, $"aanvraag/{id}", () => + { + var c = zaken.ListCases(DateTimeOffset.UtcNow).FirstOrDefault(x => x.Id == id); + if (c is null || c.Status.Tag == "Concept") return Results.NotFound(); + var docs = DocumentStore.ByIds(c.DocumentIds) + .Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList(); + var masked = c with { Owner = MaskTail(c.Owner!, 3) }; + var decisions = new BeoordelingDecisionsDto( + BeoordelingRules.CanDecide(Enum.Parse(c.Status.Tag))); + return Results.Ok(new BeoordelingViewDto(masked, docs, decisions)); + })) +.Produces() +.ProducesProblem(StatusCodes.Status403Forbidden) +.Produces(StatusCodes.Status404NotFound); + // OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is // provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it). // The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly @@ -708,14 +730,15 @@ IResult CasesAdmin(HttpContext ctx, Func action) statusCode: StatusCodes.Status403Forbidden); } -// One gate for the werkvoorraad read — the enforce twin of `CanBeoordelen` (WP-62/64). -// Unlike the other *Admin gates above, this checks the CallerIdentity directly (medewerker -// rollen), not a role-only Principal — a zorgverlener with X-Role=admin still gets denied. -IResult Werkvoorraad(HttpContext ctx, Func action) +// One gate for every behandelaar endpoint (werkvoorraad, WP-64; beoordeling detail, WP-65) — +// the enforce twin of `CanBeoordelen` (WP-62). Unlike the other *Admin gates above, this +// checks the CallerIdentity directly (medewerker rollen), not a role-only Principal — a +// zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row. +IResult Beoordelen(HttpContext ctx, string resource, Func action) { if (Authz.CanBeoordelen(ctx.Caller())) return action(); - AuditAuthz(ctx, "aanvraag:beoordelen", "werkvoorraad", false, Authz.ResolvePrincipal(ctx)); - return Results.Problem(detail: "Alleen een behandelaar mag de werkvoorraad bekijken.", + AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx)); + return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.", statusCode: StatusCodes.Status403Forbidden); } diff --git a/backend/swagger.json b/backend/swagger.json index 4df26c7..bbd6165 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -798,6 +798,48 @@ } } }, + "/api/v1/beoordeling/{id}": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeoordelingViewDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + } + }, "/api/v1/admin/cases/{id}": { "delete": { "tags": [ @@ -1606,6 +1648,52 @@ }, "additionalProperties": false }, + "BeoordelingDecisionsDto": { + "type": "object", + "properties": { + "canBesluiten": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "BeoordelingDocumentDto": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "nullable": true + }, + "categoryId": { + "type": "string", + "nullable": true + }, + "fileName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "BeoordelingViewDto": { + "type": "object", + "properties": { + "aanvraag": { + "$ref": "#/components/schemas/ApplicationSummaryDto" + }, + "documenten": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BeoordelingDocumentDto" + }, + "nullable": true + }, + "decisions": { + "$ref": "#/components/schemas/BeoordelingDecisionsDto" + } + }, + "additionalProperties": false + }, "BriefDecisionsDto": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/BeoordelingTests.cs b/backend/tests/BigRegister.Tests/BeoordelingTests.cs new file mode 100644 index 0000000..a2eedd9 --- /dev/null +++ b/backend/tests/BigRegister.Tests/BeoordelingTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +/// WP-65 (read side): one aanvraag's case-treatment detail, gated by the same medewerker +/// capability (`CanBeoordelen`, WP-62) as the werkvoorraad list (WP-64). +public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private static HttpRequestMessage AsBehandelaar(HttpMethod method, string path) + { + var req = new HttpRequestMessage(method, path); + req.Headers.Add("X-Medewerker", "medewerker-1"); + return req; + } + + private static MultipartFormDataContent UploadForm(string localId, string categoryId, string fileName) + { + var content = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + content.Add(file, "file", fileName); + content.Add(new StringContent(categoryId), "categoryId"); + content.Add(new StringContent(localId), "localId"); + content.Add(new StringContent("registratie"), "wizardId"); + return content; + } + + /// 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() + { + var created = await _client.PostAsJsonAsync("/api/v1/applications", 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 + { + diplomaHerkomst = "handmatig", + documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } }, + }); + submit.EnsureSuccessStatusCode(); + return (a, doc.DocumentId!); + } + + private Task DeleteAsAdmin(string id) => _client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/cases/{id}") + { + Headers = { { "X-Role", "admin" } }, + }); + + [Fact] + public async Task Detail_shows_status_documents_and_a_masked_owner() + { + var (a, documentId) = await CreateManualCaseWithDocument(); + try + { + var res = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}")); + res.EnsureSuccessStatusCode(); + var view = (await res.Content.ReadFromJsonAsync())!; + + Assert.Equal("InBehandeling", view.Aanvraag.Status.Tag); + Assert.Single(view.Documenten); + Assert.Equal(documentId, view.Documenten[0].DocumentId); + Assert.Equal("diploma", view.Documenten[0].CategoryId); + Assert.True(view.Decisions.CanBesluiten); + // masked: not empty, but not the full 9-digit BSN either + var owner = view.Aanvraag.Owner!; + Assert.NotEmpty(owner); + Assert.Contains('*', owner); + } + finally + { + await DeleteAsAdmin(a.Id); + } + } + + [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())!; + try + { + var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}")); + Assert.Equal(HttpStatusCode.NotFound, conceptRes.StatusCode); + + var unknownRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, "/api/v1/beoordeling/does-not-exist")); + Assert.Equal(HttpStatusCode.NotFound, unknownRes.StatusCode); + } + finally + { + await _client.DeleteAsync($"/api/v1/applications/{concept.Id}"); + } + } + + [Fact] + public async Task Zorgverlener_is_forbidden_even_with_admin_role() + { + var (a, _) = await CreateManualCaseWithDocument(); + try + { + var req = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"); + req.Headers.Add("X-Role", "admin"); // admin role, but no X-Medewerker — still a zorgverlener + Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); + } + finally + { + await DeleteAsAdmin(a.Id); + } + } + + [Fact] + public async Task Medewerker_without_behandelaar_rol_is_forbidden() + { + var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/beoordeling/anything"); + req.Headers.Add("X-Medewerker", "medewerker-2"); + req.Headers.Add("X-Rollen", "geen"); + Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); + } +} diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs index baba3f1..c984c68 100644 --- a/backend/tests/BigRegister.Tests/RuleTests.cs +++ b/backend/tests/BigRegister.Tests/RuleTests.cs @@ -1,3 +1,5 @@ +using BigRegister.Api.Data; +using BigRegister.Domain.Beoordeling; using BigRegister.Domain.Diplomas; using BigRegister.Domain.Documents; using BigRegister.Domain.Registrations; @@ -179,3 +181,15 @@ public class SubmissionRuleTests public void Phone_change_is_validated(string telefoon, string? expected) => Assert.Equal(expected, SubmissionRules.RejectPhoneChange(telefoon)); } + +public class BeoordelingRuleTests +{ + [Theory] + [InlineData(AanvraagStatusTag.Ingediend, true)] + [InlineData(AanvraagStatusTag.InBehandeling, true)] + [InlineData(AanvraagStatusTag.MeerInfoGevraagd, true)] + [InlineData(AanvraagStatusTag.Goedgekeurd, false)] + [InlineData(AanvraagStatusTag.Afgewezen, false)] + public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) => + Assert.Equal(expected, BeoordelingRules.CanDecide(tag)); +} diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 612f54c..5a85e30 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -114,8 +114,8 @@ for its existing violations, so every WP ends green. | [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done | | [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done | | [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done | -| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | todo | -| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | todo | +| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done | +| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | in progress (65a done) | | [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | todo | | [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done | diff --git a/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md b/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md index 2da1900..560ab19 100644 --- a/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md +++ b/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md @@ -75,6 +75,13 @@ atom. The stopgap `behandeling.page.ts`/`BehandelingPage` (WP-61's scaffold plac its own TODO said to replace it) is gone; `/dashboard` now loads `WerkvoorraadPage` directly, and the redundant `/behandeling` route (same placeholder, two paths) was dropped. +**Correction (found during WP-65):** this WP's Verification line ("manual: log in as a stub +medewerker, see the queue populated") could not actually have passed — the behandelportal's +login was still WP-61's copy-pasted citizen/BSN DigiD flow, nothing sent `X-Medewerker`, so +`WerkvoorraadPage` always rendered its denial alert in a real browser. CI stayed green +regardless (none of this WP's tests exercise the browser gate). Fixed in WP-65 with a +dev-only `medewerkerInterceptor` — see that WP's Progress notes. + ## Verification `npm run ci` in the behandelportal app; `cd backend && dotnet test`; manual: log in as a diff --git a/docs/project/backlog/WP-65-behandelportal-beoordeling.md b/docs/project/backlog/WP-65-behandelportal-beoordeling.md index 101900b..a2b5c9c 100644 --- a/docs/project/backlog/WP-65-behandelportal-beoordeling.md +++ b/docs/project/backlog/WP-65-behandelportal-beoordeling.md @@ -1,6 +1,6 @@ # WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen -Status: todo +Status: in progress (65a — detail read — done; 65b — decision write — not started) Phase: 11 — Behandelportal ## Why @@ -44,6 +44,72 @@ New mutation endpoint + command in `BigRegister.Api`; `behandeling/ui` detail pa 3. Wire the werkvoorraad list (WP-64) to link into this detail page. 4. Storybook stories + a11y for the new detail/decision UI. +## Progress notes (65a — done) + +This WP's own Risks note offered an explicit escape hatch ("split detail-view (read) from +decision-recording (write) into two sessions if it feels too big"). Taken: 65a below is done +and committed; 65b (the mutation + decision machine + form) is the remaining half — see its +own separate session notes once started. Don't relitigate the split; do relitigate nothing +else pre-made here. + +**Blocking gap found and fixed, not in this WP's original scope:** the behandelportal's +login was still WP-61's copy-pasted citizen/BSN DigiD flow — nothing sent `X-Medewerker`, so +`StubIdentityProvider` always resolved a zorgverlener, `GET /me` never contained +`aanvraag:beoordelen`, and WP-64's `WerkvoorraadPage` always rendered its denial alert in a +real browser. WP-64's own Verification line ("manual: log in as a stub medewerker, see the +queue populated") could not have passed as written — CI stayed green because none of its +tests exercise the browser gate. Fixed with a dev-only `medewerkerInterceptor` +(`apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` + +`medewerker.ts`), mirroring `@shared/infrastructure/role.ts`'s `?role=` idiom but app-local +(`?rollen=`, sticky per tab, default `behandelaar`) — real employee-SSO login stays out of +scope (ADR-0002 §3: the two apps' login flows are expected to diverge). Documented in +`docs/reference/roles-and-access.md`. + +**Backend (`GET /beoordeling/{id}`):** reads through `IZaakSource.ListCases` (no new seam +method — one would force an `OpenZaakZaakSource` get-by-id + mapper, which is WP-66's +surface), filters to the requested id, 404s a Concept or unknown id. Documents come from +`DocumentStore.ByIds` (new method, mirrors `ByLocalIds`) — `DocumentStore` directly, not +`IDocumentSource`, since that seam has no read method and its own header comment already +says `DocumentStore` stays the record of truth for preview/download/audit regardless of +config. The owner BSN is masked (`MaskTail`, same helper `/brief`'s BIG-nummer masking +uses) — WP-64's queue row leaks the full BSN via the same `ApplicationSummaryDto.Owner` +field; flagging as a follow-up, not fixed here or ssp's `/admin/cases` page moves too. +The gate that was `Werkvoorraad(ctx, action)` is now `Beoordelen(ctx, resource, action)` — +one gate for every behandelaar endpoint, `resource` feeding the audit row; the one existing +`/werkvoorraad` call site was updated to pass `"werkvoorraad"`. + +**Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to +65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current +*computed* status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable; +`Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet, +so this pure rule needed nothing from 65b's eventual migration to be correct today. 65b adds +the mutation, the `Besluit` enum, and the transition-legality check that reuses this same +function. + +**FE:** `BeoordelingStatus` (`domain/beoordeling.ts`) is the five-tag union (all of ssp's +`AanvraagStatus` minus `Concept`, which 404s) — wider than WP-64's `WerkvoorraadStatus` (two +tags). `TYPE_LABELS`/`statusLabel` moved from `werkvoorraad-item-view.ts` into a new +`domain/beoordeling-view.ts` (the file owning the wider union) and are re-exported from the +old location so no consumer or existing spec needed to change. Detail page composes +``/`` (mirrors ssp's `aanvraag-detail.page.ts`) plus one new +organism, `beoordeling-documenten` (plain links to the existing, pre-existing-unauthenticated +`/uploads/{id}/content` endpoint — not `ApplicationLinkComponent`, whose `to` is a +`routerLink`, not an external href). The werkvoorraad list's rows now link to +`/aanvraag/:id`, gated by the same `aanvraag:beoordelen` capability. No `MaskedValueComponent` +reveal affordance — this screen never reveals the owner, so pulling in that atom's unused +reveal machinery would be speculative. + +**Deferred to 65b, deliberately:** the mutation endpoint, the `Aanvraag.BesluitStatus` +column + EF migration, `Mappers.ToStatusDto`'s "a recorded decision wins" branch, the +`besluit.machine.ts` + `besluit-form` UI, and re-running the werkvoorraad/ssp-dashboard +end-to-end smoke this WP's acceptance criteria actually asks for (a decision advancing +status, illegal transitions rejected). None of WP-65's acceptance criteria are checked off +yet — 65a is infrastructure the decision screen needs, not a slice of the AC itself. + +`npm run ci` green (lint, dep:check ×2, both apps' localized builds, both Storybook builds, +test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift +step shows the expected pre-commit diff (this WP's own uncommitted endpoint). + ## Acceptance criteria - [ ] A medewerker can view one aanvraag's detail and record a decision that advances its diff --git a/docs/reference/roles-and-access.md b/docs/reference/roles-and-access.md index 06e58c2..5348023 100644 --- a/docs/reference/roles-and-access.md +++ b/docs/reference/roles-and-access.md @@ -35,14 +35,21 @@ as an `X-Role` header on role-aware requests; the backend resolves it into a `Pr ## Actor kinds (backend, WP-62) `X-Role`/`Principal` above is a coarse role that applies to **either** of two actor kinds the -backend now models (ADR-0002 §3): a **zorgverlener** (this SSP's citizen — has a BSN) or a +backend now models (ADR-0002 §3): a **zorgverlener** (the SSP's citizen — has a BSN) or a **medewerker** (backoffice employee — no BSN, has `Rollen`). `StubIdentityProvider` picks the medewerker kind from a dev header, `X-Medewerker` (+ `X-Rollen`), mirroring `X-Role`/`X-Subject` -above — **the SSP's FE never sends either header**; they exist only for the backend's own tests -and for the behandelportal (WP-64+) to use later. `Authz.CanBeoordelen(caller)` is the first -medewerker capability — a rol-based decision flag (`MedewerkerRol.Behandelaar`), not a role -entry on `/me`, since `/me`'s `RoleCapabilities` is keyed on `Principal` and can't see the actor -kind. +above. The SSP's FE never sends either header — it has no medewerker screens. The +**behandelportal** does: `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` +stamps every request as one fixed stand-in medewerker (dev-only, same `isDevMode()` gate as +`roleInterceptor`), since this app has no real employee-SSO login yet (ADR-0002 §3 — the two +apps' login flows are expected to diverge, and this stand-in is that flow's placeholder). +`?rollen=` (sticky per tab, mirroring `?role=`) picks the medewerker's rollen — +`?rollen=geen` exercises the deny path; the default is `behandelaar`. +`Authz.CanBeoordelen(caller)` is the first medewerker capability — a rol-based decision flag +(`MedewerkerRol.Behandelaar`), surfaced on `GET /me` as `aanvraag:beoordelen` (appended +alongside `RoleCapabilities`'s role-derived set, since that switch is keyed on `Principal` and +can't see the actor kind) — gates the behandelportal's `/dashboard` werkvoorraad queue (WP-64) +and its `/aanvraag/:id` beoordeling detail (WP-65). ## What each role unlocks diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index d535636..f59f995 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -1116,6 +1116,55 @@ export class ApiClient { return Promise.resolve(null as any); } + /** + * @return OK + */ + beoordeling(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/beoordeling/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processBeoordeling(_response); + }); + } + + protected processBeoordeling(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 BeoordelingViewDto; + return result200; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + /** * @return No Content */ @@ -1944,6 +1993,22 @@ export interface AuthzAuditDto { correlationId?: string | undefined; } +export interface BeoordelingDecisionsDto { + canBesluiten?: boolean; +} + +export interface BeoordelingDocumentDto { + documentId?: string | undefined; + categoryId?: string | undefined; + fileName?: string | undefined; +} + +export interface BeoordelingViewDto { + aanvraag?: ApplicationSummaryDto; + documenten?: BeoordelingDocumentDto[] | undefined; + decisions?: BeoordelingDecisionsDto; +} + export interface BriefDecisionsDto { canEdit?: boolean; canApprove?: boolean;