diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 199424e..ccfe022 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -6,8 +6,12 @@ public record SuggestionRequest(string Name, string? Genre, string? Description, public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList? LinkedIds = null, IReadOnlyList? LinkedTitles = null); +public record SuggestionAllDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, string? Author, int? ParentSuggestionId, bool IsOwner, IReadOnlyList LinkedIds, IReadOnlyList LinkedTitles); + public record VoteRequest(int SuggestionId, int Score); +public record VoteRecordDto(int SuggestionId, int Score); + public record ResultsOpenRequest(bool ResultsOpen); public record VoteFinalizeRequest(bool Final); diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 7aebdcc..c546e9b 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -11,14 +11,34 @@ public static class AdminEndpoints { var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter(); - 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); }); } } diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index dac48e9..18c6819 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -8,7 +8,7 @@ namespace GameList.Endpoints; internal sealed class AdminWorkflowService(AppDbContext db) { - public async Task SetResultsOpenAsync(bool resultsOpen) + public async Task> 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.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt)); } - public async Task GetVoteStatusAsync() + public async Task> 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.Success(new VoteStatusResponse(voters, ready, waiting)); } - public async Task GrantJokerAsync(Guid playerId) + public async Task> 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.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.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.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker)); } - public async Task SetPlayerPhaseAsync(Guid playerId, Phase phase) + public async Task> SetPlayerPhaseAsync(Guid playerId, Phase phase) { if (phase != Phase.Suggest) - return EndpointHelpers.BadRequestError("Only transition to Suggest is supported."); + return ServiceResult.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.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.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.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal)); } - public async Task SetPlayerAdminAsync(Guid playerId, bool isAdmin) + public async Task> 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.Failure(ServiceError.NotFound("Player not found.")); if (player.IsOwner) - return EndpointHelpers.BadRequestError("Owner permissions cannot be changed."); + return ServiceResult.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.Success(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin)); } - public async Task DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx) + public async Task> 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.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.Failure(ServiceError.NotFound("Player not found.")); if (player.IsOwner) - return EndpointHelpers.BadRequestError("Owner account cannot be deleted."); + return ServiceResult.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.Success(new AdminDeletePlayerResponse(playerId)); } - public async Task LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId) + public async Task> 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.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); if (sourceSuggestionId == targetSuggestionId) - return EndpointHelpers.BadRequestError("Pick two different games to link."); + return ServiceResult.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.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.Failure(ServiceError.NotFound("Suggestion not found.")); if (sourceRoot == targetRoot) - return EndpointHelpers.BadRequestError("These games are already linked."); + return ServiceResult.Failure(ServiceError.BadRequest("These games are already linked.")); var affectedRootIds = new HashSet { @@ -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.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync())); } - public async Task UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId) + public async Task> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult.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(), 0)); + return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(Array.Empty(), 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(), 0)); + return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(Array.Empty(), 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.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync())); } - public async Task ResetAsync(Guid adminPlayerId, string password, HttpContext ctx) + public async Task> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) - return passwordError; + return ServiceResult.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.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt)); } - public async Task FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx) + public async Task> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) - return passwordError; + return ServiceResult.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.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt)); } - private async Task ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx) + private async Task 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(); 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); diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index b301378..f98e6d8 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -112,6 +112,22 @@ internal static class EndpointHelpers public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail); + public static IResult ToHttpResult(this ServiceResult result, Func onSuccess) + { + if (result.IsSuccess) + return onSuccess(result.Value!); + + return ToHttpError(result.Error!); + } + + public static IResult ToHttpResult(this ServiceResult result, Func 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 diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index 2840f0c..3863d6a 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -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); }); } } diff --git a/Endpoints/ResultsWorkflowService.cs b/Endpoints/ResultsWorkflowService.cs index aed8267..4453869 100644 --- a/Endpoints/ResultsWorkflowService.cs +++ b/Endpoints/ResultsWorkflowService.cs @@ -7,15 +7,15 @@ namespace GameList.Endpoints; internal sealed class ResultsWorkflowService(AppDbContext db) { - public async Task GetResultsAsync(Guid playerId) + public async Task>> 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>.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>.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 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>.Success(shaped); } } diff --git a/Endpoints/ServiceResult.cs b/Endpoints/ServiceResult.cs new file mode 100644 index 0000000..d3818c8 --- /dev/null +++ b/Endpoints/ServiceResult.cs @@ -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? Value, ServiceError? Error) +{ + public bool IsSuccess => Error is null; + + public static ServiceResult Success(T value) => new(value, null); + + public static ServiceResult Failure(ServiceError error) => new(default, error); +} diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index b439139..fdbaad4 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -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); }); } diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs index 8c4512f..4344ce0 100644 --- a/Endpoints/StateWorkflowService.cs +++ b/Endpoints/StateWorkflowService.cs @@ -7,22 +7,22 @@ namespace GameList.Endpoints; internal sealed class StateWorkflowService(AppDbContext db) { - public async Task GetStateAsync(Player player) + public async Task> 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.Success(summary); } - public async Task GetMeAsync(Player player) + public async Task> 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.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker)); } - public async Task NextPhaseAsync(Player player) + public async Task> 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.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.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.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); } finally { @@ -53,10 +53,10 @@ internal sealed class StateWorkflowService(AppDbContext db) } } - public async Task PrevPhaseAsync(Player player) + public async Task> PrevPhaseAsync(Player player) { if (!player.IsAdmin) - return EndpointHelpers.BadRequestError("Only admins can move backward."); + return ServiceResult.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.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); } private static Phase NextPhase(Phase current) => current switch diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 6c78d4a..98e9219 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -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); }); } } diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs index c1379f0..dd846f7 100644 --- a/Endpoints/SuggestionWorkflowService.cs +++ b/Endpoints/SuggestionWorkflowService.cs @@ -7,7 +7,7 @@ namespace GameList.Endpoints; internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory) { - public async Task GetMineAsync(Guid playerId) + public async Task>> 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 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>.Success(ordered); } - public async Task CreateAsync(Guid playerId, SuggestionInput input) + public async Task> CreateAsync(Guid playerId, SuggestionInput input) { var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); if (validationError is not null) - return EndpointHelpers.BadRequestError(validationError); + return ServiceResult.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.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) - return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions."); + return ServiceResult.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.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.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit.")); } - return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id)); + return ServiceResult.Success(new SuggestionCreatedResponse(suggestion.Id)); } - public async Task DeleteAsync(Guid playerId, int suggestionId) + public async Task> 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.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.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.Success(default); } - public async Task UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) + public async Task> 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.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.Failure(ServiceError.NotFound("Suggestion not found.")); var isAdmin = actor.IsAdmin; if (!isAdmin) { if (suggestion.PlayerId != playerId) - return EndpointHelpers.UnauthorizedError(); + return ServiceResult.Failure(ServiceError.Unauthorized()); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase == Phase.Results) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + return ServiceResult.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.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.Success(new SuggestionUpdatedResponse( suggestion.Id, suggestion.Name, suggestion.Genre, @@ -203,11 +204,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact )); } - public async Task GetAllAsync(Guid playerId) + public async Task>> GetAllAsync(Guid playerId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase < Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult>.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 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>.Success(ordered); } private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input) diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index 0fdbf75..54df5a0 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -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); }); } } diff --git a/Endpoints/VoteWorkflowService.cs b/Endpoints/VoteWorkflowService.cs index a2171c3..82b75b3 100644 --- a/Endpoints/VoteWorkflowService.cs +++ b/Endpoints/VoteWorkflowService.cs @@ -8,29 +8,25 @@ namespace GameList.Endpoints; internal sealed class VoteWorkflowService(AppDbContext db) { - public async Task GetMineAsync(Guid playerId) + public async Task>> GetMineAsync(Guid playerId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); - var votes = await db.Votes + IReadOnlyList 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>.Success(votes); } - public async Task UpsertAsync(Guid playerId, int suggestionId, int score) + public async Task> 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.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.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.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) - return EndpointHelpers.BadRequestError("Set a display name before voting."); + return ServiceResult.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.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.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.Failure(ServiceError.Conflict("Vote update conflict. Please retry.")); } - public async Task SetFinalizeAsync(Guid playerId, bool final) + public async Task> SetFinalizeAsync(Guid playerId, bool final) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult.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.Success(new VoteFinalizeResponse(player.VotesFinal)); } private static void DetachAddedVotes(IEnumerable> voteEntries) diff --git a/README.md b/README.md index dcb69c1..14f357f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS ## Module Ownership - `Program.cs`: startup wiring, middleware order, route registration. -- `Endpoints/`: HTTP endpoint transport + request orchestration. +- `Endpoints/`: endpoint adapters plus application workflow services (`ServiceResult` outputs mapped to HTTP at the edge). - `Infrastructure/`: filters, middleware, identity helpers. - `Data/`: EF Core `DbContext` and migrations. - `Domain/`: entities and enums.