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:
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
var result = await handler.HandleAsync(registrationApplicationId, ct);
|
||||
|
||||
@@ -28,41 +28,13 @@ public sealed class TakeOwnershipHandler(
|
||||
|
||||
public async Task<TakeOwnershipResult> 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);
|
||||
}
|
||||
|
||||
/// <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') {
|
||||
<a data-testid="record-assessment-link" [href]="actions().recordAssessment.href">Beoordeling vastleggen (in legacy)</a>
|
||||
} @else {
|
||||
<app-record-assessment-form
|
||||
data-testid="record-assessment-form"
|
||||
[actionLink]="actions().recordAssessment"
|
||||
(saved)="savedRequested.emit()"
|
||||
/>
|
||||
}
|
||||
<div class="case-actions">
|
||||
@if (actions().recordAssessment.mode === 'redirect') {
|
||||
<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
|
||||
data-testid="record-assessment-form"
|
||||
[actionLink]="actions().recordAssessment"
|
||||
(saved)="savedRequested.emit()"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (actions().takeOwnership; as takeOwnership) {
|
||||
<button type="button" data-testid="take-ownership-button" (click)="takeOwnershipRequested.emit(takeOwnership)">
|
||||
In eigen beheer nemen
|
||||
</button>
|
||||
}
|
||||
@if (actions().takeOwnership; as 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
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (actions().releaseOwnership; as releaseOwnership) {
|
||||
<button type="button" data-testid="release-ownership-button" (click)="releaseOwnershipRequested.emit(releaseOwnership)">
|
||||
Eigenaarschap teruggeven
|
||||
</button>
|
||||
}
|
||||
@if (actions().releaseOwnership; as releaseOwnership) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--danger-outline"
|
||||
data-testid="release-ownership-button"
|
||||
(click)="releaseOwnershipRequested.emit(releaseOwnership)"
|
||||
>
|
||||
Eigenaarschap teruggeven
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<CaseActions>;
|
||||
let preflightTakeOwnership: ReturnType<typeof vi.fn>;
|
||||
|
||||
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<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 { 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<CaseDetailActions>();
|
||||
|
||||
readonly takeOwnershipRequested = output<ActionLink>();
|
||||
readonly releaseOwnershipRequested = output<ActionLink>();
|
||||
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,
|
||||
RecordAssessmentRequest,
|
||||
RecordAssessmentResult,
|
||||
TakeOwnershipPreflightResult,
|
||||
TakeOwnershipResult,
|
||||
} from './case-detail.types';
|
||||
|
||||
@@ -33,6 +34,12 @@ export class CaseDetailService {
|
||||
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> {
|
||||
return this.http.delete<void>(href);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,88 +1,114 @@
|
||||
@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>
|
||||
<h3>Aanvrager</h3>
|
||||
<dl>
|
||||
<dt>BSN</dt>
|
||||
<dd>{{ d.bsn }}</dd>
|
||||
<dt>E-mail</dt>
|
||||
<dd>{{ d.email ?? 'n.v.t.' }}</dd>
|
||||
<dt>Telefoon</dt>
|
||||
<dd>{{ d.phone ?? 'n.v.t.' }}</dd>
|
||||
<dt>Voorkeurskanaal</dt>
|
||||
<dd>{{ d.preferredChannel }}</dd>
|
||||
@if (d.address; as address) {
|
||||
<dt>Adres</dt>
|
||||
<dd>{{ address.street }} {{ address.number }}, {{ address.postalCode }} {{ address.city }}</dd>
|
||||
<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>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3>Diploma</h3>
|
||||
<dl>
|
||||
<dt>Code</dt>
|
||||
<dd>{{ d.diplomaCode }}</dd>
|
||||
<dt>Land van uitgifte</dt>
|
||||
<dd>{{ d.diplomaCountryOfIssue }}</dd>
|
||||
<dt>Uitgegeven op</dt>
|
||||
<dd>{{ d.diplomaIssuedOn }}</dd>
|
||||
<dt>Ontvangen op</dt>
|
||||
<dd>{{ d.receivedOn }}</dd>
|
||||
<dt>Processtatus</dt>
|
||||
<dd>{{ d.processStatus ?? 'n.v.t.' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<div class="case-grid">
|
||||
<div class="case-grid__main">
|
||||
<section class="card" [class]="sourceAccentClass(d.seams['aanvrager'])">
|
||||
<h3>Aanvrager</h3>
|
||||
<p class="card__source">bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}</p>
|
||||
<dl>
|
||||
<dt>BSN</dt>
|
||||
<dd class="mono">{{ d.bsn }}</dd>
|
||||
<dt>E-mail</dt>
|
||||
<dd>{{ d.email ?? 'n.v.t.' }}</dd>
|
||||
<dt>Telefoon</dt>
|
||||
<dd>{{ d.phone ?? 'n.v.t.' }}</dd>
|
||||
<dt>Voorkeurskanaal</dt>
|
||||
<dd>{{ d.preferredChannel }}</dd>
|
||||
@if (d.address; as address) {
|
||||
<dt>Adres</dt>
|
||||
<dd>{{ address.street }} {{ address.number }}, {{ address.postalCode }} {{ address.city }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@if (d.assessment; as assessment) {
|
||||
<section>
|
||||
<h3>Beoordeling</h3>
|
||||
<dl>
|
||||
<dt>Uitkomst</dt>
|
||||
<dd>{{ assessment.outcome }}</dd>
|
||||
<dt>Motivatie</dt>
|
||||
<dd>{{ assessment.motivation }}</dd>
|
||||
<dt>Gecontroleerde stukken</dt>
|
||||
<dd>{{ assessment.verifiedItems.join(', ') || 'geen' }}</dd>
|
||||
<dt>Beslist op</dt>
|
||||
<dd>{{ assessment.decidedOn }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
<section class="card" [class]="sourceAccentClass(d.seams['aanvrager'])">
|
||||
<h3>Diploma</h3>
|
||||
<p class="card__source">bron: {{ d.seams['aanvrager'] ?? 'onbekend' }}</p>
|
||||
<dl>
|
||||
<dt>Code</dt>
|
||||
<dd class="mono">{{ d.diplomaCode }}</dd>
|
||||
<dt>Land van uitgifte</dt>
|
||||
<dd>{{ d.diplomaCountryOfIssue }}</dd>
|
||||
<dt>Uitgegeven op</dt>
|
||||
<dd>{{ d.diplomaIssuedOn }}</dd>
|
||||
<dt>Ontvangen op</dt>
|
||||
<dd>{{ d.receivedOn }}</dd>
|
||||
<dt>Processtatus</dt>
|
||||
<dd>{{ d.processStatus ?? 'n.v.t.' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<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>
|
||||
@if (d.assessment; as assessment) {
|
||||
<section class="card" [class]="sourceAccentClass(d.seams['procestijdlijn'])">
|
||||
<h3>Beoordeling</h3>
|
||||
<p class="card__source">bron: {{ d.seams['procestijdlijn'] ?? 'onbekend' }}</p>
|
||||
<dl>
|
||||
<dt>Uitkomst</dt>
|
||||
<dd>{{ assessment.outcome }}</dd>
|
||||
<dt>Motivatie</dt>
|
||||
<dd>{{ assessment.motivation }}</dd>
|
||||
<dt>Gecontroleerde stukken</dt>
|
||||
<dd>{{ assessment.verifiedItems.join(', ') || 'geen' }}</dd>
|
||||
<dt>Beslist op</dt>
|
||||
<dd>{{ assessment.decidedOn }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Gegevens wijzigen</h3>
|
||||
<app-edit-applicant-details
|
||||
[actionLink]="d.actions.editApplicantDetails"
|
||||
[initial]="toApplicantDetailsRequest(d)"
|
||||
(saved)="reload()"
|
||||
/>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>Gegevens wijzigen</h3>
|
||||
<app-edit-applicant-details
|
||||
[actionLink]="d.actions.editApplicantDetails"
|
||||
[initial]="toApplicantDetailsRequest(d)"
|
||||
(saved)="reload()"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3>Acties</h3>
|
||||
@if (banner(); as message) {
|
||||
<p class="banner" data-testid="ownership-banner">{{ message }}</p>
|
||||
}
|
||||
<app-case-actions
|
||||
[actions]="d.actions"
|
||||
(savedRequested)="reload()"
|
||||
(takeOwnershipRequested)="onTakeOwnership($event)"
|
||||
(releaseOwnershipRequested)="onReleaseOwnership($event)"
|
||||
/>
|
||||
</section>
|
||||
<div class="case-grid__side">
|
||||
<section class="card card--actions">
|
||||
<h3>Acties</h3>
|
||||
@if (banner(); as message) {
|
||||
<p class="banner banner--error" data-testid="ownership-banner">{{ message }}</p>
|
||||
}
|
||||
<app-case-actions
|
||||
[actions]="d.actions"
|
||||
(savedRequested)="reload()"
|
||||
(takeOwnershipRequested)="onTakeOwnership($event)"
|
||||
(releaseOwnershipRequested)="onReleaseOwnership($event)"
|
||||
/>
|
||||
</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 {
|
||||
<p>Laden...</p>
|
||||
<p class="loading">Laden...</p>
|
||||
}
|
||||
|
||||
@@ -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<CaseDetail | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
|
||||
readonly sourceAccentClass = sourceAccentClass;
|
||||
readonly referenceOf = referenceOf;
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
|
||||
+5
-3
@@ -1,12 +1,12 @@
|
||||
<form (ngSubmit)="submit()">
|
||||
@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.
|
||||
</p>
|
||||
}
|
||||
|
||||
@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>
|
||||
@@ -69,5 +69,7 @@
|
||||
<input type="text" name="preferredChannel" required [(ngModel)]="preferredChannel" />
|
||||
</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>
|
||||
|
||||
+19
-1
@@ -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<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', () => {
|
||||
updateDetails.mockReturnValue(
|
||||
throwError(
|
||||
|
||||
+11
-3
@@ -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<string, string> = {};
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -1,14 +1,15 @@
|
||||
@if (recorded) {
|
||||
<p data-testid="assessment-recorded" class="success">
|
||||
@if (recorded()) {
|
||||
<p data-testid="assessment-recorded" class="banner banner--success">
|
||||
Beoordeling vastgelegd.
|
||||
@if (closurePending) {
|
||||
@if (closurePending()) {
|
||||
<br />
|
||||
<span data-testid="closure-pending-note">Administratieve afsluiting in behandeling.</span>
|
||||
}
|
||||
</p>
|
||||
} @else {
|
||||
<form (ngSubmit)="submit()">
|
||||
@if (banner) {
|
||||
<p class="banner" data-testid="assessment-banner">{{ banner }}</p>
|
||||
@if (banner()) {
|
||||
<p class="banner banner--error" data-testid="assessment-banner">{{ banner() }}</p>
|
||||
}
|
||||
|
||||
<label>
|
||||
@@ -41,6 +42,8 @@
|
||||
<textarea name="motivation" [(ngModel)]="motivation"></textarea>
|
||||
</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>
|
||||
}
|
||||
|
||||
+27
-1
@@ -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<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 () => {
|
||||
recordAssessment.mockReturnValue(
|
||||
throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Assessment.MotivationTooShort', message: 'Motivation is too short.' } })),
|
||||
|
||||
+12
-8
@@ -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<string | null>(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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user