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

@@ -11,14 +11,34 @@ public static class AdminEndpoints
{
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter<AdminOnlyFilter>();
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => await service.SetResultsOpenAsync(request.ResultsOpen));
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) =>
{
var result = await service.SetResultsOpenAsync(request.ResultsOpen);
return result.ToHttpResult(Results.Ok);
});
admin.MapGet("/vote-status", async (AdminWorkflowService service) => await service.GetVoteStatusAsync());
admin.MapGet("/vote-status", async (AdminWorkflowService service) =>
{
var result = await service.GetVoteStatusAsync();
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) =>
{
var result = await service.GrantJokerAsync(request.PlayerId);
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin));
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) =>
{
var result = await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase);
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) =>
{
var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin);
return result.ToHttpResult(Results.Ok);
});
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{
@@ -26,7 +46,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
var result = await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -35,7 +56,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -44,7 +66,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
var result = await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -53,7 +76,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.ResetAsync(player.Id, request.Password, ctx);
var result = await service.ResetAsync(player.Id, request.Password, ctx);
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -62,7 +86,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.FactoryResetAsync(player.Id, request.Password, ctx);
var result = await service.FactoryResetAsync(player.Id, request.Password, ctx);
return result.ToHttpResult(Results.Ok);
});
}
}

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

View File

@@ -112,6 +112,22 @@ internal static class EndpointHelpers
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
public static IResult ToHttpResult<T>(this ServiceResult<T> result, Func<T, IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess(result.Value!);
return ToHttpError(result.Error!);
}
public static IResult ToHttpResult(this ServiceResult<Unit> result, Func<IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess();
return ToHttpError(result.Error!);
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqliteException sqliteEx
@@ -160,6 +176,18 @@ internal static class EndpointHelpers
|| path.EndsWith(".avif", StringComparison.Ordinal);
}
private static IResult ToHttpError(ServiceError error)
{
return error.Code switch
{
ServiceErrorCode.BadRequest => BadRequestError(error.Detail),
ServiceErrorCode.Unauthorized => UnauthorizedError(error.Detail),
ServiceErrorCode.NotFound => NotFoundError(error.Detail),
ServiceErrorCode.Conflict => ConflictError(error.Detail),
_ => Problem(StatusCodes.Status500InternalServerError, "Internal Server Error", "Unhandled service error.")
};
}
public static HttpMessageHandler CreateImageValidationHandler()
{
return new SocketsHttpHandler

View File

@@ -18,7 +18,8 @@ public static class ResultsEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.GetResultsAsync(player.Id);
var result = await service.GetResultsAsync(player.Id);
return result.ToHttpResult(Results.Ok);
});
}
}

View File

@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
internal sealed class ResultsWorkflowService(AppDbContext db)
{
public async Task<IResult> GetResultsAsync(Guid playerId)
public async Task<ServiceResult<IReadOnlyList<ResultItemDto>>> GetResultsAsync(Guid playerId)
{
var appState = await db.AppState.AsNoTracking().SingleAsync();
if (!appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase));
var results = await db
.Suggestions.AsNoTracking()
@@ -49,7 +49,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
var shaped = results.Select(r =>
IReadOnlyList<ResultItemDto> shaped = results.Select(r =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
.Where(id => id != r.Id)
@@ -80,8 +80,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
linkedIds,
linkedTitles
);
});
}).ToList();
return Results.Ok(shaped);
return ServiceResult<IReadOnlyList<ResultItemDto>>.Success(shaped);
}
}

View File

@@ -0,0 +1,36 @@
using GameList.Domain;
namespace GameList.Endpoints;
internal enum ServiceErrorCode
{
BadRequest,
Unauthorized,
NotFound,
Conflict
}
internal sealed record ServiceError(ServiceErrorCode Code, string Detail)
{
public static ServiceError BadRequest(string detail) => new(ServiceErrorCode.BadRequest, detail);
public static ServiceError Unauthorized(string detail = "Unauthorized") => new(ServiceErrorCode.Unauthorized, detail);
public static ServiceError NotFound(string detail) => new(ServiceErrorCode.NotFound, detail);
public static ServiceError Conflict(string detail) => new(ServiceErrorCode.Conflict, detail);
public static ServiceError PhaseMismatch(Phase required, Phase current) =>
BadRequest($"This endpoint is available in the {required} phase. Your current phase is {current}.");
}
internal readonly record struct Unit;
internal readonly record struct ServiceResult<T>(T? Value, ServiceError? Error)
{
public bool IsSuccess => Error is null;
public static ServiceResult<T> Success(T value) => new(value, null);
public static ServiceResult<T> Failure(ServiceError error) => new(default, error);
}

View File

@@ -14,7 +14,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.GetStateAsync(player);
var result = await service.GetStateAsync(player);
return result.ToHttpResult(Results.Ok);
});
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -23,7 +24,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.GetMeAsync(player);
var result = await service.GetMeAsync(player);
return result.ToHttpResult(Results.Ok);
});
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -32,7 +34,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.NextPhaseAsync(player);
var result = await service.NextPhaseAsync(player);
return result.ToHttpResult(Results.Ok);
});
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -41,7 +44,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.PrevPhaseAsync(player);
var result = await service.PrevPhaseAsync(player);
return result.ToHttpResult(Results.Ok);
});
}

