feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a
canBesluiten decision flag, gated by the same CanBeoordelen capability as the
werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam
method (WP-66 needs one anyway for the real write); owner BSN is masked.
Fixes a real gap found while wiring this up: the behandelportal's login was still
WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the
werkvoorraad screen (WP-64) always denied in a real browser. A dev-only
medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that.
WP-65's own Risks note authorized splitting read from write across sessions given
its size; this is the read half. The decision-recording mutation is next (65b).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
|
|||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||||
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||||
|
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
|
||||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
import { SESSION_PORT } from '@shared/application/session.port';
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
import { SessionStore } from '@auth/application/session.store';
|
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
|
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
||||||
// a query param could otherwise force errors on the live app.
|
// a query param could otherwise force errors on the live app.
|
||||||
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
|
provideHttpClient(
|
||||||
|
withInterceptors(
|
||||||
|
isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [],
|
||||||
|
),
|
||||||
|
),
|
||||||
provideApiClient(),
|
provideApiClient(),
|
||||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||||
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@behandeling/ui/werkvoorraad.page').then((m) => m.WerkvoorraadPage),
|
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',
|
path: 'beheer/stamdata',
|
||||||
// Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default
|
// 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 { 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
|
/** 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. */
|
"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
|
||||||
export const TYPE_LABELS: Record<AanvraagType, string> = {
|
are re-exported here so existing imports of this file keep working. */
|
||||||
registratie: $localize`:@@werkvoorraad.type.registratie:Inschrijving`,
|
export { TYPE_LABELS, statusLabel };
|
||||||
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 {
|
export interface WerkvoorraadRow {
|
||||||
heading: string;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+30
@@ -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[]>();
|
||||||
|
}
|
||||||
+22
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -5,8 +5,8 @@ import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
|
|||||||
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
|
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
|
||||||
|
|
||||||
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition
|
/** 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
|
of the two existing shared/ui molecules, no new atom. Each row links to the
|
||||||
(no `to`): opening a case's detail is WP-65. */
|
beoordeling detail page (WP-65). */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-werkvoorraad-list',
|
selector: 'app-werkvoorraad-list',
|
||||||
imports: [ApplicationListComponent, ApplicationLinkComponent],
|
imports: [ApplicationListComponent, ApplicationLinkComponent],
|
||||||
@@ -19,6 +19,7 @@ import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
|
|||||||
[heading]="row.heading"
|
[heading]="row.heading"
|
||||||
[subtitle]="row.subtitle"
|
[subtitle]="row.subtitle"
|
||||||
[status]="row.status"
|
[status]="row.status"
|
||||||
|
[to]="'/aanvraag/' + item.id"
|
||||||
></li>
|
></li>
|
||||||
}
|
}
|
||||||
</app-application-list>
|
</app-application-list>
|
||||||
|
|||||||
@@ -2960,6 +2960,66 @@
|
|||||||
<source>Opnieuw proberen</source>
|
<source>Opnieuw proberen</source>
|
||||||
<target datatype="html">Try again</target>
|
<target datatype="html">Try again</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="beheer.noTables" datatype="html">
|
||||||
<source>Er is geen stamdata om te beheren.</source>
|
<source>Er is geen stamdata om te beheren.</source>
|
||||||
<target datatype="html">There is no stamdata to manage.</target>
|
<target datatype="html">There is no stamdata to manage.</target>
|
||||||
|
|||||||
@@ -54,99 +54,204 @@
|
|||||||
<trans-unit id="werkvoorraad.type.registratie" datatype="html">
|
<trans-unit id="werkvoorraad.type.registratie" datatype="html">
|
||||||
<source>Inschrijving</source>
|
<source>Inschrijving</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
<context context-type="linenumber">8</context>
|
<context context-type="linenumber">11</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.type.herregistratie" datatype="html">
|
<trans-unit id="werkvoorraad.type.herregistratie" datatype="html">
|
||||||
<source>Herregistratie</source>
|
<source>Herregistratie</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">12</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.type.intake" datatype="html">
|
<trans-unit id="werkvoorraad.type.intake" datatype="html">
|
||||||
<source>Herregistratie-intake</source>
|
<source>Herregistratie-intake</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
<context context-type="linenumber">10</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.status.ingediend" datatype="html">
|
<trans-unit id="werkvoorraad.status.ingediend" datatype="html">
|
||||||
<source>Ingediend</source>
|
<source>Ingediend</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
<context context-type="linenumber">16</context>
|
<context context-type="linenumber">19</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.status.inBehandelingHandmatig" datatype="html">
|
<trans-unit id="werkvoorraad.status.inBehandelingHandmatig" datatype="html">
|
||||||
<source>In behandeling (handmatig)</source>
|
<source>In behandeling (handmatig)</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
<context context-type="linenumber">19</context>
|
<context context-type="linenumber">22</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.status.inBehandeling" datatype="html">
|
<trans-unit id="werkvoorraad.status.inBehandeling" datatype="html">
|
||||||
<source>In behandeling</source>
|
<source>In behandeling</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
|
||||||
<context context-type="linenumber">20</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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
|
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
|
||||||
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
|
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.row.bsn" datatype="html">
|
<trans-unit id="werkvoorraad.row.bsn" datatype="html">
|
||||||
<source>BSN <x id="bsn" equiv-text="item.owner"/></source>
|
<source>BSN <x id="bsn" equiv-text="item.owner"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.heading" datatype="html">
|
<trans-unit id="werkvoorraad.heading" datatype="html">
|
||||||
<source>Werkvoorraad</source>
|
<source>Werkvoorraad</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.intro" datatype="html">
|
<trans-unit id="werkvoorraad.intro" datatype="html">
|
||||||
<source>Aanvragen die op beoordeling wachten.</source>
|
<source>Aanvragen die op beoordeling wachten.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.denied" datatype="html">
|
<trans-unit id="werkvoorraad.denied" datatype="html">
|
||||||
<source>U hebt geen rechten om de werkvoorraad te bekijken.</source>
|
<source>U hebt geen rechten om de werkvoorraad te bekijken.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.failed" datatype="html">
|
<trans-unit id="werkvoorraad.failed" datatype="html">
|
||||||
<source>De werkvoorraad kon niet worden geladen.</source>
|
<source>De werkvoorraad kon niet worden geladen.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.empty" datatype="html">
|
<trans-unit id="werkvoorraad.empty" datatype="html">
|
||||||
<source>Er staan geen aanvragen open.</source>
|
<source>Er staan geen aanvragen open.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.retry" datatype="html">
|
<trans-unit id="werkvoorraad.retry" datatype="html">
|
||||||
<source>Opnieuw proberen</source>
|
<source>Opnieuw proberen</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="header.nav.overzicht" datatype="html">
|
<trans-unit id="header.nav.overzicht" datatype="html">
|
||||||
|
|||||||
@@ -126,6 +126,20 @@ public sealed record SubmitApplicationRequest(
|
|||||||
|
|
||||||
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
|
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 ---
|
// --- Brief (letter composition) contracts ---
|
||||||
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
|
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
|
||||||
// discriminator + nullable fields, the same wire convention as AanvraagStatusDto.
|
// 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>
|
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
|
||||||
public static void SetDrcUrl(string documentId, string drcUrl)
|
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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
|
|||||||
using BigRegister.Api.Contracts;
|
using BigRegister.Api.Contracts;
|
||||||
using BigRegister.Api.Data;
|
using BigRegister.Api.Data;
|
||||||
using BigRegister.Domain.Authorization;
|
using BigRegister.Domain.Authorization;
|
||||||
|
using BigRegister.Domain.Beoordeling;
|
||||||
using BigRegister.Domain.Diplomas;
|
using BigRegister.Domain.Diplomas;
|
||||||
using BigRegister.Domain.Documents;
|
using BigRegister.Domain.Documents;
|
||||||
using BigRegister.Domain.Features;
|
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`,
|
// 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 —
|
// 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.
|
// 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)
|
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
|
||||||
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
|
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
|
||||||
.ToList())))
|
.ToList())))
|
||||||
.Produces<List<ApplicationSummaryDto>>()
|
.Produces<List<ApplicationSummaryDto>>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.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
|
// 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).
|
// 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
|
// 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);
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One gate for the werkvoorraad read — the enforce twin of `CanBeoordelen` (WP-62/64).
|
// One gate for every behandelaar endpoint (werkvoorraad, WP-64; beoordeling detail, WP-65) —
|
||||||
// Unlike the other *Admin gates above, this checks the CallerIdentity directly (medewerker
|
// the enforce twin of `CanBeoordelen` (WP-62). Unlike the other *Admin gates above, this
|
||||||
// rollen), not a role-only Principal — a zorgverlener with X-Role=admin still gets denied.
|
// checks the CallerIdentity directly (medewerker rollen), not a role-only Principal — a
|
||||||
IResult Werkvoorraad(HttpContext ctx, Func<IResult> action)
|
// 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();
|
if (Authz.CanBeoordelen(ctx.Caller())) return action();
|
||||||
AuditAuthz(ctx, "aanvraag:beoordelen", "werkvoorraad", false, Authz.ResolvePrincipal(ctx));
|
AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx));
|
||||||
return Results.Problem(detail: "Alleen een behandelaar mag de werkvoorraad bekijken.",
|
return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.",
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}": {
|
"/api/v1/admin/cases/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1606,6 +1648,52 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"BriefDecisionsDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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.Diplomas;
|
||||||
using BigRegister.Domain.Documents;
|
using BigRegister.Domain.Documents;
|
||||||
using BigRegister.Domain.Registrations;
|
using BigRegister.Domain.Registrations;
|
||||||
@@ -179,3 +181,15 @@ public class SubmissionRuleTests
|
|||||||
public void Phone_change_is_validated(string telefoon, string? expected) =>
|
public void Phone_change_is_validated(string telefoon, string? expected) =>
|
||||||
Assert.Equal(expected, SubmissionRules.RejectPhoneChange(telefoon));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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-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-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-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-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 | todo |
|
| [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-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 |
|
| [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`
|
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.
|
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
|
## Verification
|
||||||
|
|
||||||
`npm run ci` in the behandelportal app; `cd backend && dotnet test`; manual: log in as a
|
`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
|
# 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
|
Phase: 11 — Behandelportal
|
||||||
|
|
||||||
## Why
|
## 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.
|
3. Wire the werkvoorraad list (WP-64) to link into this detail page.
|
||||||
4. Storybook stories + a11y for the new detail/decision UI.
|
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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] A medewerker can view one aanvraag's detail and record a decision that advances its
|
- [ ] A medewerker can view one aanvraag's detail and record a decision that advances its
|
||||||
|
|||||||
@@ -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)
|
## Actor kinds (backend, WP-62)
|
||||||
|
|
||||||
`X-Role`/`Principal` above is a coarse role that applies to **either** of two actor kinds the
|
`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** (backoffice employee — no BSN, has `Rollen`). `StubIdentityProvider` picks the
|
||||||
medewerker kind from a dev header, `X-Medewerker` (+ `X-Rollen`), mirroring `X-Role`/`X-Subject`
|
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
|
above. The SSP's FE never sends either header — it has no medewerker screens. The
|
||||||
and for the behandelportal (WP-64+) to use later. `Authz.CanBeoordelen(caller)` is the first
|
**behandelportal** does: `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts`
|
||||||
medewerker capability — a rol-based decision flag (`MedewerkerRol.Behandelaar`), not a role
|
stamps every request as one fixed stand-in medewerker (dev-only, same `isDevMode()` gate as
|
||||||
entry on `/me`, since `/me`'s `RoleCapabilities` is keyed on `Principal` and can't see the actor
|
`roleInterceptor`), since this app has no real employee-SSO login yet (ADR-0002 §3 — the two
|
||||||
kind.
|
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
|
## What each role unlocks
|
||||||
|
|
||||||
|
|||||||
@@ -1116,6 +1116,55 @@ export class ApiClient {
|
|||||||
return Promise.resolve<ApplicationSummaryDto[]>(null as any);
|
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
|
* @return No Content
|
||||||
*/
|
*/
|
||||||
@@ -1944,6 +1993,22 @@ export interface AuthzAuditDto {
|
|||||||
correlationId?: string | undefined;
|
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 {
|
export interface BriefDecisionsDto {
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
canApprove?: boolean;
|
canApprove?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user