Decouple workflow services from HTTP result types

This commit is contained in:
2026-02-08 21:43:07 +01:00
parent fe6a9d5da4
commit 2d2201d0a2
14 changed files with 242 additions and 137 deletions

View File

@@ -8,7 +8,7 @@ namespace GameList.Endpoints;
internal sealed class AdminWorkflowService(AppDbContext db)
{
public async Task<IResult> SetResultsOpenAsync(bool resultsOpen)
public async Task<ServiceResult<AdminResultsStateResponse>> SetResultsOpenAsync(bool resultsOpen)
{
var state = await db.AppState.SingleAsync();
state.ResultsOpen = resultsOpen;
@@ -29,81 +29,81 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
var currentState = await db.AppState.AsNoTracking().SingleAsync();
return Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
return ServiceResult<AdminResultsStateResponse>.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
}
public async Task<IResult> GetVoteStatusAsync()
public async Task<ServiceResult<VoteStatusResponse>> GetVoteStatusAsync()
{
var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.IsAdmin, p.IsOwner, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0;
return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
return ServiceResult<VoteStatusResponse>.Success(new VoteStatusResponse(voters, ready, waiting));
}
public async Task<IResult> GrantJokerAsync(Guid playerId)
public async Task<ServiceResult<AdminGrantJokerResponse>> GrantJokerAsync(Guid playerId)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.NotFound("Player not found."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Vote)
return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker.");
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.BadRequest("Player must be in the Vote phase to receive a joker."));
player.HasJoker = true;
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
return ServiceResult<AdminGrantJokerResponse>.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker));
}
public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase)
public async Task<ServiceResult<AdminSetPlayerPhaseResponse>> SetPlayerPhaseAsync(Guid playerId, Phase phase)
{
if (phase != Phase.Suggest)
return EndpointHelpers.BadRequestError("Only transition to Suggest is supported.");
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Only transition to Suggest is supported."));
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.NotFound("Player not found."));
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (currentPhase != Phase.Vote)
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase.");
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Player must currently be in the Vote phase."));
player.CurrentPhase = Phase.Suggest;
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
return ServiceResult<AdminSetPlayerPhaseResponse>.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
}
public async Task<IResult> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
public async Task<ServiceResult<AdminSetPlayerAdminResponse>> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.NotFound("Player not found."));
if (player.IsOwner)
return EndpointHelpers.BadRequestError("Owner permissions cannot be changed.");
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.BadRequest("Owner permissions cannot be changed."));
player.IsAdmin = isAdmin;
await db.SaveChangesAsync();
return Results.Ok(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
return ServiceResult<AdminSetPlayerAdminResponse>.Success(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
}
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
public async Task<ServiceResult<AdminDeletePlayerResponse>> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
return passwordError;
return ServiceResult<AdminDeletePlayerResponse>.Failure(passwordError);
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.NotFound("Player not found."));
if (player.IsOwner)
return EndpointHelpers.BadRequestError("Owner account cannot be deleted.");
return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.BadRequest("Owner account cannot be deleted."));
await using var tx = await db.Database.BeginTransactionAsync();
@@ -121,30 +121,30 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new AdminDeletePlayerResponse(playerId));
return ServiceResult<AdminDeletePlayerResponse>.Success(new AdminDeletePlayerResponse(playerId));
}
public async Task<IResult> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
public async Task<ServiceResult<AdminLinkSuggestionsResponse>> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
if (sourceSuggestionId == targetSuggestionId)
return EndpointHelpers.BadRequestError("Pick two different games to link.");
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("Pick two different games to link."));
var suggestions = await db.Suggestions.ToListAsync();
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
if (source is null || target is null)
return EndpointHelpers.NotFoundError("Suggestion not found.");
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
return EndpointHelpers.NotFoundError("Suggestion not found.");
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
if (sourceRoot == targetRoot)
return EndpointHelpers.BadRequestError("These games are already linked.");
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("These games are already linked."));
var affectedRootIds = new HashSet<int>
{
@@ -176,23 +176,23 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
return ServiceResult<AdminLinkSuggestionsResponse>.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
}
public async Task<IResult> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
public async Task<ServiceResult<AdminUnlinkSuggestionsResponse>> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
return ServiceResult<AdminUnlinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var suggestions = await db.Suggestions.ToListAsync();
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
if (target is null)
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(target.Id, out var rootId))
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
@@ -211,14 +211,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
}
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
public async Task<ServiceResult<AdminResetStateResponse>> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
return passwordError;
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
await using var tx = await db.Database.BeginTransactionAsync();
@@ -232,14 +232,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
}
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
public async Task<ServiceResult<AdminResetStateResponse>> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
return passwordError;
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
await using var tx = await db.Database.BeginTransactionAsync();
@@ -254,24 +254,24 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
}
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
private async Task<ServiceError?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
if (string.IsNullOrWhiteSpace(password))
return EndpointHelpers.BadRequestError("Admin password is required.");
return ServiceError.BadRequest("Admin password is required.");
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
if (admin is null)
return EndpointHelpers.UnauthorizedError();
return ServiceError.Unauthorized();
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt);
if (!verified)
{
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
return EndpointHelpers.BadRequestError("Invalid admin password.");
return ServiceError.BadRequest("Invalid admin password.");
}
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);