View File

@@ -7,22 +7,22 @@ namespace GameList.Endpoints;
internal sealed class StateWorkflowService(AppDbContext db)
{
public async Task<IResult> GetStateAsync(Player player)
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
{
var state = await db.AppState.AsNoTracking().SingleAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync());
return Results.Ok(summary);
return ServiceResult<StateSummaryResponse>.Success(summary);
}
public async Task<IResult> GetMeAsync(Player player)
public async Task<ServiceResult<MeResponse>> GetMeAsync(Player player)
{
var state = await db.AppState.AsNoTracking().SingleAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
return Results.Ok(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
return ServiceResult<MeResponse>.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
}
public async Task<IResult> NextPhaseAsync(Player player)
public async Task<ServiceResult<PhaseTransitionResponse>> NextPhaseAsync(Player player)
{
var appState = await db.AppState.SingleAsync();
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
@@ -35,16 +35,16 @@ internal sealed class StateWorkflowService(AppDbContext db)
{
var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id);
if (!hasSuggestions)
return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase.");
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Add at least one suggestion before entering the Vote phase."));
}
if (next == Phase.Results && !appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
player.CurrentPhase = next;
player.VotesFinal = false; // moving forward clears any prior finalize
shouldSave = true;
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
}
finally
{
@@ -53,10 +53,10 @@ internal sealed class StateWorkflowService(AppDbContext db)
}
}
public async Task<IResult> PrevPhaseAsync(Player player)
public async Task<ServiceResult<PhaseTransitionResponse>> PrevPhaseAsync(Player player)
{
if (!player.IsAdmin)
return EndpointHelpers.BadRequestError("Only admins can move backward.");
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Only admins can move backward."));
var appState = await db.AppState.SingleAsync();
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
@@ -64,7 +64,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
}
private static Phase NextPhase(Phase current) => current switch

View File

@@ -17,7 +17,8 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player.Id);
var result = await service.GetMineAsync(player.Id);
return result.ToHttpResult(Results.Ok);
});
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -26,7 +27,7 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.CreateAsync(
var result = await service.CreateAsync(
player.Id,
new SuggestionInput(
request.Name,
@@ -39,6 +40,8 @@ public static class SuggestEndpoints
request.MaxPlayers
)
);
return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
}).AddEndpointFilter(new PhaseOrJokerFilter());
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -47,7 +50,8 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.DeleteAsync(player.Id, id);
var result = await service.DeleteAsync(player.Id, id);
return result.ToHttpResult(Results.NoContent);
});
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -56,7 +60,7 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.UpdateAsync(
var result = await service.UpdateAsync(
player.Id,
id,
new SuggestionInput(
@@ -70,6 +74,8 @@ public static class SuggestEndpoints
request.MaxPlayers
)
);
return result.ToHttpResult(Results.Ok);
});
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -78,7 +84,8 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.GetAllAsync(player.Id);
var result = await service.GetAllAsync(player.Id);
return result.ToHttpResult(Results.Ok);
});
}
}

View File

