From 5f22156e6dd08f1fc63030eaec4cf0d179b9fd79 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 1 Aug 2026 09:15:18 +0200 Subject: [PATCH] feat(ownership): add take-ownership preflight endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables dry-run checking before committing to case adoption. The preflight shares the same side-effect-free checks (steps 1–3) as the real take-ownership handler, so it cannot drift from what will actually succeed. Returns the same status codes and error shapes as the real endpoint (200 with wouldSucceed:true, or 409/404/422 if it would fail). Portal renders a "Vooraf controleren" button for legacy cases, surfaced through the existing actions block pattern. Confirmed in smoke.sh with two cases: one where preflight predicts success (and writes nothing), one where it predicts a named invariant failure (matching what the real call reproduces). Co-Authored-By: Claude Haiku 4.5 --- .../Contracts/CaseDetailResponseFactory.cs | 3 +- .../New.Api/Contracts/WorklistContracts.cs | 1 + .../New.Api/Endpoints/OwnershipEndpoints.cs | 18 ++ .../Ownership/TakeOwnershipHandler.cs | 98 ++++++---- .../case-detail/case-actions/case-actions.css | 11 ++ .../case-actions/case-actions.html | 66 +++++-- .../case-actions/case-actions.spec.ts | 46 ++++- .../case-detail/case-actions/case-actions.ts | 39 +++- .../app/case-detail/case-detail.service.ts | 7 + .../src/app/case-detail/case-detail.types.ts | 16 ++ .../case-detail/case-detail/case-detail.css | 62 ++++++ .../case-detail/case-detail/case-detail.html | 180 ++++++++++-------- .../case-detail/case-detail/case-detail.ts | 8 +- .../edit-applicant-details.html | 8 +- .../edit-applicant-details.spec.ts | 20 +- .../edit-applicant-details.ts | 14 +- .../record-assessment-form.html | 15 +- .../record-assessment-form.spec.ts | 28 ++- .../record-assessment-form.ts | 20 +- scripts/smoke.sh | 15 ++ 20 files changed, 519 insertions(+), 156 deletions(-) diff --git a/new/src/New.Api/Contracts/CaseDetailResponseFactory.cs b/new/src/New.Api/Contracts/CaseDetailResponseFactory.cs index cc63f27..00c068b 100644 --- a/new/src/New.Api/Contracts/CaseDetailResponseFactory.cs +++ b/new/src/New.Api/Contracts/CaseDetailResponseFactory.cs @@ -45,7 +45,8 @@ internal static class CaseDetailResponseFactory private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new( EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"), RecordAssessment: new ActionLink("redirect", $"/legacy/aanvraag/{aanvraagId}/beoordeling"), - TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership")); + TakeOwnership: new ActionLink("transition", $"/api/worklist/legacy/{aanvraagId}/take-ownership"), + TakeOwnershipPreflight: new ActionLink("query", $"/api/worklist/legacy/{aanvraagId}/take-ownership/preflight")); private static CaseDetailActions BuildOwnedActions(Guid registrationApplicationId) => new( EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"), diff --git a/new/src/New.Api/Contracts/WorklistContracts.cs b/new/src/New.Api/Contracts/WorklistContracts.cs index 72e53cc..8a24256 100644 --- a/new/src/New.Api/Contracts/WorklistContracts.cs +++ b/new/src/New.Api/Contracts/WorklistContracts.cs @@ -39,6 +39,7 @@ public sealed record CaseDetailActions( ActionLink EditApplicantDetails, ActionLink RecordAssessment, ActionLink? TakeOwnership = null, + ActionLink? TakeOwnershipPreflight = null, ActionLink? ReleaseOwnership = null); public sealed record AddressResponse(string Street, string Number, string PostalCode, string City) diff --git a/new/src/New.Api/Endpoints/OwnershipEndpoints.cs b/new/src/New.Api/Endpoints/OwnershipEndpoints.cs index 7e1d2b3..fe9d764 100644 --- a/new/src/New.Api/Endpoints/OwnershipEndpoints.cs +++ b/new/src/New.Api/Endpoints/OwnershipEndpoints.cs @@ -8,6 +8,7 @@ public static class OwnershipEndpoints public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app) { app.MapPost("/api/worklist/legacy/{aanvraagId:int}/take-ownership", TakeOwnershipAsync); + app.MapGet("/api/worklist/legacy/{aanvraagId:int}/take-ownership/preflight", PreflightTakeOwnershipAsync); app.MapDelete("/api/worklist/owned/{registrationApplicationId:guid}/ownership", ReleaseOwnershipAsync); } @@ -27,6 +28,23 @@ public static class OwnershipEndpoints }; } + // Read-only "would this succeed" check - same result-kind switch as + // TakeOwnershipAsync, except Success reports intent rather than creation + // (200, not 201; nothing was written). + private static async Task PreflightTakeOwnershipAsync(int aanvraagId, TakeOwnershipHandler handler, CancellationToken ct) + { + var result = await handler.PreflightAsync(aanvraagId, ct); + + return result.Kind switch + { + TakeOwnershipResultKind.Success => Results.Ok(new { wouldSucceed = true }), + TakeOwnershipResultKind.AlreadyOwned => Results.Conflict(new MessageResponse("This aanvraag has already been taken into ownership.")), + TakeOwnershipResultKind.LegacyCaseNotFound => Results.NotFound(), + TakeOwnershipResultKind.MappingFailed => Results.UnprocessableEntity(new InvariantViolationResponse(result.Invariant!, result.Message!)), + _ => Results.Problem(statusCode: 500), + }; + } + private static async Task ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct) { var result = await handler.HandleAsync(registrationApplicationId, ct); diff --git a/new/src/New.Application/Ownership/TakeOwnershipHandler.cs b/new/src/New.Application/Ownership/TakeOwnershipHandler.cs index 40c121c..b2b1ff3 100644 --- a/new/src/New.Application/Ownership/TakeOwnershipHandler.cs +++ b/new/src/New.Application/Ownership/TakeOwnershipHandler.cs @@ -28,41 +28,13 @@ public sealed class TakeOwnershipHandler( public async Task HandleAsync(int aanvraagId, CancellationToken ct) { - // Step 1: guard against double adoption. Checked first and cheaply, - // before touching legacy or case-framework at all. - var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct); - if (existingOwnedId is not null) + var checkResult = await CheckAsync(aanvraagId, ct); + if (checkResult.Result.Kind != TakeOwnershipResultKind.Success || checkResult.Application is null) { - return TakeOwnershipResult.AlreadyOwned; + return checkResult.Result; } - // Step 2: read the legacy case (seam A). - // Step 3: map it to a RegistrationApplication. The mapper calls the - // domain's normal validating constructors/factories, so any domain - // exception here means the legacy data doesn't satisfy an invariant - // the owned side requires. That must fail as a 422 naming the - // failing invariant, and - critically - NOTHING is written anywhere: - // no case-framework call, no persistence. FetchAndMapAsync lets the - // domain exception surface as a thrown DomainInvariantViolationException, - // which we catch here and translate, rather than swallowing it inside - // the gateway - that keeps "nothing written on failure" trivially true, - // since we simply haven't called anything else yet. - LegacyFetchAndMapResult fetchResult; - try - { - fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct); - } - catch (DomainInvariantViolationException ex) - { - return TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message); - } - - if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null) - { - return TakeOwnershipResult.LegacyCaseNotFound; - } - - var application = fetchResult.Application; + var application = checkResult.Application; // Step 4: THEN create the case-framework case - done before the local // transaction because it's an external system with no distributed @@ -111,4 +83,66 @@ public sealed class TakeOwnershipHandler( return TakeOwnershipResult.Success(application.RegistrationApplicationId); } + + /// + /// Read-only "would this succeed" check: runs the same dedupe-lookup and + /// fetch-and-map steps HandleAsync would, then stops - it never reaches + /// case-framework, persistence, or the legacy flag flip. A `Success` + /// result here has a null RegistrationApplicationId, since nothing was + /// actually created. Used by the take-ownership/preflight endpoint so a + /// caller can compare new-vs-legacy behaviour before committing to the + /// real cutover. + /// + public async Task PreflightAsync(int aanvraagId, CancellationToken ct) + { + var checkResult = await CheckAsync(aanvraagId, ct); + return checkResult.Result; + } + + // Steps 1-3 of HandleAsync, factored out so PreflightAsync can run the + // exact same side-effect-free checks without duplicating them. + private async Task CheckAsync(int aanvraagId, CancellationToken ct) + { + // Step 1: guard against double adoption. Checked first and cheaply, + // before touching legacy or case-framework at all. + var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct); + if (existingOwnedId is not null) + { + return new CheckOutcome(TakeOwnershipResult.AlreadyOwned, Application: null); + } + + // Step 2: read the legacy case (seam A). + // Step 3: map it to a RegistrationApplication. The mapper calls the + // domain's normal validating constructors/factories, so any domain + // exception here means the legacy data doesn't satisfy an invariant + // the owned side requires. That must fail as a 422 naming the + // failing invariant, and - critically - NOTHING is written anywhere: + // no case-framework call, no persistence. FetchAndMapAsync lets the + // domain exception surface as a thrown DomainInvariantViolationException, + // which we catch here and translate, rather than swallowing it inside + // the gateway - that keeps "nothing written on failure" trivially true, + // since we simply haven't called anything else yet. + LegacyFetchAndMapResult fetchResult; + try + { + fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct); + } + catch (DomainInvariantViolationException ex) + { + return new CheckOutcome(TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message), Application: null); + } + + if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null) + { + return new CheckOutcome(TakeOwnershipResult.LegacyCaseNotFound, Application: null); + } + + // RegistrationApplicationId stays null here - nothing has been created + // yet. HandleAsync reads the id off the Application itself once it + // proceeds past this point and actually persists it. + var successResult = new TakeOwnershipResult(TakeOwnershipResultKind.Success, RegistrationApplicationId: null); + return new CheckOutcome(successResult, fetchResult.Application); + } + + private sealed record CheckOutcome(TakeOwnershipResult Result, RegistrationApplication? Application); } diff --git a/portal-frontend/src/app/case-detail/case-actions/case-actions.css b/portal-frontend/src/app/case-detail/case-actions/case-actions.css index e69de29..9f71532 100644 --- a/portal-frontend/src/app/case-detail/case-actions/case-actions.css +++ b/portal-frontend/src/app/case-detail/case-actions/case-actions.css @@ -0,0 +1,11 @@ +.case-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--rhs-space-3); +} + +.preflight-result { + font-size: 0.9em; + color: var(--rhs-color-text-muted, inherit); +} diff --git a/portal-frontend/src/app/case-detail/case-actions/case-actions.html b/portal-frontend/src/app/case-detail/case-actions/case-actions.html index fcaa4bc..59f4ee3 100644 --- a/portal-frontend/src/app/case-detail/case-actions/case-actions.html +++ b/portal-frontend/src/app/case-detail/case-actions/case-actions.html @@ -1,21 +1,49 @@ -@if (actions().recordAssessment.mode === 'redirect') { - Beoordeling vastleggen (in legacy) -} @else { - -} +
+ @if (actions().recordAssessment.mode === 'redirect') { + + Beoordeling vastleggen (in legacy) + + } @else { + + } -@if (actions().takeOwnership; as takeOwnership) { - -} + @if (actions().takeOwnership; as takeOwnership) { + @if (actions().takeOwnershipPreflight; as preflight) { + + @if (preflightResult(); as result) { +

{{ result }}

+ } + } + + } -@if (actions().releaseOwnership; as releaseOwnership) { - -} + @if (actions().releaseOwnership; as releaseOwnership) { + + } +
diff --git a/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts b/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts index 82c076c..155f1a8 100644 --- a/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts +++ b/portal-frontend/src/app/case-detail/case-actions/case-actions.spec.ts @@ -1,4 +1,7 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; import { CaseDetailService } from '../case-detail.service'; import { CaseDetailActions } from '../case-detail.types'; @@ -6,6 +9,7 @@ import { CaseActions } from './case-actions'; describe('CaseActions', () => { let fixture: ComponentFixture; + let preflightTakeOwnership: ReturnType; function render(actions: CaseDetailActions): HTMLElement { fixture = TestBed.createComponent(CaseActions); @@ -15,9 +19,15 @@ describe('CaseActions', () => { } beforeEach(async () => { + preflightTakeOwnership = vi.fn(); await TestBed.configureTestingModule({ imports: [CaseActions], - providers: [{ provide: CaseDetailService, useValue: { recordAssessment: () => { throw new Error('not stubbed'); } } }], + providers: [ + { + provide: CaseDetailService, + useValue: { recordAssessment: () => { throw new Error('not stubbed'); }, preflightTakeOwnership }, + }, + ], }).compileComponents(); }); @@ -25,6 +35,7 @@ describe('CaseActions', () => { editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' }, recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' }, takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' }, + takeOwnershipPreflight: { mode: 'query', href: '/api/worklist/legacy/1001/take-ownership/preflight' }, }; const ownedActions: CaseDetailActions = { @@ -52,4 +63,37 @@ describe('CaseActions', () => { expect(el.querySelector('[data-testid="release-ownership-button"]')).toBeTruthy(); expect(el.querySelector('[data-testid="take-ownership-button"]')).toBeFalsy(); }); + + it('given no takeOwnershipPreflight link (owned case), hides the preflight check button', () => { + const el = render(ownedActions); + + expect(el.querySelector('[data-testid="take-ownership-preflight-button"]')).toBeFalsy(); + }); + + it('checking take-ownership beforehand calls preflightTakeOwnership with the link href and shows a would-succeed result', () => { + preflightTakeOwnership.mockReturnValue(of({ wouldSucceed: true })); + const el = render(legacyActions); + + el.querySelector('[data-testid="take-ownership-preflight-button"]')!.click(); + fixture.detectChanges(); + + expect(preflightTakeOwnership).toHaveBeenCalledWith('/api/worklist/legacy/1001/take-ownership/preflight'); + expect(el.querySelector('[data-testid="take-ownership-preflight-result"]')?.textContent).toContain('Zou slagen'); + expect(el.querySelector('[data-testid="take-ownership-button"]')!.disabled).toBe(false); + }); + + it('checking take-ownership beforehand shows the named invariant on a 422, without disabling the real action', () => { + preflightTakeOwnership.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Bsn.ElevenProof', message: 'BSN failed the eleven-proof check.' } })), + ); + const el = render(legacyActions); + + el.querySelector('[data-testid="take-ownership-preflight-button"]')!.click(); + fixture.detectChanges(); + + const result = el.querySelector('[data-testid="take-ownership-preflight-result"]')?.textContent; + expect(result).toContain('Bsn.ElevenProof'); + expect(result).toContain('BSN failed the eleven-proof check.'); + expect(el.querySelector('[data-testid="take-ownership-button"]')!.disabled).toBe(false); + }); }); diff --git a/portal-frontend/src/app/case-detail/case-actions/case-actions.ts b/portal-frontend/src/app/case-detail/case-actions/case-actions.ts index 8925f92..9a780c7 100644 --- a/portal-frontend/src/app/case-detail/case-actions/case-actions.ts +++ b/portal-frontend/src/app/case-detail/case-actions/case-actions.ts @@ -1,5 +1,8 @@ -import { Component, input, output } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, inject, input, output, signal } from '@angular/core'; +import { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types'; +import { CaseDetailService } from '../case-detail.service'; import { ActionLink, CaseDetailActions } from '../case-detail.types'; import { RecordAssessmentForm } from '../record-assessment-form/record-assessment-form'; @@ -10,9 +13,43 @@ import { RecordAssessmentForm } from '../record-assessment-form/record-assessmen styleUrl: './case-actions.css', }) export class CaseActions { + private readonly caseDetailService = inject(CaseDetailService); + readonly actions = input.required(); readonly takeOwnershipRequested = output(); readonly releaseOwnershipRequested = output(); readonly savedRequested = output(); + + // Advisory only - never disables the real take-ownership button. Legacy + // data can change between a check and the real call, so that call stays + // the source of truth regardless of what this reports. + readonly preflightChecking = signal(false); + readonly preflightResult = signal(null); + + checkTakeOwnership(link: ActionLink): void { + this.preflightChecking.set(true); + this.preflightResult.set(null); + this.caseDetailService.preflightTakeOwnership(link.href).subscribe({ + next: () => { + this.preflightChecking.set(false); + this.preflightResult.set('Zou slagen: dit dossier kan in eigen beheer worden genomen.'); + }, + error: (error: HttpErrorResponse) => { + this.preflightChecking.set(false); + this.preflightResult.set(`Zou mislukken — ${this.describePreflightError(error)}`); + }, + }); + } + + private describePreflightError(error: HttpErrorResponse): string { + if (error.status === 422) { + const body = error.error as InvariantViolationResponse; + return `${body.invariant}: ${body.message}`; + } + if (error.status === 409) { + return (error.error as MessageResponse).message; + } + return 'Onbekende fout.'; + } } diff --git a/portal-frontend/src/app/case-detail/case-detail.service.ts b/portal-frontend/src/app/case-detail/case-detail.service.ts index ce4b2f5..7fb3bf6 100644 --- a/portal-frontend/src/app/case-detail/case-detail.service.ts +++ b/portal-frontend/src/app/case-detail/case-detail.service.ts @@ -7,6 +7,7 @@ import { CaseDetail, RecordAssessmentRequest, RecordAssessmentResult, + TakeOwnershipPreflightResult, TakeOwnershipResult, } from './case-detail.types'; @@ -33,6 +34,12 @@ export class CaseDetailService { return this.http.post(href, null); } + // Read-only: reports what takeOwnership would do right now, without + // calling it. A 4xx (409/404/422) means it would fail the same way. + preflightTakeOwnership(href: string): Observable { + return this.http.get(href); + } + releaseOwnership(href: string): Observable { return this.http.delete(href); } diff --git a/portal-frontend/src/app/case-detail/case-detail.types.ts b/portal-frontend/src/app/case-detail/case-detail.types.ts index 677d680..7f5f931 100644 --- a/portal-frontend/src/app/case-detail/case-detail.types.ts +++ b/portal-frontend/src/app/case-detail/case-detail.types.ts @@ -11,6 +11,7 @@ export interface CaseDetailActions { editApplicantDetails: ActionLink; recordAssessment: ActionLink; takeOwnership?: ActionLink; + takeOwnershipPreflight?: ActionLink; releaseOwnership?: ActionLink; } @@ -61,6 +62,16 @@ export interface ApplicantDetailsRequest { preferredChannel: string; } +/** Maps a `seams` entry to which system it came from, for the provenance accent on info cards. */ +export function sourceAccentClass(source: string | null): 'card--source-legacy' | 'card--source-owned' { + return source === 'legacy-backend' ? 'card--source-legacy' : 'card--source-owned'; +} + +/** The human-facing case reference shown in headers/breadcrumbs. */ +export function referenceOf(detail: CaseDetail): string { + return detail.origin === 'Legacy' ? `A-${detail.legacyAanvraagId}` : detail.registrationApplicationId!; +} + export function toApplicantDetailsRequest(detail: CaseDetail): ApplicantDetailsRequest { return { surname: detail.surname, @@ -87,3 +98,8 @@ export interface RecordAssessmentResult { export interface TakeOwnershipResult { registrationApplicationId: string; } + +/** 200 body from GET .../take-ownership/preflight - a 4xx means it wouldn't. */ +export interface TakeOwnershipPreflightResult { + wouldSucceed: true; +} diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.css b/portal-frontend/src/app/case-detail/case-detail/case-detail.css index e69de29..6ccb5a0 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.css +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.css @@ -0,0 +1,62 @@ +.breadcrumb { + font-size: 0.875rem; + color: var(--rhs-ink-muted); + margin-bottom: var(--rhs-space-3); +} + +.breadcrumb a { + color: var(--rhs-primary-dark); +} + +.case-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--rhs-space-3); + margin-bottom: var(--rhs-space-5); +} + +.case-header h2 { + margin-bottom: 0; +} + +.case-header__ref { + color: var(--rhs-ink-muted); + margin: var(--rhs-space-1) 0 0; +} + +.case-header__badges { + display: flex; + gap: var(--rhs-space-2); + flex-shrink: 0; +} + +.case-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr); + gap: var(--rhs-space-4); + align-items: start; +} + +.card--actions { + box-shadow: 0 1px 3px rgb(0 0 0 / 8%); +} + +.card--diagnostic { + background: var(--rhs-surface); +} + +.card--diagnostic .seam-note { + font-size: 0.875rem; + color: var(--rhs-ink-muted); +} + +.loading { + color: var(--rhs-ink-muted); +} + +@media (max-width: 45rem) { + .case-grid { + grid-template-columns: 1fr; + } +} diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.html b/portal-frontend/src/app/case-detail/case-detail/case-detail.html index b0eaec3..df6b732 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.html +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.html @@ -1,88 +1,114 @@ @if (detail(); as d) { -

{{ d.surname }}, {{ d.initials }} ({{ d.origin }})

+ -
-

Aanvrager

-
-
BSN
-
{{ d.bsn }}
-
E-mail
-
{{ d.email ?? 'n.v.t.' }}
-
Telefoon
-
{{ d.phone ?? 'n.v.t.' }}
-
Voorkeurskanaal
-
{{ d.preferredChannel }}
- @if (d.address; as address) { -
Adres
-
{{ address.street }} {{ address.number }}, {{ address.postalCode }} {{ address.city }}
+
+
+

{{ d.surname }}, {{ d.initials }}

+

{{ referenceOf(d) }}

+
+
+ {{ d.origin }} + @if (d.processStatus) { + {{ d.processStatus }} } -
-
+ + -
-

Diploma

-
-
Code
-
{{ d.diplomaCode }}
-
Land van uitgifte
-
{{ d.diplomaCountryOfIssue }}
-
Uitgegeven op
-
{{ d.diplomaIssuedOn }}
-
Ontvangen op
-
{{ d.receivedOn }}
-
Processtatus
-
{{ d.processStatus ?? 'n.v.t.' }}
-
-
+
+
+
+

Aanvrager

+

bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}

+
+
BSN
+
{{ d.bsn }}
+
E-mail
+
{{ d.email ?? 'n.v.t.' }}
+
Telefoon
+
{{ d.phone ?? 'n.v.t.' }}
+
Voorkeurskanaal
+
{{ d.preferredChannel }}
+ @if (d.address; as address) { +
Adres
+
{{ address.street }} {{ address.number }}, {{ address.postalCode }} {{ address.city }}
+ } +
+
- @if (d.assessment; as assessment) { -
-

Beoordeling

-
-
Uitkomst
-
{{ assessment.outcome }}
-
Motivatie
-
{{ assessment.motivation }}
-
Gecontroleerde stukken
-
{{ assessment.verifiedItems.join(', ') || 'geen' }}
-
Beslist op
-
{{ assessment.decidedOn }}
-
-
- } +
+

Diploma

+

bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}

