diff --git a/apps/behandelportal/src/app/app.routes.ts b/apps/behandelportal/src/app/app.routes.ts index ed2efcd..cfabac4 100644 --- a/apps/behandelportal/src/app/app.routes.ts +++ b/apps/behandelportal/src/app/app.routes.ts @@ -15,9 +15,8 @@ export const routes: Routes = [ { path: 'dashboard', canActivate: [authGuard], - // TODO(create-ssp): stopgap landing page — point this at a real overview once you have one. loadComponent: () => - import('@behandeling/ui/behandeling.page').then((m) => m.BehandelingPage), + import('@behandeling/ui/werkvoorraad.page').then((m) => m.WerkvoorraadPage), }, { path: 'beheer/stamdata', @@ -41,12 +40,6 @@ export const routes: Routes = [ loadComponent: () => import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), }, - { - path: 'behandeling', - canActivate: [authGuard], - loadComponent: () => - import('@behandeling/ui/behandeling.page').then((m) => m.BehandelingPage), - }, { path: '**', redirectTo: 'login' }, ], }, diff --git a/apps/behandelportal/src/app/behandeling/application/.gitkeep b/apps/behandelportal/src/app/behandeling/application/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts b/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts new file mode 100644 index 0000000..19b2509 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts @@ -0,0 +1,37 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { RemoteData } from '@shared/application/remote-data'; +import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; +import { + WerkvoorraadAdapter, + parseWerkvoorraad, +} from '@behandeling/infrastructure/werkvoorraad.adapter'; + +type Err = Error | undefined; + +/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp + counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */ +@Injectable({ providedIn: 'root' }) +export class WerkvoorraadStore { + private adapter = inject(WerkvoorraadAdapter); + + private state = signal>({ tag: 'Loading' }); + readonly items = this.state.asReadonly(); + + async load() { + if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); + try { + const parsed = parseWerkvoorraad(await this.adapter.list()); + this.state.set( + parsed.ok + ? { tag: 'Success', value: parsed.value } + : { tag: 'Failure', error: new Error(parsed.error) }, + ); + } catch (e) { + this.state.set({ tag: 'Failure', error: e as Error }); + } + } + + reload() { + void this.load(); + } +} diff --git a/apps/behandelportal/src/app/behandeling/domain/.gitkeep b/apps/behandelportal/src/app/behandeling/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.spec.ts b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.spec.ts new file mode 100644 index 0000000..f5bee19 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { werkvoorraadRow, statusLabel, TYPE_LABELS } from './werkvoorraad-item-view'; +import { WerkvoorraadItem } from './werkvoorraad-item'; + +const base: Omit = { + id: '1', + type: 'herregistratie', + owner: '111222333', + submittedAt: '2024-05-12', +}; + +describe('werkvoorraadRow', () => { + it('heading is the type, subtitle carries the owner BSN', () => { + const row = werkvoorraadRow({ + ...base, + status: { tag: 'InBehandeling', referentie: 'R1', manual: false }, + }); + expect(row.heading).toBe(TYPE_LABELS.herregistratie); + expect(row.subtitle).toContain('111222333'); + }); + + it('status line carries the status label, reference and submit date', () => { + const row = werkvoorraadRow({ + ...base, + status: { tag: 'InBehandeling', referentie: 'R1', manual: false }, + }); + expect(row.status).toContain( + statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }), + ); + expect(row.status).toContain('R1'); + expect(row.status).toContain('12 mei 2024'); + }); + + it('manual review is called out distinctly from an automatic InBehandeling', () => { + const manual = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true }); + const auto = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }); + expect(manual).not.toBe(auto); + }); + + it('a missing submit date leaves no dangling separator', () => { + const row = werkvoorraadRow({ + ...base, + submittedAt: undefined, + status: { tag: 'Ingediend', referentie: 'R9' }, + }); + expect(row.status).toBe(`${statusLabel({ tag: 'Ingediend', referentie: 'R9' })} · R9`); + }); +}); diff --git a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts new file mode 100644 index 0000000..7e4f01e --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts @@ -0,0 +1,44 @@ +import { formatDatumNl } from '@shared/kernel/datum'; +import { WerkvoorraadItem, WerkvoorraadStatus, AanvraagType } from './werkvoorraad-item'; + +/** 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`; + } +} + +export interface WerkvoorraadRow { + heading: string; + subtitle: string; + status: string; +} + +/** Fields for one queue row: type as heading, owner (BSN) as subtitle, status + + reference + submit date as the status line. */ +export function werkvoorraadRow(item: WerkvoorraadItem): WerkvoorraadRow { + const parts = [statusLabel(item.status), item.status.referentie]; + if (item.submittedAt) { + parts.push( + $localize`:@@werkvoorraad.row.ingediend:ingediend op ${formatDatumNl(item.submittedAt)}:datum:`, + ); + } + return { + heading: TYPE_LABELS[item.type], + subtitle: $localize`:@@werkvoorraad.row.bsn:BSN ${item.owner}:bsn:`, + status: parts.join(' · '), + }; +} diff --git a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts new file mode 100644 index 0000000..b5fcb35 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts @@ -0,0 +1,23 @@ +/** + * A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view + * of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular. + * + * The status union is narrower than the SSP's full `AanvraagStatus` (ssp's + * `registratie/domain/aanvraag.ts`): the backend only ever puts a case in the queue + * while it is still open (`Ingediend`/`InBehandeling`), so a queue item literally + * cannot be `Concept`/`Goedgekeurd`/`Afgewezen` — illegal states unrepresentable. + */ +export type AanvraagType = 'registratie' | 'herregistratie' | 'intake'; + +export type WerkvoorraadStatus = + | { tag: 'Ingediend'; referentie: string } + | { tag: 'InBehandeling'; referentie: string; manual: boolean }; + +export interface WerkvoorraadItem { + id: string; + type: AanvraagType; + status: WerkvoorraadStatus; + /** The BSN of the citizen the aanvraag belongs to — always populated (cross-owner list). */ + owner: string; + submittedAt?: string; +} diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/.gitkeep b/apps/behandelportal/src/app/behandeling/infrastructure/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.spec.ts b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.spec.ts new file mode 100644 index 0000000..b13ab10 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { parseWerkvoorraadItem, parseWerkvoorraad } from './werkvoorraad.adapter'; + +const inBehandeling = { + id: 'a1', + type: 'herregistratie', + status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: false }, + documentIds: [], + createdAt: '2026-07-01T10:00:00Z', + updatedAt: '2026-07-01T10:05:00Z', + owner: '111222333', +}; + +describe('parseWerkvoorraadItem', () => { + it('parses Ingediend and InBehandeling', () => { + expect(parseWerkvoorraadItem(inBehandeling).ok).toBe(true); + expect( + parseWerkvoorraadItem({ ...inBehandeling, status: { tag: 'Ingediend', referentie: 'BIG-2' } }) + .ok, + ).toBe(true); + }); + + it('rejects a case whose status is not an open queue tag', () => { + expect( + parseWerkvoorraadItem({ + ...inBehandeling, + status: { tag: 'Goedgekeurd', referentie: 'BIG-1' }, + }).ok, + ).toBe(false); + expect( + parseWerkvoorraadItem({ + ...inBehandeling, + status: { tag: 'Concept', stepIndex: 0, stepCount: 1 }, + }).ok, + ).toBe(false); + }); + + it('rejects a missing owner, bad type, and non-objects', () => { + expect(parseWerkvoorraadItem({ ...inBehandeling, owner: undefined }).ok).toBe(false); + expect(parseWerkvoorraadItem({ ...inBehandeling, type: 'onbekend' }).ok).toBe(false); + expect(parseWerkvoorraadItem(null).ok).toBe(false); + }); +}); + +describe('parseWerkvoorraad', () => { + it('parses a list and fails fast on a bad element', () => { + expect(parseWerkvoorraad([inBehandeling, inBehandeling]).ok).toBe(true); + expect(parseWerkvoorraad([inBehandeling, { ...inBehandeling, owner: undefined }]).ok).toBe( + false, + ); + expect(parseWerkvoorraad({}).ok).toBe(false); + }); +}); diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts new file mode 100644 index 0000000..c06f4a3 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts @@ -0,0 +1,72 @@ +import { Injectable, inject } from '@angular/core'; +import { Result, ok, err } from '@shared/kernel/fp'; +import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client'; +import { + WerkvoorraadItem, + WerkvoorraadStatus, + AanvraagType, +} from '@behandeling/domain/werkvoorraad-item'; + +/** + * Infrastructure adapter for the behandelportal's queue read (WP-64) — the only + * place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response + * is validated + mapped to the (narrower) queue domain shape by the parse* boundary + * below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not + * a silently-rendered row — the endpoint's own filter is a guarantee this boundary enforces. + */ +@Injectable({ providedIn: 'root' }) +export class WerkvoorraadAdapter { + private client = inject(ApiClient); + + list(): Promise { + return this.client.werkvoorraad(); + } +} + +const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake']; + +function parseWerkvoorraadStatus( + s: ApplicationSummaryDto['status'] | undefined, +): Result { + if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status'); + switch (s.tag) { + case 'Ingediend': + if (typeof s.referentie !== 'string') return err('werkvoorraad: bad Ingediend status'); + return ok({ tag: 'Ingediend', referentie: s.referentie }); + case 'InBehandeling': + if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean') + return err('werkvoorraad: bad InBehandeling status'); + return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual }); + default: + return err(`werkvoorraad: a queue item cannot have status ${s.tag}`); + } +} + +export function parseWerkvoorraadItem(json: unknown): Result { + if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object'); + const dto = json as ApplicationSummaryDto; + 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}`); + if (typeof dto.owner !== 'string' || !dto.owner) return err('werkvoorraad: missing owner'); + const status = parseWerkvoorraadStatus(dto.status); + if (!status.ok) return status; + return ok({ + id: dto.id, + type: dto.type as AanvraagType, + status: status.value, + owner: dto.owner, + submittedAt: dto.submittedAt, + }); +} + +export function parseWerkvoorraad(json: unknown): Result { + if (!Array.isArray(json)) return err('werkvoorraad: not an array'); + const out: WerkvoorraadItem[] = []; + for (const item of json) { + const parsed = parseWerkvoorraadItem(item); + if (!parsed.ok) return parsed; + out.push(parsed.value); + } + return ok(out); +} diff --git a/apps/behandelportal/src/app/behandeling/ui/behandeling.page.ts b/apps/behandelportal/src/app/behandeling/ui/behandeling.page.ts deleted file mode 100644 index 50e84ac..0000000 --- a/apps/behandelportal/src/app/behandeling/ui/behandeling.page.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component } from '@angular/core'; -import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; - -/** - * Scaffolded by `gen:context` (WP-44) — replace with the `behandeling` context's first - * feature slice (the `new-feature` skill: domain first, then infrastructure/application, UI last). - */ -@Component({ - selector: 'app-behandeling-page', - imports: [PageShellComponent], - template: ` - -

{{ intro }}

-
- `, -}) -export class BehandelingPage { - protected heading = $localize`:@@behandeling.landing.heading:Behandeling`; - protected intro = $localize`:@@behandeling.landing.intro:Hier komt de eerste behandeling-functionaliteit.`; -} 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 new file mode 100644 index 0000000..ac51d08 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts @@ -0,0 +1,31 @@ +import { Component, input } from '@angular/core'; +import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component'; +import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; +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. */ +@Component({ + selector: 'app-werkvoorraad-list', + imports: [ApplicationListComponent, ApplicationLinkComponent], + template: ` + + @for (item of items(); track item.id) { + @let row = row_(item); +
  • + } +
    + `, +}) +export class WerkvoorraadListComponent { + items = input.required(); + + protected row_ = werkvoorraadRow; +} diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.stories.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.stories.ts new file mode 100644 index 0000000..0ad9914 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.stories.ts @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { provideRouter } from '@angular/router'; +import { WerkvoorraadListComponent } from './werkvoorraad-list.component'; +import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item'; + +const items: WerkvoorraadItem[] = [ + { + id: 'a1', + type: 'herregistratie', + owner: '111222333', + submittedAt: '2026-06-28T10:05:00Z', + status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false }, + }, + { + id: 'a2', + type: 'registratie', + owner: '444555666', + submittedAt: '2026-06-27T09:00:00Z', + status: { tag: 'InBehandeling', referentie: 'BIG-2026-456790', manual: true }, + }, + { + id: 'a3', + type: 'intake', + owner: '777888999', + status: { tag: 'Ingediend', referentie: 'BIG-2026-456791' }, + }, +]; + +const meta: Meta = { + title: 'Domein/Behandeling/Werkvoorraad List', + component: WerkvoorraadListComponent, + decorators: [applicationConfig({ providers: [provideRouter([])] })], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { items }, +}; + +export const Empty: Story = { + args: { items: [] }, +}; diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts new file mode 100644 index 0000000..ce8ed3f --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts @@ -0,0 +1,82 @@ +import { Component, computed, effect, inject } from '@angular/core'; +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 { ASYNC } from '@shared/ui/async/async.component'; +import { AccessStore } from '@shared/application/access.store'; +import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store'; +import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component'; + +/** + * Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page. + * Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's + * AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening + * a case's detail is out of scope here (WP-65). + */ +@Component({ + selector: 'app-werkvoorraad-page', + imports: [ + PageShellComponent, + AlertComponent, + ButtonComponent, + SkeletonComponent, + WerkvoorraadListComponent, + ...ASYNC, + ], + template: ` + + @if (!access.ready()) { + + } @else if (!canBeoordelen()) { + {{ deniedText }} + } @else { + + + + + + {{ failedText }} + {{ retryText }} + + + @if (items().length === 0) { + {{ emptyText }} + } @else { + + } + + + } + + `, +}) +export class WerkvoorraadPage { + protected store = inject(WerkvoorraadStore); + protected access = inject(AccessStore); + + protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen')); + protected items = computed(() => { + const rd = this.store.items(); + return rd.tag === 'Success' ? rd.value : []; + }); + + protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`; + protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`; + protected deniedText = $localize`:@@werkvoorraad.denied:U hebt geen rechten om de werkvoorraad te bekijken.`; + protected failedText = $localize`:@@werkvoorraad.failed:De werkvoorraad kon niet worden geladen.`; + protected emptyText = $localize`:@@werkvoorraad.empty:Er staan geen aanvragen open.`; + protected retryText = $localize`:@@werkvoorraad.retry:Opnieuw proberen`; + + private loadRequested = false; + constructor() { + // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) — + // same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson). + effect(() => { + if (this.canBeoordelen() && !this.loadRequested) { + this.loadRequested = true; + void this.store.load(); + } + }); + } +} diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf index 385569f..581b99a 100644 --- a/apps/behandelportal/src/locale/messages.en.xlf +++ b/apps/behandelportal/src/locale/messages.en.xlf @@ -2904,21 +2904,61 @@ 95 - - Behandeling - Case handling - - src/app/behandeling/ui/behandeling.page.ts - 18 - + + Inschrijving + Registration - - Hier komt de eerste behandeling-functionaliteit. - The first case-handling functionality will land here. - - src/app/behandeling/ui/behandeling.page.ts - 19 - + + Herregistratie + Re-registration + + + Herregistratie-intake + Re-registration intake + + + Ingediend + Submitted + + + In behandeling (handmatig) + In progress (manual) + + + In behandeling + In progress + + + ingediend op + submitted on + + + BSN + BSN + + + Werkvoorraad + Queue + + + Aanvragen die op beoordeling wachten. + Applications waiting for review. + + + U hebt geen rechten om de werkvoorraad te bekijken. + You do not have permission to view the queue. + + + De werkvoorraad kon niet worden geladen. + The queue could not be loaded. + + + Er staan geen aanvragen open. + There are no open applications. + + + Opnieuw proberen + Try again Er is geen stamdata om te beheren. diff --git a/apps/behandelportal/src/locale/messages.xlf b/apps/behandelportal/src/locale/messages.xlf index a1595a3..00d1744 100644 --- a/apps/behandelportal/src/locale/messages.xlf +++ b/apps/behandelportal/src/locale/messages.xlf @@ -5,686 +5,770 @@ * verplichte velden - src/app/auth/ui/login-form/login-form.component.ts + apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts 15,18 BSN - src/app/auth/ui/login-form/login-form.component.ts + apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts 22,23 9-cijferig BSN, elfproef-geldig (demo: 123456782) - src/app/auth/ui/login-form/login-form.component.ts + apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts 25,28 Wachtwoord - src/app/auth/ui/login-form/login-form.component.ts + apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts 36,37 Inloggen met DigiD - src/app/auth/ui/login-form/login-form.component.ts + apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts 41,43 Inloggen - src/app/auth/ui/login.page.ts + apps/behandelportal/src/app/auth/ui/login.page.ts 14,16 Log in op uw persoonlijke BIG-register omgeving. - src/app/auth/ui/login.page.ts + apps/behandelportal/src/app/auth/ui/login.page.ts 17,19 - - Behandeling + + Inschrijving - src/app/behandeling/ui/behandeling.page.ts - 18 + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts + 8 - - Hier komt de eerste behandeling-functionaliteit. + + Herregistratie - src/app/behandeling/ui/behandeling.page.ts + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts + 9 + + + + Herregistratie-intake + + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts + 10 + + + + Ingediend + + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts + 16 + + + + In behandeling (handmatig) + + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts 19 + + In behandeling + + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts + 20 + + + + ingediend op + + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts + 36 + + + + BSN + + apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts + 41 + + + + Werkvoorraad + + apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts + 57 + + + + Aanvragen die op beoordeling wachten. + + apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts + 58 + + + + U hebt geen rechten om de werkvoorraad te bekijken. + + apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts + 59 + + + + De werkvoorraad kon niet worden geladen. + + apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts + 60 + + + + Er staan geen aanvragen open. + + apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts + 61 + + + + Opnieuw proberen + + apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts + 62 + + + + Overzicht + + apps/behandelportal/src/app/shell/nav.config.ts + 6 + + + + Stamdata + + apps/behandelportal/src/app/shell/nav.config.ts + 14 + + + + Business-tabellen onderhouden + + apps/behandelportal/src/app/shell/nav.config.ts + 15 + + + + Auditlog + + apps/behandelportal/src/app/shell/nav.config.ts + 20 + + + + Toegangs- en inzagebeslissingen bekijken + + apps/behandelportal/src/app/shell/nav.config.ts + 21 + + + + Functievlaggen + + apps/behandelportal/src/app/shell/nav.config.ts + 26 + + + + Functionaliteit aan- of uitzetten + + apps/behandelportal/src/app/shell/nav.config.ts + 27 + + Er is geen stamdata om te beheren. - src/app/beheer/application/stamdata.store.ts + libs/beheer/src/application/stamdata.store.ts 150 Vul de sleutelkolom in. - src/app/beheer/domain/stamdata.ts + libs/beheer/src/domain/stamdata.ts 68 Vul een 'geldig van'-datum in. - src/app/beheer/domain/stamdata.ts + libs/beheer/src/domain/stamdata.ts 72 'Geldig tot' moet ná 'geldig van' liggen. - src/app/beheer/domain/stamdata.ts + libs/beheer/src/domain/stamdata.ts 74 De stamdata kon niet worden geladen. - src/app/beheer/infrastructure/stamdata.adapter.ts + libs/beheer/src/infrastructure/stamdata.adapter.ts 13 Auditlog - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 102 Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens. - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 103 U hebt geen rechten om de auditlog te bekijken. - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 104 De auditlog kon niet worden geladen. - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 105 Nog geen auditregels. - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 106 Opnieuw proberen - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 107 Tijd - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 108 Actie - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 109 Resource - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 110 Besluit - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 111 Rol - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 112 Correlatie-id - src/app/beheer/ui/audit.page.ts + libs/beheer/src/ui/audit.page.ts 113 Functievlaggen - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 82 Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status. - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 83 U hebt geen rechten om functievlaggen te beheren. - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 84 De functievlaggen konden niet worden geladen. - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 85 Opnieuw proberen - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 86 Aan - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 87 Uit - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 88 Aanzetten - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 89 Uitzetten - src/app/beheer/ui/feature-flags.page.ts + libs/beheer/src/ui/feature-flags.page.ts 90 toegevoegd - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 228 gewijzigd - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 229 verwijderd - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 230 Tabel - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 236 Toon geldig op - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 237 Toon alles - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 238 Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit. - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 239 Acties - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 240 Verwijderen - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 241 Sluiten per vandaag - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 242 Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen. - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 243 Ongedaan maken - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 257 Opnieuw uitvoeren - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 258 Rij toevoegen - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 259 Download JSON - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 260 Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze. - src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts + libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts 261 Stamdata onderhouden - src/app/beheer/ui/stamdata.page.ts + libs/beheer/src/ui/stamdata.page.ts 72 Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker. - src/app/beheer/ui/stamdata.page.ts + libs/beheer/src/ui/stamdata.page.ts 73 U hebt geen rechten om stamdata te onderhouden. - src/app/beheer/ui/stamdata.page.ts + libs/beheer/src/ui/stamdata.page.ts 74 De stamdata kon niet worden geladen. - src/app/beheer/ui/stamdata.page.ts + libs/beheer/src/ui/stamdata.page.ts 75 Opnieuw proberen - src/app/beheer/ui/stamdata.page.ts + libs/beheer/src/ui/stamdata.page.ts 76 Het indienen is niet gelukt. Probeer het later opnieuw. - src/app/shared/application/submit.ts + libs/shared/src/application/submit.ts 28 Voer een geldig BSN van 9 cijfers in. - src/app/shared/kernel/bsn.ts + libs/shared/src/kernel/bsn.ts 18 Dit is geen geldig BSN (klopt niet met de elfproef). - src/app/shared/kernel/bsn.ts + libs/shared/src/kernel/bsn.ts 23 - - Stamdata - - src/app/shared/layout/admin-links.ts - 17 - - - - Business-tabellen onderhouden - - src/app/shared/layout/admin-links.ts - 18 - - - - Auditlog - - src/app/shared/layout/admin-links.ts - 23 - - - - Toegangs- en inzagebeslissingen bekijken - - src/app/shared/layout/admin-links.ts - 24 - - - - Functievlaggen - - src/app/shared/layout/admin-links.ts - 29 - - - - Functionaliteit aan- of uitzetten - - src/app/shared/layout/admin-links.ts - 30 - - Mijn overzicht - src/app/shared/layout/breadcrumb/breadcrumb-trail.ts + libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts 12 Mijn gegevens - src/app/shared/layout/breadcrumb/breadcrumb-trail.ts + libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts 13 Inschrijven - src/app/shared/layout/breadcrumb/breadcrumb-trail.ts + libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts 14 Herregistratie - src/app/shared/layout/breadcrumb/breadcrumb-trail.ts + libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts 16 Herregistratie-intake - src/app/shared/layout/breadcrumb/breadcrumb-trail.ts + libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts 19 Functionele patronen - src/app/shared/layout/breadcrumb/breadcrumb-trail.ts + libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts 20 Kruimelpad - src/app/shared/layout/breadcrumb/breadcrumb.component.ts + libs/shared/src/layout/breadcrumb/breadcrumb.component.ts 27,28 U bevindt zich hier: - src/app/shared/layout/breadcrumb/breadcrumb.component.ts + libs/shared/src/layout/breadcrumb/breadcrumb.component.ts 28,29 Taal / Language - src/app/shared/layout/language-switcher/language-switcher.component.ts + libs/shared/src/layout/language-switcher/language-switcher.component.ts 95 Kies een taal - src/app/shared/layout/language-switcher/language-switcher.component.ts + libs/shared/src/layout/language-switcher/language-switcher.component.ts 96 Terug naar overzicht - src/app/shared/layout/page-shell/page-shell.component.ts + libs/shared/src/layout/page-shell/page-shell.component.ts 49 Naar de inhoud - src/app/shared/layout/shell/shell.component.ts - 53,54 + libs/shared/src/layout/shell/shell.component.ts + 62,63 De Rijksoverheid. Voor Nederland. - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 85,86 CIBG — Ministerie van Volksgezondheid, Welzijn en Sport - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 87,89 Over deze site - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 90,91 Over deze site - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 91,92 Privacy - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 99,101 Cookies - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 108,110 Toegankelijkheid - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 117,120 Demo / POC — geen echte gegevens. - src/app/shared/layout/site-footer/site-footer.component.ts + libs/shared/src/layout/site-footer/site-footer.component.ts 122,124 - - Overzicht - - src/app/shared/layout/site-header/site-header.component.ts - 19 - - BIG-register - src/app/shared/layout/site-header/site-header.component.ts - 54,55 + libs/shared/src/layout/site-header/site-header.component.ts + 44,45 Ministerie van Volksgezondheid, Welzijn en Sport - src/app/shared/layout/site-header/site-header.component.ts - 56,58 + libs/shared/src/layout/site-header/site-header.component.ts + 46,48 Uitloggen - src/app/shared/layout/site-header/site-header.component.ts - 78,79 + libs/shared/src/layout/site-header/site-header.component.ts + 68,69 Hoofdnavigatie - src/app/shared/layout/site-header/site-header.component.ts - 86,87 + libs/shared/src/layout/site-header/site-header.component.ts + 76,77 Informatie - src/app/shared/ui/alert/alert.component.ts + libs/shared/src/ui/alert/alert.component.ts 7 Gelukt - src/app/shared/ui/alert/alert.component.ts + libs/shared/src/ui/alert/alert.component.ts 8 Waarschuwing - src/app/shared/ui/alert/alert.component.ts + libs/shared/src/ui/alert/alert.component.ts 9 Foutmelding - src/app/shared/ui/alert/alert.component.ts + libs/shared/src/ui/alert/alert.component.ts 10 Er ging iets mis bij het laden van de gegevens. - src/app/shared/ui/async/async.component.ts + libs/shared/src/ui/async/async.component.ts 105 Opnieuw proberen - src/app/shared/ui/async/async.component.ts + libs/shared/src/ui/async/async.component.ts 106 Geen gegevens gevonden. - src/app/shared/ui/async/async.component.ts + libs/shared/src/ui/async/async.component.ts 107 Bezig met laden - src/app/shared/ui/spinner/spinner.component.ts + libs/shared/src/ui/spinner/spinner.component.ts 36,40 diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 129dd75..86d481b 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -409,6 +409,17 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); +// --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. --- +// 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, () => + Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow) + .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") + .ToList()))) +.Produces>() +.ProducesProblem(StatusCodes.Status403Forbidden); + // 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 @@ -462,7 +473,15 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT // tied to a specific brief's live status — see BriefDecisionsDto for that). -api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx)))) +// WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like +// the rest of RoleCapabilities — appended here rather than folded into that switch, since it +// depends on CallerIdentity (medewerker rollen), not the dev X-Role stand-in. +api.MapGet("/me", (HttpContext ctx) => +{ + var caps = Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx)).ToList(); + if (Authz.CanBeoordelen(ctx.Caller())) caps.Add("aanvraag:beoordelen"); + return new MeDto(caps); +}) .Produces(); // Feature flags (WP-47). GET is readable by any principal (it drives FE gating); the toggle is @@ -689,6 +708,17 @@ 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) +{ + 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.", + statusCode: StatusCodes.Status403Forbidden); +} + // One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). IResult FlagsAdmin(HttpContext ctx, Func action) { diff --git a/backend/swagger.json b/backend/swagger.json index 76406c0..4df26c7 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -766,6 +766,38 @@ } } }, + "/api/v1/werkvoorraad": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationSummaryDto" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/admin/cases/{id}": { "delete": { "tags": [ diff --git a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs new file mode 100644 index 0000000..dc76a16 --- /dev/null +++ b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +/// WP-64: the behandelportal's queue of aanvragen needing treatment, gated by the +/// medewerker capability `CanBeoordelen` (WP-62) — not the admin role. +public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private HttpRequestMessage AsBehandelaar(string path) + { + var req = new HttpRequestMessage(HttpMethod.Get, path); + req.Headers.Add("X-Medewerker", "medewerker-1"); + return req; + } + + 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 })) + .EnsureSuccessStatusCode(); + return a; + } + + [Fact] + public async Task Behandelaar_sees_submitted_cases_in_the_queue() + { + var a = await CreateAndSubmitHerregistratie(); + try + { + var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad")); + res.EnsureSuccessStatusCode(); + var queue = (await res.Content.ReadFromJsonAsync>())!; + var mine = queue.Single(x => x.Id == a.Id); + Assert.Equal("InBehandeling", mine.Status.Tag); + Assert.False(string.IsNullOrEmpty(mine.Owner)); // cross-owner, like /admin/cases + } + finally + { + await _client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}") + { + Headers = { { "X-Role", "admin" } }, + }); + } + } + + [Fact] + public async Task Queue_excludes_concepts() + { + var created = await _client.PostAsJsonAsync("/api/v1/applications", 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>())!; + Assert.DoesNotContain(queue, x => x.Id == a.Id); + } + finally + { + await _client.DeleteAsync($"/api/v1/applications/{a.Id}"); + } + } + + [Fact] + public async Task Zorgverlener_is_forbidden_even_with_admin_role() + { + var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/werkvoorraad"); + req.Headers.Add("X-Role", "admin"); // admin role, but no X-Medewerker — still a zorgverlener + Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); + } + + [Fact] + public async Task Medewerker_without_behandelaar_rol_is_forbidden() + { + var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/werkvoorraad"); + req.Headers.Add("X-Medewerker", "medewerker-2"); + req.Headers.Add("X-Rollen", "geen"); + Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); + } + + [Fact] + public async Task Me_reports_the_capability_only_for_a_behandelaar() + { + var behandelaar = new HttpRequestMessage(HttpMethod.Get, "/api/v1/me"); + behandelaar.Headers.Add("X-Medewerker", "medewerker-1"); + var caps = (await (await _client.SendAsync(behandelaar)).Content.ReadFromJsonAsync())!; + Assert.Contains("aanvraag:beoordelen", caps.Capabilities); + + var zorgverlener = (await (await _client.GetAsync("/api/v1/me")).Content.ReadFromJsonAsync())!; + Assert.DoesNotContain("aanvraag:beoordelen", zorgverlener.Capabilities); + } +} diff --git a/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md b/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md index 638b9b3..2da1900 100644 --- a/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md +++ b/docs/project/backlog/WP-64-behandelportal-werkvoorraad.md @@ -1,6 +1,6 @@ # WP-64 — Behandelportal: werkvoorraad (queue) screen -Status: todo +Status: done Phase: 11 — Behandelportal ## Why @@ -44,10 +44,36 @@ behandelportal app (domain/infrastructure/application/ui per the house layering) ## Acceptance criteria -- [ ] Werkvoorraad screen lists aanvragen needing treatment for an authenticated +- [x] Werkvoorraad screen lists aanvragen needing treatment for an authenticated medewerker. -- [ ] `npm run ci` green in the behandelportal app; Storybook story present. -- [ ] Endpoint follows BFF-lite discipline (decision-enriched, not raw passthrough). +- [x] `npm run ci` green in the behandelportal app; Storybook story present. +- [x] Endpoint follows BFF-lite discipline (decision-enriched, not raw passthrough). + +## Outcome + +`GET /werkvoorraad` reuses the existing `ApplicationSummaryDto`/`IZaakSource.ListCases` — +no new DTO — filtered server-side to `Status.Tag is "Ingediend" or "InBehandeling"`. Gated +by a new `Werkvoorraad` local-function twin of `CasesAdmin`, but checking +`Authz.CanBeoordelen(ctx.Caller())` (the CallerIdentity directly) rather than a +role-only `Principal` — a zorgverlener with `X-Role=admin` is still denied (covered by a test). + +One course correction beyond the pre-made decisions: `GET /me` didn't expose any +capability a medewerker could hold (`RoleCapabilities` only switches on `PrincipalRole`, +which every `MedewerkerCaller` also carries but doesn't determine `CanBeoordelen`). Added +one line — `if (Authz.CanBeoordelen(ctx.Caller())) caps.Add("aanvraag:beoordelen")` — and a +matching `Capability` union member in `libs/shared`, so the FE page can use the same +deny-by-default `AccessStore`/`capabilityGuard` idiom every other gated page uses (avoids a +wasted 403 GET and a denial flash), instead of inventing a second gating mechanism. + +FE: the queue item's domain type (`behandeling/domain/werkvoorraad-item.ts`) is +deliberately **narrower** than the SSP's full `AanvraagStatus` union — only +`Ingediend`/`InBehandeling` — so a case the backend's filter let through with any other +tag is a parse error, not a silently-rendered row. Composed entirely from existing +`libs/shared/ui` molecules (`ApplicationListComponent` + `ApplicationLinkComponent`, the +same ones ssp's dashboard uses) via one new organism, `WerkvoorraadListComponent` — no new +atom. The stopgap `behandeling.page.ts`/`BehandelingPage` (WP-61's scaffold placeholder, +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. ## Verification diff --git a/libs/shared/src/domain/capability.ts b/libs/shared/src/domain/capability.ts index 7886d56..8d9f71b 100644 --- a/libs/shared/src/domain/capability.ts +++ b/libs/shared/src/domain/capability.ts @@ -9,4 +9,5 @@ export type Capability = | 'orgtemplate:edit' | 'stamdata:edit' | 'cases:manage' - | 'flags:manage'; + | 'flags:manage' + | 'aanvraag:beoordelen'; diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 6943f55..d535636 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -1074,6 +1074,48 @@ export class ApiClient { return Promise.resolve(null as any); } + /** + * @return OK + */ + werkvoorraad(): Promise { + let url_ = this.baseUrl + "/api/v1/werkvoorraad"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processWerkvoorraad(_response); + }); + } + + 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[]; + 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 !== 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 */