Decouple workflow services from HTTP result types
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
36
Endpoints/ServiceResult.cs
Normal file
36
Endpoints/ServiceResult.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user