feat(behandelportal): WP-65b beoordeling besluit (decision write)
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s

Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.

Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-03 09:46:20 +02:00
co-authored by Claude Sonnet 5
parent 4133b30e5d
commit af8a011819
20 changed files with 1303 additions and 20 deletions
@@ -0,0 +1,16 @@
import { inject } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Valid } from '@behandeling/domain/besluit.machine';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { BesluitAdapter } from '@behandeling/infrastructure/besluit.adapter';
/**
* Command factory: binds the besluit adapter in an injection context and returns the
* submit function the decision form calls. Same field-initializer shape as
* `createStore` — the UI holds an application command, never the network client.
*/
export function createSubmitBesluit() {
const adapter = inject(BesluitAdapter);
return (id: string, data: Valid): Promise<Result<string, void>> =>
runSubmit(() => adapter.besluit(id, data), SUBMIT_FAILED);
}
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { BesluitState, reduce, initial } from './besluit.machine';
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
tag: 'Editing',
draft: { besluit, toelichting },
errors: {},
});
describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).draft.besluit).toBe('Goedkeuren');
});
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
const s = reduce(editingWith(''), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.besluit).toBeTruthy();
});
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.toelichting).toBeTruthy();
});
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
besluit: 'Goedkeuren',
toelichting: undefined,
});
});
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
besluit: 'Afwijzen',
toelichting: 'niet erkend',
});
});
it('SubmitConfirmed maps Submitting to Submitted', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -0,0 +1,92 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum — see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
export type BesluitTag = (typeof BESLUIT_TAGS)[number];
function isBesluitTag(v: string): v is BesluitTag {
return (BESLUIT_TAGS as readonly string[]).includes(v);
}
/** What the user picked (raw, possibly empty while nothing is selected yet). */
export interface Draft {
besluit: string;
toelichting: string;
}
/** After parsing — besluit is the narrow tag; toelichting is present only when given
(required for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced by validate). */
export interface Valid {
besluit: BesluitTag;
toelichting?: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/** The decision form as one tagged union — same idiom as every other form in this
house (form-machine skill), single-step. draft/errors exist only while Editing. */
export type BesluitState =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: BesluitState = {
tag: 'Editing',
draft: { besluit: '', toelichting: '' },
errors: {},
};
function validate(draft: Draft): Result<Errors, Valid> {
if (!isBesluitTag(draft.besluit)) {
return {
ok: false,
error: { besluit: $localize`:@@besluit.error.verplicht:Kies een besluit.` },
};
}
const toelichting = draft.toelichting.trim();
if (draft.besluit !== 'Goedkeuren' && toelichting === '') {
return {
ok: false,
error: {
toelichting: $localize`:@@besluit.error.toelichtingVerplicht:Geef een toelichting.`,
},
};
}
return { ok: true, value: { besluit: draft.besluit, toelichting: toelichting || undefined } };
}
export type BesluitMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: BesluitState }; // mount a specific state (stories/tests)
export function reduce(s: BesluitState, m: BesluitMsg): BesluitState {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}
@@ -0,0 +1,18 @@
import { Injectable, inject } from '@angular/core';
import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine';
/**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single
* place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority).
*/
@Injectable({ providedIn: 'root' })
export class BesluitAdapter {
private client = inject(ApiClient);
async besluit(id: string, data: Valid): Promise<void> {
await this.client.besluit(id, { besluit: data.besluit, toelichting: data.toelichting });
}
}
@@ -10,12 +10,13 @@ import { ASYNC } from '@shared/ui/async/async.component';
import { BeoordelingStore } from '@behandeling/application/beoordeling.store'; import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view'; import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component'; import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/** /**
* Page: one aanvraag's beoordeling detail (WP-65, read side). The werkvoorraad list * Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
* (WP-64) links here. Recording a decision is this WP's second half — for now the * here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) —
* page only shows status/documents; `canBesluiten` is already carried by the view so * the page never recomputes the lifecycle itself. On a recorded decision the form emits
* the decision form has zero further backend round-trip to add. * `decided`, and the page just reloads (the server is the authority on the new status).
*/ */
@Component({ @Component({
selector: 'app-beoordeling-page', selector: 'app-beoordeling-page',
@@ -27,6 +28,7 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
DataBlockComponent, DataBlockComponent,
DataRowComponent, DataRowComponent,
BeoordelingDocumentenComponent, BeoordelingDocumentenComponent,
BesluitFormComponent,
...ASYNC, ...ASYNC,
], ],
template: ` template: `
@@ -49,6 +51,11 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
<app-data-block [heading]="documentenHeading" class="app-section"> <app-data-block [heading]="documentenHeading" class="app-section">
<app-beoordeling-documenten [documenten]="v.documenten" /> <app-beoordeling-documenten [documenten]="v.documenten" />
</app-data-block> </app-data-block>
@if (v.canBesluiten) {
<div class="app-section">
<app-besluit-form [id]="v.id" (decided)="reload()" />
</div>
}
} }
</ng-template> </ng-template>
</app-async> </app-async>
@@ -0,0 +1,140 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/**
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the
* authority; on success this only emits `decided` — the page reloads the detail
* (BeoordelingStore.reload()), it doesn't guess the new state itself.
*/
@Component({
selector: 'app-besluit-form',
imports: [
FormsModule,
ButtonComponent,
HeadingComponent,
AlertComponent,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
],
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
} @else {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
<app-form-field
i18n-label="@@besluit.besluitLabel"
label="Besluit"
fieldId="besluit-keuze"
required
[error]="errors().besluit"
>
<app-radio-group
name="besluit-keuze"
[options]="BESLUIT_OPTIONS"
[invalid]="!!errors().besluit"
[ngModel]="besluit()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'besluit', value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
<app-form-field
i18n-label="@@besluit.toelichtingLabel"
label="Toelichting"
fieldId="besluit-toelichting"
[error]="errors().toelichting"
>
<app-text-input
inputId="besluit-toelichting"
[invalid]="!!errors().toelichting"
[ngModel]="toelichting()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'toelichting', value: $event })"
name="toelichting"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
</form>
}
`,
})
export class BesluitFormComponent {
private submit = createSubmitBesluit();
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
id = input.required<string>();
decided = output<void>();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<BesluitState>(initial);
readonly state = this.store.model;
protected dispatch = this.store.dispatch;
protected readonly BESLUIT_OPTIONS: RadioOption[] = [
{ value: 'Goedkeuren', label: $localize`:@@besluit.optie.goedkeuren:Goedkeuren` },
{ value: 'Afwijzen', label: $localize`:@@besluit.optie.afwijzen:Afwijzen` },
{
value: 'MeerInfoOpvragen',
label: $localize`:@@besluit.optie.meerInfoOpvragen:Meer informatie opvragen`,
},
];
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
}
onSubmit() {
this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(this.id(), s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { BesluitFormComponent } from './besluit-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Valid } from '@behandeling/domain/besluit.machine';
const validData: Valid = { besluit: 'Afwijzen', toelichting: 'Diploma niet erkend' };
const meta: Meta<BesluitFormComponent> = {
title: 'Domein/Behandeling/Besluit Form',
component: BesluitFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
args: { id: 'aanvraag-1' },
};
export default meta;
type Story = StoryObj<BesluitFormComponent>;
// One render per state of the machine.
export const Empty: Story = {
args: { seed: { tag: 'Editing', draft: { besluit: '', toelichting: '' }, errors: {} } },
};
export const WithErrors: Story = {
args: {
seed: {
tag: 'Editing',
draft: { besluit: 'Afwijzen', toelichting: '' },
errors: { toelichting: 'Geef een toelichting.' },
},
},
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};
@@ -3020,6 +3020,54 @@
<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="besluit.error.verplicht" datatype="html">
<source>Kies een besluit.</source>
<target datatype="html">Choose a decision.</target>
</trans-unit>
<trans-unit id="besluit.error.toelichtingVerplicht" datatype="html">
<source>Geef een toelichting.</source>
<target datatype="html">Give an explanation.</target>
</trans-unit>
<trans-unit id="besluit.success" datatype="html">
<source>Het besluit is vastgelegd.</source>
<target datatype="html">The decision has been recorded.</target>
</trans-unit>
<trans-unit id="besluit.heading" datatype="html">
<source>Besluit vastleggen</source>
<target datatype="html">Record a decision</target>
</trans-unit>
<trans-unit id="besluit.besluitLabel" datatype="html">
<source>Besluit</source>
<target datatype="html">Decision</target>
</trans-unit>
<trans-unit id="besluit.toelichtingLabel" datatype="html">
<source>Toelichting</source>
<target datatype="html">Explanation</target>
</trans-unit>
<trans-unit id="besluit.failed" datatype="html">
<source>Het vastleggen is niet gelukt:</source>
<target datatype="html">Recording the decision failed:</target>
</trans-unit>
<trans-unit id="besluit.optie.goedkeuren" datatype="html">
<source>Goedkeuren</source>
<target datatype="html">Approve</target>
</trans-unit>
<trans-unit id="besluit.optie.afwijzen" datatype="html">
<source>Afwijzen</source>
<target datatype="html">Reject</target>
</trans-unit>
<trans-unit id="besluit.optie.meerInfoOpvragen" datatype="html">
<source>Meer informatie opvragen</source>
<target datatype="html">Request more information</target>
</trans-unit>
<trans-unit id="besluit.submit" datatype="html">
<source>Besluit vastleggen</source>
<target datatype="html">Record decision</target>
</trans-unit>
<trans-unit id="besluit.submitBezig" datatype="html">
<source>Bezig met vastleggen…</source>
<target datatype="html">Recording…</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>
+103 -5
View File
@@ -156,6 +156,20 @@
<context context-type="linenumber">47</context> <context context-type="linenumber">47</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="besluit.error.verplicht" datatype="html">
<source>Kies een besluit.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
<context context-type="linenumber">46</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.error.toelichtingVerplicht" datatype="html">
<source>Geef een toelichting.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
<context context-type="linenumber">54</context>
</context-group>
</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">
@@ -181,35 +195,105 @@
<source>Aanvraag</source> <source>Aanvraag</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context> <context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.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="beoordeling.detail.heading" datatype="html"> <trans-unit id="beoordeling.detail.heading" datatype="html">
<source>Aanvraaggegevens</source> <source>Aanvraaggegevens</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context> <context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">63</context> <context context-type="linenumber">70</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="beoordeling.documenten.heading" datatype="html"> <trans-unit id="beoordeling.documenten.heading" datatype="html">
<source>Documenten</source> <source>Documenten</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context> <context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">64</context> <context context-type="linenumber">71</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="beoordeling.failed" datatype="html"> <trans-unit id="beoordeling.failed" datatype="html">
<source>De aanvraag kon niet worden geladen.</source> <source>De aanvraag kon niet worden geladen.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context> <context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">65</context> <context context-type="linenumber">72</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="beoordeling.retry" datatype="html"> <trans-unit id="beoordeling.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/beoordeling.page.ts</context> <context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">66</context> <context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.success" datatype="html">
<source>Het besluit is vastgelegd.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">35,37</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.heading" datatype="html">
<source>Besluit vastleggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">37,39</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.besluitLabel" datatype="html">
<source>Besluit</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.toelichtingLabel" datatype="html">
<source>Toelichting</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">59,60</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.failed" datatype="html">
<source>Het vastleggen is niet gelukt:</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">75,76</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.goedkeuren" datatype="html">
<source>Goedkeuren</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">101</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.afwijzen" datatype="html">
<source>Afwijzen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.meerInfoOpvragen" datatype="html">
<source>Meer informatie opvragen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.submit" datatype="html">
<source>Besluit vastleggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.submitBezig" datatype="html">
<source>Bezig met vastleggen…</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">110</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="werkvoorraad.heading" datatype="html"> <trans-unit id="werkvoorraad.heading" datatype="html">
@@ -870,6 +954,20 @@
<context context-type="linenumber">107</context> <context context-type="linenumber">107</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="common.ja" datatype="html">
<source>Ja</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="common.nee" datatype="html">
<source>Nee</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="spinner.aria" datatype="html"> <trans-unit id="spinner.aria" datatype="html">
<source>Bezig met laden</source> <source>Bezig met laden</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -140,6 +140,15 @@ public sealed record BeoordelingViewDto(
IReadOnlyList<BeoordelingDocumentDto> Documenten, IReadOnlyList<BeoordelingDocumentDto> Documenten,
BeoordelingDecisionsDto Decisions); BeoordelingDecisionsDto Decisions);
/// Recording a decision (WP-65b). `Besluit` is the enum member name as a string — same
/// wire convention as `AanvraagStatusDto.Tag` (this backend never ships a raw C# enum,
/// it round-trips names via Enum.Parse/.ToString() at the Contracts boundary, no
/// JsonStringEnumConverter configured). The endpoint 400s an unknown name. Toelichting
/// is required for Afwijzen/MeerInfoOpvragen, validated server-side.
public sealed record RecordBesluitRequest(string Besluit, string? Toelichting = null);
public sealed record RecordBesluitResponse(AanvraagStatusDto Status);
// --- 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.
@@ -38,17 +38,25 @@ public static class Mappers
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports // Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case // Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable // stays In behandeling until a behandelaar records a decision (WP-65b — before that
// by passing different `now` values without waiting for the wall clock. // WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure —
// // testable by passing different `now` values without waiting for the wall clock.
// Ingediend/MeerInfoGevraagd (AanvraagStatusTag, WP-63) aren't produced here yet — no
// behandelaar action exists to reach them (WP-65 adds the transition endpoint).
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{ {
if (!a.Submitted) if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount); return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null) if (a.Reden is not null)
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden); return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
// A recorded decision (WP-65b) wins over the auto-approve computation below — a
// behandelaar's explicit besluit is authoritative once made.
if (a.BesluitStatus is { } besluit)
return besluit switch
{
Besluit.Goedkeuren => new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie),
Besluit.Afwijzen => new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
Besluit.MeerInfoOpvragen => new(AanvraagStatusTag.MeerInfoGevraagd.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
};
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow) if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie); return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable); return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
@@ -13,6 +13,14 @@ namespace BigRegister.Api.Data;
/// </summary> /// </summary>
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen } public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
/// <summary>
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
/// offers, each advancing <see cref="Aanvraag.BesluitStatus"/> and (via
/// <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/>) the published
/// <see cref="AanvraagStatusTag"/> the FE renders.
/// </summary>
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
/// <summary> /// <summary>
/// An application (aanvraag) — the system of record the dashboard reads. A wizard /// An application (aanvraag) — the system of record the dashboard reads. A wizard
/// creates one as a Concept on its first step, syncs its draft snapshot per step, /// creates one as a Concept on its first step, syncs its draft snapshot per step,
@@ -50,6 +58,17 @@ public sealed class Aanvraag
/// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path; /// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path;
/// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary> /// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary>
public string? ZgwError { get; set; } public string? ZgwError { get; set; }
/// <summary>WP-65b: a behandelaar's recorded decision, if any. Non-null wins over the
/// auto-approve computation in <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/> —
/// "a recorded decision wins". Mutable across <see cref="AanvraagStatusTag.MeerInfoGevraagd"/>
/// (a behandelaar may decide again later); frozen once Goedgekeurd/Afgewezen (terminal, per
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/>).</summary>
public Besluit? BesluitStatus { get; set; }
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
/// the published status's Reden), optional for Goedkeuren.</summary>
public string? BesluitToelichting { get; set; }
} }
/// <summary> /// <summary>
@@ -104,6 +123,17 @@ public static class ApplicationStore
} }
} }
/// Cross-owner single read (WP-65b) — the behandelaar decision endpoint's counterpart of
/// <see cref="Get"/>, same "any owner" shape as <see cref="DeleteAny"/>.
public static Aanvraag? GetAny(string id)
{
lock (_gate)
{
using var db = Db.Create();
return db.Applications.Find(id);
}
}
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this /// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint. /// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
public static IReadOnlyList<Aanvraag> ListAll() public static IReadOnlyList<Aanvraag> ListAll()
@@ -224,4 +254,24 @@ public static class ApplicationStore
db.SaveChanges(); db.SaveChanges();
} }
} }
/// <summary>Record a behandelaar's decision (WP-65b). The endpoint has already checked
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/> against the
/// freshly-read status before calling this — cross-owner like <see cref="DeleteAny"/>,
/// since a behandelaar decides on any citizen's case. Returns null only if the aanvraag
/// is gone (shouldn't happen — this runs right after the endpoint's own read found it).</summary>
public static Aanvraag? RecordBesluit(string id, Besluit besluit, string? toelichting)
{
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null) return null;
a.BesluitStatus = besluit;
a.BesluitToelichting = toelichting;
a.UpdatedAt = DateTimeOffset.UtcNow;
db.SaveChanges();
return a;
}
}
} }
@@ -0,0 +1,288 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260803070817_BesluitStatus")]
partial class BesluitStatus
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<int?>("BesluitStatus")
.HasColumnType("INTEGER");
b.Property<string>("BesluitToelichting")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.Property<string>("ZgwError")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Decision")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuthzAudit");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER");
b.HasKey("Key");
b.ToTable("FeatureFlags");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class BesluitStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BesluitStatus",
table: "Applications",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BesluitToelichting",
table: "Applications",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BesluitStatus",
table: "Applications");
migrationBuilder.DropColumn(
name: "BesluitToelichting",
table: "Applications");
}
}
}
@@ -25,6 +25,12 @@ namespace BigRegister.Api.Data.Migrations
b.Property<bool>("AutoApprovable") b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("BesluitStatus")
.HasColumnType("INTEGER");
b.Property<string>("BesluitToelichting")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+34
View File
@@ -442,6 +442,40 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- Besluit (WP-65b): record a behandelaar's decision, advancing the WP-63 status
// lifecycle. Runs against ApplicationStore directly (not the IZaakSource seam) — same
// reasoning as the GET above: a new seam method would force an OpenZaakZaakSource
// write now, which is WP-66's surface, not this one's. The transition-legality check
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
// so the two can never drift.
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx) =>
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
{
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
var now = DateTimeOffset.UtcNow;
var a = ApplicationStore.GetAny(id);
var statusTag = a?.ToStatusDto(now).Tag;
if (a is null || statusTag == "Concept") return Results.NotFound();
var current = Enum.Parse<AanvraagStatusTag>(statusTag!);
if (!BeoordelingRules.CanDecide(current))
return Results.Problem(
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
statusCode: StatusCodes.Status409Conflict);
if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting))
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!;
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit);
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
}))
.Produces<RecordBesluitResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.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
+95
View File
@@ -840,6 +840,78 @@
} }
} }
}, },
"/api/v1/beoordeling/{id}/besluit": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecordBesluitRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecordBesluitResponse"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"409": {
"description": "Conflict",
"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": [
@@ -2448,6 +2520,29 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"RecordBesluitRequest": {
"type": "object",
"properties": {
"besluit": {
"type": "string",
"nullable": true
},
"toelichting": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"RecordBesluitResponse": {
"type": "object",
"properties": {
"status": {
"$ref": "#/components/schemas/AanvraagStatusDto"
}
},
"additionalProperties": false
},
"ReferentieResponse": { "ReferentieResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -127,4 +127,113 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
req.Headers.Add("X-Rollen", "geen"); req.Headers.Add("X-Rollen", "geen");
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
} }
private Task<HttpResponseMessage> PostBesluit(string id, object body)
{
var req = AsBehandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{id}/besluit");
req.Content = JsonContent.Create(body);
return _client.SendAsync(req);
}
[Fact]
public async Task Goedkeuren_advances_status_to_Goedgekeurd()
{
var (a, _) = await CreateManualCaseWithDocument();
try
{
var res = await PostBesluit(a.Id, new { besluit = "Goedkeuren" });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
Assert.Equal("Goedgekeurd", body.Status.Tag);
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
[Fact]
public async Task Afwijzen_requires_a_toelichting()
{
var (a, _) = await CreateManualCaseWithDocument();
try
{
var missing = await PostBesluit(a.Id, new { besluit = "Afwijzen" });
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
var res = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "Diploma niet erkend" });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
Assert.Equal("Diploma niet erkend", body.Status.Reden);
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
[Fact]
public async Task MeerInfoOpvragen_is_still_decidable_afterwards()
{
var (a, _) = await CreateManualCaseWithDocument();
try
{
var res = await PostBesluit(a.Id, new { besluit = "MeerInfoOpvragen", toelichting = "Stuur een geldig diploma" });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
Assert.Equal("MeerInfoGevraagd", body.Status.Tag);
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
Assert.True(view.Decisions.CanBesluiten); // not terminal — a decision can still follow
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
[Fact]
public async Task Already_decided_case_rejects_a_further_besluit()
{
var (a, _) = await CreateManualCaseWithDocument();
try
{
(await PostBesluit(a.Id, new { besluit = "Goedkeuren" })).EnsureSuccessStatusCode();
var again = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "te laat" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
[Fact]
public async Task Unknown_id_404s_and_zorgverlener_is_forbidden()
{
var notFound = await PostBesluit("does-not-exist", new { besluit = "Goedkeuren" });
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
var (a, _) = await CreateManualCaseWithDocument();
try
{
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/beoordeling/{a.Id}/besluit")
{
Content = JsonContent.Create(new { besluit = "Goedkeuren" }),
};
req.Headers.Add("X-Role", "admin"); // zorgverlener, no X-Medewerker
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
} }
@@ -1,6 +1,6 @@
# WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen # WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen
Status: in progress (65a — detail read — done; 65b — decision write — not started) Status: done (65a — detail read — done; 65b — decision write — done)
Phase: 11 — Behandelportal Phase: 11 — Behandelportal
## Why ## Why
@@ -80,7 +80,7 @@ one gate for every behandelaar endpoint, `resource` feeding the audit row; the o
**Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to **Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to
65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current 65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current
*computed* status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable; _computed_ status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable;
`Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet, `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 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 the mutation, the `Besluit` enum, and the transition-legality check that reuses this same
@@ -110,14 +110,62 @@ yet — 65a is infrastructure the decision screen needs, not a slice of the AC i
test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift 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). step shows the expected pre-commit diff (this WP's own uncommitted endpoint).
## Progress notes (65b — done)
**Backend (`POST /beoordeling/{id}/besluit`):** runs against `ApplicationStore` directly
(not the `IZaakSource` seam) — same reasoning as 65a's GET: a new seam method would force
an `OpenZaakZaakSource` write now, which stays WP-66's surface. A new `Besluit` enum
(`Goedkeuren | Afwijzen | MeerInfoOpvragen`) backs a nullable `Aanvraag.BesluitStatus` +
`Aanvraag.BesluitToelichting` column pair (EF migration `BesluitStatus`). Like every other
enum in this backend, `Besluit` never crosses the wire as a raw C# enum — no
`JsonStringEnumConverter` is configured, so `RecordBesluitRequest.Besluit` is a plain
`string`, parsed with `Enum.TryParse` (400 on an unknown name) — the same wire convention
`AanvraagStatusDto.Tag` already established. The endpoint reuses
`BeoordelingRules.CanDecide` — the SAME function the read side's `canBesluiten` flag calls
— as the transition-legality check, so the two can never drift (409 on an illegal
transition, e.g. deciding an already-`Goedgekeurd` case again). Toelichting is required
(400) for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced server-side because
the published `AanvraagStatusDto`'s `Reden` field is non-optional on those two tags (the
FE's existing `parseBeoordelingStatus` already required it). `Mappers.ToStatusDto` gained
"a recorded decision wins" between the submit-time `Reden` check and the auto-approve
computation — the two never collide in practice (a submit-time-rejected case is already
terminal and never reaches the werkvoorraad/beoordeling screens, so no behandelaar ever
records a besluit on one). `MeerInfoGevraagd` is not terminal: `CanDecide` still allows a
further besluit afterwards, so a behandelaar can ask for info, then later approve/reject
once it arrives — the same `BesluitStatus` column is simply overwritten.
**FE:** `besluit.machine.ts` is the same single-step Editing/Submitting/Submitted/Failed
union as `change-request.machine.ts` (form-machine skill) — `Draft.besluit` stays a raw
string (parsed into the narrow `BesluitTag` union only in `validate`, "parse, don't
validate"), so the generic `SetField` reducer case needs no per-field typing gymnastics.
`besluit-form` (organism) composes `RadioGroupComponent` (the three actions) +
`FormFieldComponent`/`TextInputComponent` (toelichting, plain single-line — no textarea
atom exists and this form doesn't justify adding one) — no new shared atom. On a
successful decision it emits `decided`, and `BeoordelingPage` just calls
`BeoordelingStore.reload()` — the server is the authority on the new status, the page
never guesses it. The form only renders when the server's `canBesluiten` flag is true
(ADR-0001: render the decision, don't recompute the lifecycle).
Re-ran the full acceptance-criteria smoke by hand against `LocalZaakSource`: created a
manual registratie case, opened it via werkvoorraad → beoordeling, recorded Afwijzen with a
toelichting (status → Afgewezen, reason shown), confirmed a further besluit on that same
case now 409s. `npm run ci` green (lint, dep:check ×2, format:check, check:tokens, all four
projects' test suites, both apps' localized `nl`+`en` builds, backend `dotnet format
--verify-no-changes` + `dotnet test` — 201/201 including this WP's 5 new tests, api-client
regenerated). One pre-existing, unrelated finding: `format:check` was already red on this
branch before this session touched anything — `docs/project/backlog/README.md` has a
long-standing prettier drift (a big markdown table) untouched by this WP; fixed the same
class of drift in this file's own body (`*computed*``_computed_`) since this WP was
already editing it, left `README.md` alone as out of scope.
## Acceptance criteria ## Acceptance criteria
- [ ] A medewerker can view one aanvraag's detail and record a decision that advances its - [x] A medewerker can view one aanvraag's detail and record a decision that advances its
status. status.
- [ ] Illegal transitions are rejected server-side (tested). - [x] Illegal transitions are rejected server-side (tested).
- [ ] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back - [x] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back
in the queue. in the queue.
- [ ] `npm run ci` (behandelportal app) + `dotnet test` green. - [x] `npm run ci` (behandelportal app) + `dotnet test` green.
## Verification ## Verification
@@ -1165,6 +1165,71 @@ export class ApiClient {
return Promise.resolve<BeoordelingViewDto>(null as any); return Promise.resolve<BeoordelingViewDto>(null as any);
} }
/**
* @return OK
*/
besluit(id: string, body: RecordBesluitRequest): Promise<RecordBesluitResponse> {
let url_ = this.baseUrl + "/api/v1/beoordeling/{id}/besluit";
if (id === undefined || id === null)
throw new globalThis.Error("The parameter 'id' must be defined.");
url_ = url_.replace("{id}", encodeURIComponent("" + id));
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processBesluit(_response);
});
}
protected processBesluit(response: Response): Promise<RecordBesluitResponse> {
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 RecordBesluitResponse;
return result200;
});
} else if (status === 400) {
return response.text().then((_responseText) => {
let result400: any = null;
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Bad Request", status, _responseText, _headers, result400);
});
} 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 === 409) {
return response.text().then((_responseText) => {
let result409: any = null;
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Conflict", status, _responseText, _headers, result409);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<RecordBesluitResponse>(null as any);
}
/** /**
* @return No Content * @return No Content
*/ */
@@ -2248,6 +2313,15 @@ export interface PublishOrgTemplateResponse {
affectedUnsentBriefs?: number; affectedUnsentBriefs?: number;
} }
export interface RecordBesluitRequest {
besluit?: string | undefined;
toelichting?: string | undefined;
}
export interface RecordBesluitResponse {
status?: AanvraagStatusDto;
}
export interface ReferentieResponse { export interface ReferentieResponse {
referentie?: string | undefined; referentie?: string | undefined;
} }