feat(ownership): add take-ownership preflight endpoint

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 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-01 09:15:18 +02:00
co-authored by Claude Haiku 4.5
parent 19c010653a
commit 5f22156e6d
20 changed files with 519 additions and 156 deletions
@@ -45,7 +45,8 @@ internal static class CaseDetailResponseFactory
private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new( private static CaseDetailActions BuildLegacyActions(int aanvraagId) => new(
EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"), EditApplicantDetails: new ActionLink("writeThrough", $"/api/worklist/legacy/{aanvraagId}/details"),
RecordAssessment: new ActionLink("redirect", $"/legacy/aanvraag/{aanvraagId}/beoordeling"), 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( private static CaseDetailActions BuildOwnedActions(Guid registrationApplicationId) => new(
EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"), EditApplicantDetails: new ActionLink("owned", $"/api/worklist/owned/{registrationApplicationId}/details"),
@@ -39,6 +39,7 @@ public sealed record CaseDetailActions(
ActionLink EditApplicantDetails, ActionLink EditApplicantDetails,
ActionLink RecordAssessment, ActionLink RecordAssessment,
ActionLink? TakeOwnership = null, ActionLink? TakeOwnership = null,
ActionLink? TakeOwnershipPreflight = null,
ActionLink? ReleaseOwnership = null); ActionLink? ReleaseOwnership = null);
public sealed record AddressResponse(string Street, string Number, string PostalCode, string City) public sealed record AddressResponse(string Street, string Number, string PostalCode, string City)
@@ -8,6 +8,7 @@ public static class OwnershipEndpoints
public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app) public static void MapOwnershipEndpoints(this IEndpointRouteBuilder app)
{ {
app.MapPost("/api/worklist/legacy/{aanvraagId:int}/take-ownership", TakeOwnershipAsync); 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); 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<IResult> 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<IResult> ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct) private static async Task<IResult> ReleaseOwnershipAsync(Guid registrationApplicationId, ReleaseOwnershipHandler handler, CancellationToken ct)
{ {
var result = await handler.HandleAsync(registrationApplicationId, ct); var result = await handler.HandleAsync(registrationApplicationId, ct);
@@ -28,41 +28,13 @@ public sealed class TakeOwnershipHandler(
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct) public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
{ {
// Step 1: guard against double adoption. Checked first and cheaply, var checkResult = await CheckAsync(aanvraagId, ct);
// before touching legacy or case-framework at all. if (checkResult.Result.Kind != TakeOwnershipResultKind.Success || checkResult.Application is null)
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
if (existingOwnedId is not null)
{ {
return TakeOwnershipResult.AlreadyOwned; return checkResult.Result;
} }
// Step 2: read the legacy case (seam A). var application = checkResult.Application;
// 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;
// Step 4: THEN create the case-framework case - done before the local // Step 4: THEN create the case-framework case - done before the local
// transaction because it's an external system with no distributed // transaction because it's an external system with no distributed
@@ -111,4 +83,66 @@ public sealed class TakeOwnershipHandler(
return TakeOwnershipResult.Success(application.RegistrationApplicationId); return TakeOwnershipResult.Success(application.RegistrationApplicationId);
} }
/// <summary>
/// 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.
/// </summary>
public async Task<TakeOwnershipResult> 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<CheckOutcome> 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);
} }
@@ -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);
}
@@ -1,21 +1,49 @@
@if (actions().recordAssessment.mode === 'redirect') { <div class="case-actions">
<a data-testid="record-assessment-link" [href]="actions().recordAssessment.href">Beoordeling vastleggen (in legacy)</a> @if (actions().recordAssessment.mode === 'redirect') {
} @else { <a class="btn btn--secondary" data-testid="record-assessment-link" [href]="actions().recordAssessment.href">
Beoordeling vastleggen (in legacy) <span aria-hidden="true"></span>
</a>
} @else {
<app-record-assessment-form <app-record-assessment-form
data-testid="record-assessment-form" data-testid="record-assessment-form"
[actionLink]="actions().recordAssessment" [actionLink]="actions().recordAssessment"
(saved)="savedRequested.emit()" (saved)="savedRequested.emit()"
/> />
} }
@if (actions().takeOwnership; as takeOwnership) { @if (actions().takeOwnership; as takeOwnership) {
<button type="button" data-testid="take-ownership-button" (click)="takeOwnershipRequested.emit(takeOwnership)"> @if (actions().takeOwnershipPreflight; as preflight) {
<button
type="button"
class="btn btn--secondary"
data-testid="take-ownership-preflight-button"
[disabled]="preflightChecking()"
(click)="checkTakeOwnership(preflight)"
>
Vooraf controleren
</button>
@if (preflightResult(); as result) {
<p class="preflight-result" data-testid="take-ownership-preflight-result">{{ result }}</p>
}
}
<button
type="button"
class="btn btn--primary"
data-testid="take-ownership-button"
(click)="takeOwnershipRequested.emit(takeOwnership)"
>
In eigen beheer nemen In eigen beheer nemen
</button> </button>
} }
@if (actions().releaseOwnership; as releaseOwnership) { @if (actions().releaseOwnership; as releaseOwnership) {
<button type="button" data-testid="release-ownership-button" (click)="releaseOwnershipRequested.emit(releaseOwnership)"> <button
type="button"
class="btn btn--danger-outline"
data-testid="release-ownership-button"
(click)="releaseOwnershipRequested.emit(releaseOwnership)"
>
Eigenaarschap teruggeven Eigenaarschap teruggeven
</button> </button>
} }
</div>
@@ -1,4 +1,7 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service'; import { CaseDetailService } from '../case-detail.service';
import { CaseDetailActions } from '../case-detail.types'; import { CaseDetailActions } from '../case-detail.types';
@@ -6,6 +9,7 @@ import { CaseActions } from './case-actions';
describe('CaseActions', () => { describe('CaseActions', () => {
let fixture: ComponentFixture<CaseActions>; let fixture: ComponentFixture<CaseActions>;
let preflightTakeOwnership: ReturnType<typeof vi.fn>;
function render(actions: CaseDetailActions): HTMLElement { function render(actions: CaseDetailActions): HTMLElement {
fixture = TestBed.createComponent(CaseActions); fixture = TestBed.createComponent(CaseActions);
@@ -15,9 +19,15 @@ describe('CaseActions', () => {
} }
beforeEach(async () => { beforeEach(async () => {
preflightTakeOwnership = vi.fn();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CaseActions], imports: [CaseActions],
providers: [{ provide: CaseDetailService, useValue: { recordAssessment: () => { throw new Error('not stubbed'); } } }], providers: [
{
provide: CaseDetailService,
useValue: { recordAssessment: () => { throw new Error('not stubbed'); }, preflightTakeOwnership },
},
],
}).compileComponents(); }).compileComponents();
}); });
@@ -25,6 +35,7 @@ describe('CaseActions', () => {
editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' }, editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' },
recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' }, recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' },
takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' }, takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' },
takeOwnershipPreflight: { mode: 'query', href: '/api/worklist/legacy/1001/take-ownership/preflight' },
}; };
const ownedActions: CaseDetailActions = { const ownedActions: CaseDetailActions = {
@@ -52,4 +63,37 @@ describe('CaseActions', () => {
expect(el.querySelector('[data-testid="release-ownership-button"]')).toBeTruthy(); expect(el.querySelector('[data-testid="release-ownership-button"]')).toBeTruthy();
expect(el.querySelector('[data-testid="take-ownership-button"]')).toBeFalsy(); 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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-testid="take-ownership-button"]')!.disabled).toBe(false);
});
}); });
@@ -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 { ActionLink, CaseDetailActions } from '../case-detail.types';
import { RecordAssessmentForm } from '../record-assessment-form/record-assessment-form'; 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', styleUrl: './case-actions.css',
}) })
export class CaseActions { export class CaseActions {
private readonly caseDetailService = inject(CaseDetailService);
readonly actions = input.required<CaseDetailActions>(); readonly actions = input.required<CaseDetailActions>();
readonly takeOwnershipRequested = output<ActionLink>(); readonly takeOwnershipRequested = output<ActionLink>();
readonly releaseOwnershipRequested = output<ActionLink>(); readonly releaseOwnershipRequested = output<ActionLink>();
readonly savedRequested = output<void>(); readonly savedRequested = output<void>();
// 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<string | null>(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.';
}
} }
@@ -7,6 +7,7 @@ import {
CaseDetail, CaseDetail,
RecordAssessmentRequest, RecordAssessmentRequest,
RecordAssessmentResult, RecordAssessmentResult,
TakeOwnershipPreflightResult,
TakeOwnershipResult, TakeOwnershipResult,
} from './case-detail.types'; } from './case-detail.types';
@@ -33,6 +34,12 @@ export class CaseDetailService {
return this.http.post<TakeOwnershipResult>(href, null); return this.http.post<TakeOwnershipResult>(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<TakeOwnershipPreflightResult> {
return this.http.get<TakeOwnershipPreflightResult>(href);
}
releaseOwnership(href: string): Observable<void> { releaseOwnership(href: string): Observable<void> {
return this.http.delete<void>(href); return this.http.delete<void>(href);
} }
@@ -11,6 +11,7 @@ export interface CaseDetailActions {
editApplicantDetails: ActionLink; editApplicantDetails: ActionLink;
recordAssessment: ActionLink; recordAssessment: ActionLink;
takeOwnership?: ActionLink; takeOwnership?: ActionLink;
takeOwnershipPreflight?: ActionLink;
releaseOwnership?: ActionLink; releaseOwnership?: ActionLink;
} }
@@ -61,6 +62,16 @@ export interface ApplicantDetailsRequest {
preferredChannel: string; 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 { export function toApplicantDetailsRequest(detail: CaseDetail): ApplicantDetailsRequest {
return { return {
surname: detail.surname, surname: detail.surname,
@@ -87,3 +98,8 @@ export interface RecordAssessmentResult {
export interface TakeOwnershipResult { export interface TakeOwnershipResult {
registrationApplicationId: string; registrationApplicationId: string;
} }
/** 200 body from GET .../take-ownership/preflight - a 4xx means it wouldn't. */
export interface TakeOwnershipPreflightResult {
wouldSucceed: true;
}
@@ -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;
}
}
@@ -1,11 +1,31 @@
@if (detail(); as d) { @if (detail(); as d) {
<h2>{{ d.surname }}, {{ d.initials }} ({{ d.origin }})</h2> <nav class="breadcrumb" aria-label="Kruimelpad">
<a routerLink="/">Werkvoorraad</a>
<span aria-hidden="true"> / </span>
<span class="mono">{{ referenceOf(d) }}</span>
</nav>
<section> <div class="case-header">
<div>
<h2>{{ d.surname }}, {{ d.initials }}</h2>
<p class="mono case-header__ref">{{ referenceOf(d) }}</p>
</div>
<div class="case-header__badges">
<span class="badge" [class.badge--legacy]="d.origin === 'Legacy'" [class.badge--owned]="d.origin === 'Owned'">{{ d.origin }}</span>
@if (d.processStatus) {
<span class="badge badge--status">{{ d.processStatus }}</span>
}
</div>
</div>
<div class="case-grid">
<div class="case-grid__main">
<section class="card" [class]="sourceAccentClass(d.seams['aanvrager'])">
<h3>Aanvrager</h3> <h3>Aanvrager</h3>
<p class="card__source">bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}</p>
<dl> <dl>
<dt>BSN</dt> <dt>BSN</dt>
<dd>{{ d.bsn }}</dd> <dd class="mono">{{ d.bsn }}</dd>
<dt>E-mail</dt> <dt>E-mail</dt>
<dd>{{ d.email ?? 'n.v.t.' }}</dd> <dd>{{ d.email ?? 'n.v.t.' }}</dd>
<dt>Telefoon</dt> <dt>Telefoon</dt>
@@ -19,11 +39,12 @@
</dl> </dl>
</section> </section>
<section> <section class="card" [class]="sourceAccentClass(d.seams['aanvrager'])">
<h3>Diploma</h3> <h3>Diploma</h3>
<p class="card__source">bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}</p>
<dl> <dl>
<dt>Code</dt> <dt>Code</dt>
<dd>{{ d.diplomaCode }}</dd> <dd class="mono">{{ d.diplomaCode }}</dd>
<dt>Land van uitgifte</dt> <dt>Land van uitgifte</dt>
<dd>{{ d.diplomaCountryOfIssue }}</dd> <dd>{{ d.diplomaCountryOfIssue }}</dd>
<dt>Uitgegeven op</dt> <dt>Uitgegeven op</dt>
@@ -36,8 +57,9 @@
</section> </section>
@if (d.assessment; as assessment) { @if (d.assessment; as assessment) {
<section> <section class="card" [class]="sourceAccentClass(d.seams['procestijdlijn'])">
<h3>Beoordeling</h3> <h3>Beoordeling</h3>
<p class="card__source">bron: {{ d.seams['procestijdlijn'] ?? 'onbekend' }}</p>
<dl> <dl>
<dt>Uitkomst</dt> <dt>Uitkomst</dt>
<dd>{{ assessment.outcome }}</dd> <dd>{{ assessment.outcome }}</dd>
@@ -51,18 +73,7 @@
</section> </section>
} }
<section> <section class="card">
<h3>Seams</h3>
<p class="seam-note">Toont welk backend elk onderdeel van deze pagina levert.</p>
<dl>
@for (seam of d.seams | keyvalue; track seam.key) {
<dt>{{ seam.key }}</dt>
<dd>{{ seam.value ?? 'n.v.t.' }}</dd>
}
</dl>
</section>
<section>
<h3>Gegevens wijzigen</h3> <h3>Gegevens wijzigen</h3>
<app-edit-applicant-details <app-edit-applicant-details
[actionLink]="d.actions.editApplicantDetails" [actionLink]="d.actions.editApplicantDetails"
@@ -70,11 +81,13 @@
(saved)="reload()" (saved)="reload()"
/> />
</section> </section>
</div>
<section> <div class="case-grid__side">
<section class="card card--actions">
<h3>Acties</h3> <h3>Acties</h3>
@if (banner(); as message) { @if (banner(); as message) {
<p class="banner" data-testid="ownership-banner">{{ message }}</p> <p class="banner banner--error" data-testid="ownership-banner">{{ message }}</p>
} }
<app-case-actions <app-case-actions
[actions]="d.actions" [actions]="d.actions"
@@ -83,6 +96,19 @@
(releaseOwnershipRequested)="onReleaseOwnership($event)" (releaseOwnershipRequested)="onReleaseOwnership($event)"
/> />
</section> </section>
<section class="card card--diagnostic">
<h3>Herkomst</h3>
<p class="seam-note">Toont welk backend elk onderdeel van deze pagina levert.</p>
<dl>
@for (seam of d.seams | keyvalue; track seam.key) {
<dt>{{ seam.key }}</dt>
<dd class="mono">{{ seam.value ?? 'n.v.t.' }}</dd>
}
</dl>
</section>
</div>
</div>
} @else { } @else {
<p>Laden...</p> <p class="loading">Laden...</p>
} }
@@ -1,17 +1,17 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { KeyValuePipe } from '@angular/common'; import { KeyValuePipe } from '@angular/common';
import { Component, inject, signal } from '@angular/core'; 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 { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
import { CaseActions } from '../case-actions/case-actions'; import { CaseActions } from '../case-actions/case-actions';
import { CaseDetailService } from '../case-detail.service'; 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'; import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details';
@Component({ @Component({
selector: 'app-case-detail', selector: 'app-case-detail',
imports: [CaseActions, EditApplicantDetails, KeyValuePipe], imports: [CaseActions, EditApplicantDetails, KeyValuePipe, RouterLink],
templateUrl: './case-detail.html', templateUrl: './case-detail.html',
styleUrl: './case-detail.css', styleUrl: './case-detail.css',
}) })
@@ -23,6 +23,8 @@ export class CaseDetailPage {
readonly detail = signal<CaseDetail | null>(null); readonly detail = signal<CaseDetail | null>(null);
readonly banner = signal<string | null>(null); readonly banner = signal<string | null>(null);
readonly toApplicantDetailsRequest = toApplicantDetailsRequest; readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
readonly sourceAccentClass = sourceAccentClass;
readonly referenceOf = referenceOf;
constructor() { constructor() {
this.route.paramMap.subscribe((params) => { this.route.paramMap.subscribe((params) => {
@@ -1,12 +1,12 @@
<form (ngSubmit)="submit()"> <form (ngSubmit)="submit()">
@if (isWriteThrough) { @if (isWriteThrough) {
<p class="disclaimer" data-testid="write-through-disclaimer"> <p class="banner banner--warning" data-testid="write-through-disclaimer">
Gevalideerd door het legacy systeem — Angular voegt hier geen eigen validatie toe. Gevalideerd door het legacy systeem — Angular voegt hier geen eigen validatie toe.
</p> </p>
} }
@if (banner) { @if (banner) {
<p class="banner" data-testid="edit-details-banner">{{ banner }}</p> <p class="banner banner--error" data-testid="edit-details-banner">{{ banner }}</p>
} }
<label> <label>
@@ -69,5 +69,7 @@
<input type="text" name="preferredChannel" required [(ngModel)]="preferredChannel" /> <input type="text" name="preferredChannel" required [(ngModel)]="preferredChannel" />
</label> </label>
<button type="submit">Opslaan</button> <button type="submit" class="btn btn--primary" data-testid="save-details-submit" [disabled]="saving()">
{{ saving() ? 'Bezig met opslaan…' : 'Opslaan' }}
</button>
</form> </form>
@@ -1,6 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing'; 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service'; import { CaseDetailService } from '../case-detail.service';
@@ -68,6 +68,24 @@ describe('EditApplicantDetails', () => {
expect(savedSpy).toHaveBeenCalled(); expect(savedSpy).toHaveBeenCalled();
}); });
it('disables the submit button while the request is in flight', async () => {
const subject = new Subject<void>();
updateDetails.mockReturnValue(subject);
const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
const button = el.querySelector<HTMLButtonElement>('[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', () => { it('maps a 400 ErrorsResponse to per-field inline errors', () => {
updateDetails.mockReturnValue( updateDetails.mockReturnValue(
throwError( throwError(
@@ -1,5 +1,5 @@
import { HttpErrorResponse } from '@angular/common/http'; 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 { FormsModule } from '@angular/forms';
import { ErrorsResponse, InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types'; import { ErrorsResponse, InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
@@ -32,6 +32,7 @@ export class EditApplicantDetails implements OnInit {
fieldErrors: Record<string, string> = {}; fieldErrors: Record<string, string> = {};
banner: string | null = null; banner: string | null = null;
readonly saving = signal(false);
get isWriteThrough(): boolean { get isWriteThrough(): boolean {
return this.actionLink.mode === 'writeThrough'; return this.actionLink.mode === 'writeThrough';
@@ -52,6 +53,7 @@ export class EditApplicantDetails implements OnInit {
submit(): void { submit(): void {
this.fieldErrors = {}; this.fieldErrors = {};
this.banner = null; this.banner = null;
this.saving.set(true);
const body: ApplicantDetailsRequest = { const body: ApplicantDetailsRequest = {
surname: this.surname, surname: this.surname,
@@ -63,8 +65,14 @@ export class EditApplicantDetails implements OnInit {
}; };
this.caseDetailService.updateDetails(this.actionLink.href, body).subscribe({ this.caseDetailService.updateDetails(this.actionLink.href, body).subscribe({
next: () => this.saved.emit(), next: () => {
error: (error: HttpErrorResponse) => this.handleError(error), this.saving.set(false);
this.saved.emit();
},
error: (error: HttpErrorResponse) => {
this.saving.set(false);
this.handleError(error);
},
}); });
} }
@@ -1,14 +1,15 @@
@if (recorded) { @if (recorded()) {
<p data-testid="assessment-recorded" class="success"> <p data-testid="assessment-recorded" class="banner banner--success">
Beoordeling vastgelegd. Beoordeling vastgelegd.
@if (closurePending) { @if (closurePending()) {
<br />
<span data-testid="closure-pending-note">Administratieve afsluiting in behandeling.</span> <span data-testid="closure-pending-note">Administratieve afsluiting in behandeling.</span>
} }
</p> </p>
} @else { } @else {
<form (ngSubmit)="submit()"> <form (ngSubmit)="submit()">
@if (banner) { @if (banner()) {
<p class="banner" data-testid="assessment-banner">{{ banner }}</p> <p class="banner banner--error" data-testid="assessment-banner">{{ banner() }}</p>
} }
<label> <label>
@@ -41,6 +42,8 @@
<textarea name="motivation" [(ngModel)]="motivation"></textarea> <textarea name="motivation" [(ngModel)]="motivation"></textarea>
</label> </label>
<button type="submit" data-testid="record-assessment-submit">Beoordeling vastleggen</button> <button type="submit" class="btn btn--primary" data-testid="record-assessment-submit" [disabled]="saving()">
{{ saving() ? 'Bezig met vastleggen…' : 'Beoordeling vastleggen' }}
</button>
</form> </form>
} }
@@ -1,6 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing'; 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service'; import { CaseDetailService } from '../case-detail.service';
@@ -70,6 +70,32 @@ describe('RecordAssessmentForm', () => {
expect(el.querySelector('[data-testid="assessment-banner"]')).toBeFalsy(); 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<HTMLInputElement>('[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<HTMLButtonElement>('[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 () => { it('shows a 422 InvariantViolationResponse as a banner, not a curated message', async () => {
recordAssessment.mockReturnValue( recordAssessment.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Assessment.MotivationTooShort', message: 'Motivation is too short.' } })), throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Assessment.MotivationTooShort', message: 'Motivation is too short.' } })),
@@ -1,5 +1,5 @@
import { HttpErrorResponse } from '@angular/common/http'; 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 { FormsModule } from '@angular/forms';
import { InvariantViolationResponse } from '../../shared/api-error.types'; import { InvariantViolationResponse } from '../../shared/api-error.types';
@@ -24,12 +24,14 @@ export class RecordAssessmentForm {
rejectionCategory = ''; rejectionCategory = '';
motivation = ''; motivation = '';
banner: string | null = null; readonly banner = signal<string | null>(null);
recorded = false; readonly recorded = signal(false);
closurePending = false; readonly closurePending = signal(false);
readonly saving = signal(false);
submit(): void { submit(): void {
this.banner = null; this.banner.set(null);
this.saving.set(true);
const verifiedItems = this.verifiedItemsText const verifiedItems = this.verifiedItemsText
.split(',') .split(',')
@@ -46,13 +48,15 @@ export class RecordAssessmentForm {
this.caseDetailService.recordAssessment(this.actionLink.href, body).subscribe({ this.caseDetailService.recordAssessment(this.actionLink.href, body).subscribe({
next: (result) => { next: (result) => {
this.recorded = true; this.saving.set(false);
this.closurePending = result.closurePending; this.recorded.set(true);
this.closurePending.set(result.closurePending);
this.saved.emit(); this.saved.emit();
}, },
error: (error: HttpErrorResponse) => { error: (error: HttpErrorResponse) => {
this.saving.set(false);
const invariant = error.error as InvariantViolationResponse; const invariant = error.error as InvariantViolationResponse;
this.banner = `${invariant.invariant}: ${invariant.message}`; this.banner.set(`${invariant.invariant}: ${invariant.message}`);
}, },
}); });
} }
+15
View File
@@ -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']))") 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" 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 ==" 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") TAKE=$(curl -sS -o /tmp/take_1002.json -w '%{http_code}' -X POST "$BASE/api/worklist/legacy/1002/take-ownership")