+
+
Code
+
{{ d.diplomaCode }}
+
Land van uitgifte
+
{{ d.diplomaCountryOfIssue }}
+
Uitgegeven op
+
{{ d.diplomaIssuedOn }}
+
Ontvangen op
+
{{ d.receivedOn }}
+
Processtatus
+
{{ d.processStatus ?? 'n.v.t.' }}
+
+
-
-

Seams

-

Toont welk backend elk onderdeel van deze pagina levert.

-
- @for (seam of d.seams | keyvalue; track seam.key) { -
{{ seam.key }}
-
{{ seam.value ?? 'n.v.t.' }}
+ @if (d.assessment; as assessment) { +
+

Beoordeling

+

bron: {{ d.seams['procestijdlijn'] ?? 'onbekend' }}

+
+
Uitkomst
+
{{ assessment.outcome }}
+
Motivatie
+
{{ assessment.motivation }}
+
Gecontroleerde stukken
+
{{ assessment.verifiedItems.join(', ') || 'geen' }}
+
Beslist op
+
{{ assessment.decidedOn }}
+
+
} -
-
-
-

Gegevens wijzigen

- -
+
+

Gegevens wijzigen

+ +
+
-
-

Acties

- @if (banner(); as message) { - - } - -
+
+
+

Acties

+ @if (banner(); as message) { + + } + +
+ +
+

Herkomst

+

Toont welk backend elk onderdeel van deze pagina levert.

+
+ @for (seam of d.seams | keyvalue; track seam.key) { +
{{ seam.key }}
+
{{ seam.value ?? 'n.v.t.' }}
+ } +
+
+
+
} @else { -

Laden...

+

Laden...

} diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.ts b/portal-frontend/src/app/case-detail/case-detail/case-detail.ts index cf09f5d..e5aad0d 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.ts +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.ts @@ -1,17 +1,17 @@ import { HttpErrorResponse } from '@angular/common/http'; import { KeyValuePipe } from '@angular/common'; import { Component, inject, signal } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types'; import { CaseActions } from '../case-actions/case-actions'; import { CaseDetailService } from '../case-detail.service'; -import { ActionLink, CaseDetail, toApplicantDetailsRequest } from '../case-detail.types'; +import { ActionLink, CaseDetail, referenceOf, sourceAccentClass, toApplicantDetailsRequest } from '../case-detail.types'; import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details'; @Component({ selector: 'app-case-detail', - imports: [CaseActions, EditApplicantDetails, KeyValuePipe], + imports: [CaseActions, EditApplicantDetails, KeyValuePipe, RouterLink], templateUrl: './case-detail.html', styleUrl: './case-detail.css', }) @@ -23,6 +23,8 @@ export class CaseDetailPage { readonly detail = signal(null); readonly banner = signal(null); readonly toApplicantDetailsRequest = toApplicantDetailsRequest; + readonly sourceAccentClass = sourceAccentClass; + readonly referenceOf = referenceOf; constructor() { this.route.paramMap.subscribe((params) => { diff --git a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.html b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.html index 724a7eb..0136253 100644 --- a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.html +++ b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.html @@ -1,12 +1,12 @@
@if (isWriteThrough) { -

+

} @if (banner) { - + } - +
diff --git a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.spec.ts b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.spec.ts index 141c19f..c441d89 100644 --- a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.spec.ts +++ b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.spec.ts @@ -1,6 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; +import { Subject, of, throwError } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CaseDetailService } from '../case-detail.service'; @@ -68,6 +68,24 @@ describe('EditApplicantDetails', () => { expect(savedSpy).toHaveBeenCalled(); }); + it('disables the submit button while the request is in flight', async () => { + const subject = new Subject(); + updateDetails.mockReturnValue(subject); + const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' }); + const button = el.querySelector('[data-testid="save-details-submit"]')!; + + submitForm(el); + expect(button.disabled).toBe(true); + expect(button.textContent).toContain('Bezig met opslaan'); + + subject.next(); + subject.complete(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(button.disabled).toBe(false); + }); + it('maps a 400 ErrorsResponse to per-field inline errors', () => { updateDetails.mockReturnValue( throwError( diff --git a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.ts b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.ts index 61ee218..3fa1c04 100644 --- a/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.ts +++ b/portal-frontend/src/app/case-detail/edit-applicant-details/edit-applicant-details.ts @@ -1,5 +1,5 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ErrorsResponse, InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types'; @@ -32,6 +32,7 @@ export class EditApplicantDetails implements OnInit { fieldErrors: Record = {}; banner: string | null = null; + readonly saving = signal(false); get isWriteThrough(): boolean { return this.actionLink.mode === 'writeThrough'; @@ -52,6 +53,7 @@ export class EditApplicantDetails implements OnInit { submit(): void { this.fieldErrors = {}; this.banner = null; + this.saving.set(true); const body: ApplicantDetailsRequest = { surname: this.surname, @@ -63,8 +65,14 @@ export class EditApplicantDetails implements OnInit { }; this.caseDetailService.updateDetails(this.actionLink.href, body).subscribe({ - next: () => this.saved.emit(), - error: (error: HttpErrorResponse) => this.handleError(error), + next: () => { + this.saving.set(false); + this.saved.emit(); + }, + error: (error: HttpErrorResponse) => { + this.saving.set(false); + this.handleError(error); + }, }); } diff --git a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.html b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.html index b2e8ed0..b375ce9 100644 --- a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.html +++ b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.html @@ -1,14 +1,15 @@ -@if (recorded) { -

+@if (recorded()) { +

} @else {
- @if (banner) { - + @if (banner()) { + } - +
} diff --git a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.spec.ts b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.spec.ts index 20dd738..ea3726d 100644 --- a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.spec.ts +++ b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.spec.ts @@ -1,6 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; +import { Subject, of, throwError } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CaseDetailService } from '../case-detail.service'; @@ -70,6 +70,32 @@ describe('RecordAssessmentForm', () => { expect(el.querySelector('[data-testid="assessment-banner"]')).toBeFalsy(); }); + it('disables the submit button while the request is in flight', async () => { + const subject = new Subject<{ closurePending: boolean }>(); + recordAssessment.mockReturnValue(subject); + const el = await render(); + + const input = el.querySelector('[name="verifiedItems"]')!; + input.value = 'document'; + input.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + await fixture.whenStable(); + + el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + fixture.detectChanges(); + + const button = el.querySelector('[data-testid="record-assessment-submit"]')!; + expect(button.disabled).toBe(true); + expect(button.textContent).toContain('Bezig met vastleggen'); + + subject.next({ closurePending: false }); + subject.complete(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeTruthy(); + }); + it('shows a 422 InvariantViolationResponse as a banner, not a curated message', async () => { recordAssessment.mockReturnValue( throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Assessment.MotivationTooShort', message: 'Motivation is too short.' } })), diff --git a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.ts b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.ts index 21a21e3..d8777a7 100644 --- a/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.ts +++ b/portal-frontend/src/app/case-detail/record-assessment-form/record-assessment-form.ts @@ -1,5 +1,5 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; +import { Component, EventEmitter, Input, Output, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { InvariantViolationResponse } from '../../shared/api-error.types'; @@ -24,12 +24,14 @@ export class RecordAssessmentForm { rejectionCategory = ''; motivation = ''; - banner: string | null = null; - recorded = false; - closurePending = false; + readonly banner = signal(null); + readonly recorded = signal(false); + readonly closurePending = signal(false); + readonly saving = signal(false); submit(): void { - this.banner = null; + this.banner.set(null); + this.saving.set(true); const verifiedItems = this.verifiedItemsText .split(',') @@ -46,13 +48,15 @@ export class RecordAssessmentForm { this.caseDetailService.recordAssessment(this.actionLink.href, body).subscribe({ next: (result) => { - this.recorded = true; - this.closurePending = result.closurePending; + this.saving.set(false); + this.recorded.set(true); + this.closurePending.set(result.closurePending); this.saved.emit(); }, error: (error: HttpErrorResponse) => { + this.saving.set(false); const invariant = error.error as InvariantViolationResponse; - this.banner = `${invariant.invariant}: ${invariant.message}`; + this.banner.set(`${invariant.invariant}: ${invariant.message}`); }, }); } diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 6be90d1..39449f8 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -92,6 +92,21 @@ ERRORS=$(curl -sS -X PUT "$BASE/api/worklist/legacy/1001/details" \ ERROR_COUNT=$(echo "$ERRORS" | json_field "['errors'].__len__()" 2>/dev/null || echo "$ERRORS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)['errors']))") check_status "3-field-invalid write-through returns 3 mapped errors" "3" "$ERROR_COUNT" +echo "== Write path 3 preflight: shadow check before take ownership ==" + +PREFLIGHT_1004=$(curl -sS -o /tmp/preflight_1004.json -w '%{http_code}' "$BASE/api/worklist/legacy/1004/take-ownership/preflight") +check_status "preflight predicts A-1004 would adopt cleanly" "200" "$PREFLIGHT_1004" +WOULD_SUCCEED=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1004.json'))['wouldSucceed'])") +check_status "preflight body reports wouldSucceed" "True" "$WOULD_SUCCEED" + +SEAM_1004_AFTER_PREFLIGHT=$(curl -sS "$BASE/api/worklist/legacy/1004" | json_field "['seams']['aanvrager']") +check_status "preflight on A-1004 wrote nothing (still legacy-backend)" "legacy-backend" "$SEAM_1004_AFTER_PREFLIGHT" + +PREFLIGHT_1005=$(curl -sS -o /tmp/preflight_1005.json -w '%{http_code}' "$BASE/api/worklist/legacy/1005/take-ownership/preflight") +check_status "preflight predicts A-1005 would fail adoption" "422" "$PREFLIGHT_1005" +PREFLIGHT_INVARIANT=$(python3 -c "import json; print(json.load(open('/tmp/preflight_1005.json'))['invariant'])") +check_status "preflight names the invariant the real call below also fails on" "Bsn.ElevenProof" "$PREFLIGHT_INVARIANT" + echo "== Write path 3: take ownership ==" TAKE=$(curl -sS -o /tmp/take_1002.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/1002/take-ownership")