From af8a01181955ec6c7653dd37c865379a542c89a9 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Mon, 3 Aug 2026 09:46:20 +0200 Subject: [PATCH] feat(behandelportal): WP-65b beoordeling besluit (decision write) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../behandeling/application/submit-besluit.ts | 16 + .../domain/besluit.machine.spec.ts | 68 +++++ .../app/behandeling/domain/besluit.machine.ts | 92 ++++++ .../infrastructure/besluit.adapter.ts | 18 ++ .../app/behandeling/ui/beoordeling.page.ts | 15 +- .../ui/besluit-form/besluit-form.component.ts | 140 +++++++++ .../ui/besluit-form/besluit-form.stories.ts | 37 +++ .../behandelportal/src/locale/messages.en.xlf | 48 +++ apps/behandelportal/src/locale/messages.xlf | 108 ++++++- backend/src/BigRegister.Api/Contracts/Dtos.cs | 9 + .../src/BigRegister.Api/Contracts/Mappers.cs | 18 +- .../BigRegister.Api/Data/ApplicationStore.cs | 50 +++ .../20260803070817_BesluitStatus.Designer.cs | 288 ++++++++++++++++++ .../20260803070817_BesluitStatus.cs | 38 +++ .../Migrations/AppDbContextModelSnapshot.cs | 6 + backend/src/BigRegister.Api/Program.cs | 34 +++ backend/swagger.json | 95 ++++++ .../BigRegister.Tests/BeoordelingTests.cs | 109 +++++++ .../WP-65-behandelportal-beoordeling.md | 60 +++- libs/shared/src/infrastructure/api-client.ts | 74 +++++ 20 files changed, 1303 insertions(+), 20 deletions(-) create mode 100644 apps/behandelportal/src/app/behandeling/application/submit-besluit.ts create mode 100644 apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts create mode 100644 apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts create mode 100644 apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts create mode 100644 apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts create mode 100644 apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.stories.ts create mode 100644 backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.Designer.cs create mode 100644 backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.cs diff --git a/apps/behandelportal/src/app/behandeling/application/submit-besluit.ts b/apps/behandelportal/src/app/behandeling/application/submit-besluit.ts new file mode 100644 index 0000000..d80a893 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/application/submit-besluit.ts @@ -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> => + runSubmit(() => adapter.besluit(id, data), SUBMIT_FAILED); +} diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts new file mode 100644 index 0000000..77a068a --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts @@ -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).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).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).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).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).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); + }); +}); diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts new file mode 100644 index 0000000..ef9a56c --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts @@ -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>; + +/** 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 { + 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); + } +} diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts new file mode 100644 index 0000000..0af191f --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts @@ -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 { + await this.client.besluit(id, { besluit: data.besluit, toelichting: data.toelichting }); + } +} diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts index 186901e..cf8971e 100644 --- a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts @@ -10,12 +10,13 @@ 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'; +import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.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. + * Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links + * here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) — + * the page never recomputes the lifecycle itself. On a recorded decision the form emits + * `decided`, and the page just reloads (the server is the authority on the new status). */ @Component({ selector: 'app-beoordeling-page', @@ -27,6 +28,7 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu DataBlockComponent, DataRowComponent, BeoordelingDocumentenComponent, + BesluitFormComponent, ...ASYNC, ], template: ` @@ -49,6 +51,11 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu + @if (v.canBesluiten) { +
+ +
+ } } diff --git a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts new file mode 100644 index 0000000..e26a30f --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts @@ -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') { + Het besluit is vastgelegd. + } @else { + Besluit vastleggen + +
+ + + + + + + + + @if (failedError()) { + Het vastleggen is niet gelukt: + {{ failedError() }} + } + + + {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} + +
+ } + `, +}) +export class BesluitFormComponent { + private submit = createSubmitBesluit(); + private store = createStore(initial, reduce); + + id = input.required(); + decided = output(); + + /** Optional seed so Storybook / tests can mount any state directly. */ + seed = input(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 }); + } + } +} diff --git a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.stories.ts b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.stories.ts new file mode 100644 index 0000000..21862b2 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.stories.ts @@ -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 = { + 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; + +// 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' } }, +}; diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf index 0626e77..a4dddc2 100644 --- a/apps/behandelportal/src/locale/messages.en.xlf +++ b/apps/behandelportal/src/locale/messages.en.xlf @@ -3020,6 +3020,54 @@ Opnieuw proberen Try again + + Kies een besluit. + Choose a decision. + + + Geef een toelichting. + Give an explanation. + + + Het besluit is vastgelegd. + The decision has been recorded. + + + Besluit vastleggen + Record a decision + + + Besluit + Decision + + + Toelichting + Explanation + + + Het vastleggen is niet gelukt: + Recording the decision failed: + + + Goedkeuren + Approve + + + Afwijzen + Reject + + + Meer informatie opvragen + Request more information + + + Besluit vastleggen + Record decision + + + Bezig met vastleggen… + Recording… + Er is geen stamdata om te beheren. There is no stamdata to manage. diff --git a/apps/behandelportal/src/locale/messages.xlf b/apps/behandelportal/src/locale/messages.xlf index f14724c..8fe4928 100644 --- a/apps/behandelportal/src/locale/messages.xlf +++ b/apps/behandelportal/src/locale/messages.xlf @@ -156,6 +156,20 @@ 47 + + Kies een besluit. + + apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts + 46 + + + + Geef een toelichting. + + apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts + 54 + + ingediend op @@ -181,35 +195,105 @@ Aanvraag apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts - 62 + 69 Aanvraaggegevens apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts - 63 + 70 Documenten apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts - 64 + 71 De aanvraag kon niet worden geladen. apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts - 65 + 72 Opnieuw proberen apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts - 66 + 73 + + + + Het besluit is vastgelegd. + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 35,37 + + + + Besluit vastleggen + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 37,39 + + + + Besluit + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 43 + + + + Toelichting + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 59,60 + + + + Het vastleggen is niet gelukt: + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 75,76 + + + + Goedkeuren + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 101 + + + + Afwijzen + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 102 + + + + Meer informatie opvragen + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 105 + + + + Besluit vastleggen + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 109 + + + + Bezig met vastleggen… + + apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts + 110 @@ -870,6 +954,20 @@ 107 + + Ja + + libs/shared/src/ui/radio-group/radio-group.component.ts + 12 + + + + Nee + + libs/shared/src/ui/radio-group/radio-group.component.ts + 13 + + Bezig met laden diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index cd2d2fb..9cda378 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -140,6 +140,15 @@ public sealed record BeoordelingViewDto( IReadOnlyList Documenten, 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 --- // Rich text is a serialisable node tree; the node union is flattened with a `Type` // discriminator + nullable fields, the same wire convention as AanvraagStatusDto. diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index dc26083..eb37220 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -38,17 +38,25 @@ public static class Mappers // Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports // Goedgekeurd once past the processing window, else In behandeling; a manual case - // stays In behandeling forever (awaits the 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). + // stays In behandeling until a behandelaar records a decision (WP-65b — before that + // WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure — + // testable by passing different `now` values without waiting for the wall clock. public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) { if (!a.Submitted) return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount); if (a.Reden is not null) 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) return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie); return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable); diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index d778e98..3a62ba1 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -13,6 +13,14 @@ namespace BigRegister.Api.Data; /// public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen } +/// +/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen +/// offers, each advancing and (via +/// ) the published +/// the FE renders. +/// +public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen } + /// /// 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, @@ -50,6 +58,17 @@ public sealed class Aanvraag /// is re-findable by identificatie == Referentie. Cleared by a future repair path; /// none exists yet (see openzaak-integration.md's "Write resilience" section). public string? ZgwError { get; set; } + + /// WP-65b: a behandelaar's recorded decision, if any. Non-null wins over the + /// auto-approve computation in — + /// "a recorded decision wins". Mutable across + /// (a behandelaar may decide again later); frozen once Goedgekeurd/Afgewezen (terminal, per + /// ). + public Besluit? BesluitStatus { get; set; } + + /// The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes + /// the published status's Reden), optional for Goedkeuren. + public string? BesluitToelichting { get; set; } } /// @@ -104,6 +123,17 @@ public static class ApplicationStore } } + /// Cross-owner single read (WP-65b) — the behandelaar decision endpoint's counterpart of + /// , same "any owner" shape as . + 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 /// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint. public static IReadOnlyList ListAll() @@ -224,4 +254,24 @@ public static class ApplicationStore db.SaveChanges(); } } + + /// Record a behandelaar's decision (WP-65b). The endpoint has already checked + /// against the + /// freshly-read status before calling this — cross-owner like , + /// 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). + 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; + } + } } diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.Designer.cs b/backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.Designer.cs new file mode 100644 index 0000000..cc430ab --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.Designer.cs @@ -0,0 +1,288 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("TEXT"); + + b.Property("AutoApprovable") + .HasColumnType("INTEGER"); + + b.Property("BesluitStatus") + .HasColumnType("INTEGER"); + + b.Property("BesluitToelichting") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Draft") + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Reden") + .HasColumnType("TEXT"); + + b.Property("Referentie") + .HasColumnType("TEXT"); + + b.Property("StepCount") + .HasColumnType("INTEGER"); + + b.Property("StepIndex") + .HasColumnType("INTEGER"); + + b.Property("Submitted") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ZaakUrl") + .HasColumnType("TEXT"); + + b.Property("ZgwError") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Resource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuthzAudit"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b => + { + b.Property("BriefId") + .HasColumnType("TEXT"); + + b.Property("ArchivedHtml") + .HasColumnType("TEXT"); + + b.Property("Beroep") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DrafterId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Placeholders") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sections") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SentOrgTemplateVersion") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubOrgId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("BriefId"); + + b.HasIndex("Owner") + .IsUnique(); + + b.ToTable("Briefs"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.HasKey("Key"); + + b.ToTable("FeatureFlags"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b => + { + b.Property("SubOrgId") + .HasColumnType("TEXT"); + + b.Property("Draft") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("History") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("SubOrgId"); + + b.ToTable("OrgTemplates"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b => + { + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DrcUrl") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Linked") + .HasColumnType("INTEGER"); + + b.Property("LocalId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("WizardId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DocumentId"); + + b.ToTable("Documents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.cs b/backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.cs new file mode 100644 index 0000000..ebf6a2d --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260803070817_BesluitStatus.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + /// + public partial class BesluitStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BesluitStatus", + table: "Applications", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "BesluitToelichting", + table: "Applications", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BesluitStatus", + table: "Applications"); + + migrationBuilder.DropColumn( + name: "BesluitToelichting", + table: "Applications"); + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs index 909dace..2db0a3c 100644 --- a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -25,6 +25,12 @@ namespace BigRegister.Api.Data.Migrations b.Property("AutoApprovable") .HasColumnType("INTEGER"); + b.Property("BesluitStatus") + .HasColumnType("INTEGER"); + + b.Property("BesluitToelichting") + .HasColumnType("TEXT"); + b.Property("CreatedAt") .HasColumnType("TEXT"); diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index ebfef8d..d6b4dc5 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -442,6 +442,40 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) .ProducesProblem(StatusCodes.Status403Forbidden) .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(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(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() +.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 // 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 diff --git a/backend/swagger.json b/backend/swagger.json index bbd6165..2464e6f 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -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}": { "delete": { "tags": [ @@ -2448,6 +2520,29 @@ }, "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": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/BeoordelingTests.cs b/backend/tests/BigRegister.Tests/BeoordelingTests.cs index a2eedd9..de65c67 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingTests.cs @@ -127,4 +127,113 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture req.Headers.Add("X-Rollen", "geen"); Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); } + + private Task 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())!; + 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())!; + 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())!; + 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())!; + 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())!; + 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); + } + } } diff --git a/docs/project/backlog/WP-65-behandelportal-beoordeling.md b/docs/project/backlog/WP-65-behandelportal-beoordeling.md index a2b5c9c..965cd22 100644 --- a/docs/project/backlog/WP-65-behandelportal-beoordeling.md +++ b/docs/project/backlog/WP-65-behandelportal-beoordeling.md @@ -1,6 +1,6 @@ # WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen -Status: in progress (65a — detail read — done; 65b — decision write — not started) +Status: done (65a — detail read — done; 65b — decision write — done) Phase: 11 — Behandelportal ## 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 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, 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 @@ -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 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 -- [ ] 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. -- [ ] Illegal transitions are rejected server-side (tested). -- [ ] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back +- [x] Illegal transitions are rejected server-side (tested). +- [x] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back in the queue. -- [ ] `npm run ci` (behandelportal app) + `dotnet test` green. +- [x] `npm run ci` (behandelportal app) + `dotnet test` green. ## Verification diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index f59f995..771985e 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -1165,6 +1165,71 @@ export class ApiClient { return Promise.resolve(null as any); } + /** + * @return OK + */ + besluit(id: string, body: RecordBesluitRequest): Promise { + 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 { + 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(null as any); + } + /** * @return No Content */ @@ -2248,6 +2313,15 @@ export interface PublishOrgTemplateResponse { affectedUnsentBriefs?: number; } +export interface RecordBesluitRequest { + besluit?: string | undefined; + toelichting?: string | undefined; +} + +export interface RecordBesluitResponse { + status?: AanvraagStatusDto; +} + export interface ReferentieResponse { referentie?: string | undefined; }