@@ -7,7 +7,7 @@ namespace GameList.Endpoints;
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
{
public async Task<IResult> GetMineAsync(Guid playerId)
public async Task<ServiceResult<IReadOnlyList<SuggestionDto>>> GetMineAsync(Guid playerId)
{
var mine = await db.Suggestions
.AsNoTracking()
@@ -29,18 +29,19 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
})
.ToListAsync();
var ordered = mine
IReadOnlyList<SuggestionDto> ordered = mine
.OrderBy(s => s.CreatedAt)
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId))
.ToList();
return Results.Ok(ordered);
return ServiceResult<IReadOnlyList<SuggestionDto>>.Success(ordered);
}
public async Task<IResult> CreateAsync(Guid playerId, SuggestionInput input)
public async Task<ServiceResult<SuggestionCreatedResponse>> CreateAsync(Guid playerId, SuggestionInput input)
{
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError);
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest(validationError));
var playerState = await db.Players
.AsNoTracking()
@@ -55,14 +56,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
if (phase != Phase.Suggest && !usingJoker)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions."));
var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5)
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
var suggestion = new Suggestion
{
@@ -97,13 +98,13 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
{
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
}
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
return ServiceResult<SuggestionCreatedResponse>.Success(new SuggestionCreatedResponse(suggestion.Id));
}
public async Task<IResult> DeleteAsync(Guid playerId, int suggestionId)
public async Task<ServiceResult<Unit>> DeleteAsync(Guid playerId, int suggestionId)
{
var actor = await db.Players
.AsNoTracking()
@@ -119,14 +120,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
return ServiceResult<Unit>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
}
var suggestion = isAdmin
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found.");
return ServiceResult<Unit>.Failure(ServiceError.NotFound("Suggestion not found."));
await using var tx = await db.Database.BeginTransactionAsync();
@@ -139,14 +140,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
db.Suggestions.Remove(suggestion);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.NoContent();
return ServiceResult<Unit>.Success(default);
}
public async Task<IResult> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
public async Task<ServiceResult<SuggestionUpdatedResponse>> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
{
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError);
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.BadRequest(validationError));
var actor = await db.Players
.AsNoTracking()
@@ -159,17 +160,17 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found.");
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
var isAdmin = actor.IsAdmin;
if (!isAdmin)
{
if (suggestion.PlayerId != playerId)
return EndpointHelpers.UnauthorizedError();
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.Unauthorized());
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase == Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
if (phase == Phase.Suggest)
{
@@ -177,7 +178,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
}
else if (phase != Phase.Vote)
{
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
}
ApplyEditableFields(suggestion, input);
@@ -190,7 +191,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
await db.SaveChangesAsync();
return Results.Ok(new SuggestionUpdatedResponse(
return ServiceResult<SuggestionUpdatedResponse>.Success(new SuggestionUpdatedResponse(
suggestion.Id,
suggestion.Name,
suggestion.Genre,
@@ -203,11 +204,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
));
}
public async Task<IResult> GetAllAsync(Guid playerId)
public async Task<ServiceResult<IReadOnlyList<SuggestionAllDto>>> GetAllAsync(Guid playerId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase < Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var all = await db.Suggestions
.AsNoTracking()
@@ -233,12 +234,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
IReadOnlyList<SuggestionAllDto> ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
return new
{
return new SuggestionAllDto(
s.Id,
s.Name,
s.Genre,
@@ -251,12 +251,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
s.Author,
s.ParentSuggestionId,
s.IsOwner,
LinkedIds = linkedIds,
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
};
});
linkedIds,
linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
);
}).ToList();
return Results.Ok(ordered);
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Success(ordered);
}
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)

View File

@@ -17,7 +17,8 @@ public static class VoteEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player.Id);
var result = await service.GetMineAsync(player.Id);
return result.ToHttpResult(Results.Ok);
});
group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -25,7 +26,9 @@ public static class VoteEndpoints
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
var result = await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
return result.ToHttpResult(Results.Ok);
});
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -34,7 +37,8 @@ public static class VoteEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.SetFinalizeAsync(player.Id, request.Final);
var result = await service.SetFinalizeAsync(player.Id, request.Final);
return result.ToHttpResult(Results.Ok);
});
}
}

View File

@@ -8,29 +8,25 @@ namespace GameList.Endpoints;
internal sealed class VoteWorkflowService(AppDbContext db)
{
public async Task<IResult> GetMineAsync(Guid playerId)
public async Task<ServiceResult<IReadOnlyList<VoteRecordDto>>> GetMineAsync(Guid playerId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var votes = await db.Votes
IReadOnlyList<VoteRecordDto> votes = await db.Votes
.AsNoTracking()
.Where(v => v.PlayerId == playerId)
.Select(v => new
{
v.SuggestionId,
v.Score
})
.Select(v => new VoteRecordDto(v.SuggestionId, v.Score))
.ToListAsync();
return Results.Ok(votes);
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Success(votes);
}
public async Task<IResult> UpsertAsync(Guid playerId, int suggestionId, int score)
public async Task<ServiceResult<VoteUpsertResponse>> UpsertAsync(Guid playerId, int suggestionId, int score)
{
if (score is < 0 or > 10)
return EndpointHelpers.BadRequestError("Score must be between 0 and 10.");
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Score must be between 0 and 10."));
var playerState = await db.Players
.AsNoTracking()
@@ -43,14 +39,14 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.FirstAsync();
if (playerState.VotesFinal)
return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores.");
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before voting.");
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Set a display name before voting."));
var linkMap = await db.Suggestions
.AsNoTracking()
@@ -62,7 +58,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(suggestionId))
return EndpointHelpers.BadRequestError("Suggestion not found.");
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Suggestion not found."));
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
if (linkedIds.Count == 0)
@@ -95,7 +91,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
try
{
await db.SaveChangesAsync();
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
return ServiceResult<VoteUpsertResponse>.Success(new VoteUpsertResponse(linkedIds, score));
}
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
{
@@ -111,20 +107,20 @@ internal sealed class VoteWorkflowService(AppDbContext db)
}
}
return EndpointHelpers.ConflictError("Vote update conflict. Please retry.");
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.Conflict("Vote update conflict. Please retry."));
}
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
public async Task<ServiceResult<VoteFinalizeResponse>> SetFinalizeAsync(Guid playerId, bool final)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
return ServiceResult<VoteFinalizeResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var player = await db.Players.FirstAsync(p => p.Id == playerId);
player.VotesFinal = final;
await db.SaveChangesAsync();
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
return ServiceResult<VoteFinalizeResponse>.Success(new VoteFinalizeResponse(player.VotesFinal));
}
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)