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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user