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(
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);
}