Behandelportal: monorepo merge + WP-64..67 backoffice arc (OpenZaak write closes it out) #1

Merged
eho merged 8 commits from monorepo-behandelportal into main 2026-08-04 12:56:34 +00:00
29 changed files with 1195 additions and 58 deletions
Showing only changes of commit 4133b30e5d - Show all commits
+6 -1
View File
@@ -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
@@ -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
@@ -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);
@@ -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';
}
@@ -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<RemoteData<Err, BeoordelingView>>({ 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);
}
}
@@ -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<BeoordelingView, 'status'> = {
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);
});
});
@@ -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<AanvraagType, string> = {
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;
}
@@ -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;
}
@@ -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<AanvraagType, string> = {
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;
@@ -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([]);
});
});
@@ -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<BeoordelingViewDto> {
return this.client.beoordeling(id);
}
}
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
export function parseBeoordelingStatus(
s: AanvraagStatusDto | undefined,
): Result<string, BeoordelingStatus> {
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<string, BeoordelingDocument> {
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<string, BeoordelingView> {
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,
});
}
@@ -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) {
<p class="app-text-subtle" i18n="@@beoordeling.documenten.leeg">Geen documenten.</p>
} @else {
<ul class="list-unstyled">
@for (doc of documenten(); track doc.documentId) {
<li>
<a
[href]="'/api/v1/uploads/' + doc.documentId + '/content'"
target="_blank"
rel="noopener"
>{{ doc.fileName }}</a
>
</li>
}
</ul>
}
`,
})
export class BeoordelingDocumentenComponent {
documenten = input.required<BeoordelingDocument[]>();
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { BeoordelingDocumentenComponent } from './beoordeling-documenten.component';
const meta: Meta<BeoordelingDocumentenComponent> = {
title: 'Domein/Behandeling/Beoordeling Documenten',
component: BeoordelingDocumentenComponent,
};
export default meta;
type Story = StoryObj<BeoordelingDocumentenComponent>;
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: [] },
};
@@ -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: `
<app-page-shell [heading]="heading" backLink="/dashboard">
<app-async [data]="store.view()" (retryClicked)="reload()">
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="5" />
</ng-template>
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (view(); as v) {
<app-data-block [heading]="detailHeading" class="app-section">
@for (row of rows(v); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div>
}
</app-data-block>
<app-data-block [heading]="documentenHeading" class="app-section">
<app-beoordeling-documenten [documenten]="v.documenten" />
</app-data-block>
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
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();
}
}
@@ -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"
></li>
}
</app-application-list>
@@ -2960,6 +2960,66 @@
<source>Opnieuw proberen</source>
<target datatype="html">Try again</target>
</trans-unit>
<trans-unit id="beoordeling.status.meerInfoGevraagd" datatype="html">
<source>Meer informatie gevraagd</source>
<target datatype="html">More information requested</target>
</trans-unit>
<trans-unit id="beoordeling.status.goedgekeurd" datatype="html">
<source>Goedgekeurd</source>
<target datatype="html">Approved</target>
</trans-unit>
<trans-unit id="beoordeling.status.afgewezen" datatype="html">
<source>Afgewezen</source>
<target datatype="html">Rejected</target>
</trans-unit>
<trans-unit id="beoordeling.detail.soort" datatype="html">
<source>Soort aanvraag</source>
<target datatype="html">Application type</target>
</trans-unit>
<trans-unit id="beoordeling.detail.status" datatype="html">
<source>Status</source>
<target datatype="html">Status</target>
</trans-unit>
<trans-unit id="beoordeling.detail.referentie" datatype="html">
<source>Referentie</source>
<target datatype="html">Reference</target>
</trans-unit>
<trans-unit id="beoordeling.detail.eigenaar" datatype="html">
<source>Eigenaar (BSN)</source>
<target datatype="html">Owner (BSN)</target>
</trans-unit>
<trans-unit id="beoordeling.detail.ingediend" datatype="html">
<source>Ingediend op</source>
<target datatype="html">Submitted on</target>
</trans-unit>
<trans-unit id="beoordeling.detail.reden" datatype="html">
<source>Reden</source>
<target datatype="html">Reason</target>
</trans-unit>
<trans-unit id="beoordeling.heading" datatype="html">
<source>Aanvraag</source>
<target datatype="html">Application</target>
</trans-unit>
<trans-unit id="beoordeling.detail.heading" datatype="html">
<source>Aanvraaggegevens</source>
<target datatype="html">Application details</target>
</trans-unit>
<trans-unit id="beoordeling.documenten.heading" datatype="html">
<source>Documenten</source>
<target datatype="html">Documents</target>
</trans-unit>
<trans-unit id="beoordeling.documenten.leeg" datatype="html">
<source>Geen documenten.</source>
<target datatype="html">No documents.</target>
</trans-unit>
<trans-unit id="beoordeling.failed" datatype="html">
<source>De aanvraag kon niet worden geladen.</source>
<target datatype="html">The application could not be loaded.</target>
</trans-unit>
<trans-unit id="beoordeling.retry" datatype="html">
<source>Opnieuw proberen</source>
<target datatype="html">Try again</target>
</trans-unit>
<trans-unit id="beheer.noTables" datatype="html">
<source>Er is geen stamdata om te beheren.</source>
<target datatype="html">There is no stamdata to manage.</target>
+125 -20
View File
@@ -54,99 +54,204 @@
<trans-unit id="werkvoorraad.type.registratie" datatype="html">
<source>Inschrijving</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">8</context>
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.herregistratie" datatype="html">
<source>Herregistratie</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">9</context>
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.intake" datatype="html">
<source>Herregistratie-intake</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">10</context>
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.ingediend" datatype="html">
<source>Ingediend</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">16</context>
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.inBehandelingHandmatig" datatype="html">
<source>In behandeling (handmatig)</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">19</context>
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">22</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.inBehandeling" datatype="html">
<source>In behandeling</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">20</context>
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.meerInfoGevraagd" datatype="html">
<source>Meer informatie gevraagd</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.goedgekeurd" datatype="html">
<source>Goedgekeurd</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.afgewezen" datatype="html">
<source>Afgewezen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.soort" datatype="html">
<source>Soort aanvraag</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">37</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.status" datatype="html">
<source>Status</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.referentie" datatype="html">
<source>Referentie</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">39</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.eigenaar" datatype="html">
<source>Eigenaar (BSN)</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">40</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.ingediend" datatype="html">
<source>Ingediend op</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.reden" datatype="html">
<source>Reden</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">47</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">36</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.row.bsn" datatype="html">
<source>BSN <x id="bsn" equiv-text="item.owner"/></source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">41</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.documenten.leeg" datatype="html">
<source>Geen documenten.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts</context>
<context context-type="linenumber">11,13</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.heading" datatype="html">
<source>Aanvraag</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.heading" datatype="html">
<source>Aanvraaggegevens</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.documenten.heading" datatype="html">
<source>Documenten</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.failed" datatype="html">
<source>De aanvraag kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.heading" datatype="html">
<source>Werkvoorraad</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">57</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.intro" datatype="html">
<source>Aanvragen die op beoordeling wachten.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">58</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.denied" datatype="html">
<source>U hebt geen rechten om de werkvoorraad te bekijken.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">59</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.failed" datatype="html">
<source>De werkvoorraad kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">60</context>
<context context-type="linenumber">67</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.empty" datatype="html">
<source>Er staan geen aanvragen open.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">61</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">62</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.overzicht" datatype="html">
@@ -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<BeoordelingDocumentDto> 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.
@@ -83,6 +83,19 @@ public static class DocumentStore
}
}
/// <summary>Documents by DocumentId (WP-65's beoordeling detail reads an aanvraag's already-
/// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is
/// keyed by the wizard's own LocalId instead.</summary>
public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds)
{
var set = documentIds.ToHashSet();
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.DocumentId)).ToList();
}
}
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl)
{
@@ -0,0 +1,19 @@
using BigRegister.Api.Data;
namespace BigRegister.Domain.Beoordeling;
/// <summary>
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Read-side
/// today (<see cref="CanDecide"/> 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.
/// </summary>
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;
}
+30 -7
View File
@@ -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<List<ApplicationSummaryDto>>()
.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<AanvraagStatusTag>(c.Status.Tag)));
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
}))
.Produces<BeoordelingViewDto>()
.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<IResult> 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<IResult> 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<IResult> 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);
}
+88
View File
@@ -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": {
@@ -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<TestWebApplicationFactory>
{
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<ApplicationDetailDto>())!;
var localId = Guid.NewGuid().ToString();
var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf"));
upload.EnsureSuccessStatusCode();
var doc = (await upload.Content.ReadFromJsonAsync<UploadResponse>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new
{
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<BeoordelingViewDto>())!;
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<ApplicationDetailDto>())!;
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);
}
}
@@ -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));
}
+2 -2
View File
@@ -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 |
@@ -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
@@ -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
`<app-data-block>`/`<app-data-row>` (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
+13 -6
View File
@@ -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
@@ -1116,6 +1116,55 @@ export class ApiClient {
return Promise.resolve<ApplicationSummaryDto[]>(null as any);
}
/**
* @return OK
*/
beoordeling(id: string): Promise<BeoordelingViewDto> {
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<BeoordelingViewDto> {
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<BeoordelingViewDto>(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;