diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 70ec4dd..2e01362 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -14,7 +14,7 @@ public static class AdminEndpoints admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => { - return await service.SetResultsOpenAsync(request); + return await service.SetResultsOpenAsync(request.ResultsOpen); }); admin.MapGet("/vote-status", async (AdminWorkflowService service) => @@ -24,7 +24,7 @@ public static class AdminEndpoints admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => { - return await service.GrantJokerAsync(request); + return await service.GrantJokerAsync(request.PlayerId); }); admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => @@ -38,7 +38,7 @@ public static class AdminEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.LinkSuggestionsAsync(player, request); + return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId); }); admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => @@ -47,7 +47,7 @@ public static class AdminEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.UnlinkSuggestionsAsync(player, request); + return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); }); admin.MapPost("/reset", async (AdminWorkflowService service) => diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index 041faae..b869bed 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -7,15 +7,15 @@ namespace GameList.Endpoints; internal sealed class AdminWorkflowService(AppDbContext db) { - public async Task SetResultsOpenAsync(ResultsOpenRequest request) + public async Task SetResultsOpenAsync(bool resultsOpen) { var state = await db.AppState.FirstAsync(); - state.ResultsOpen = request.ResultsOpen; + state.ResultsOpen = resultsOpen; state.UpdatedAt = DateTimeOffset.UtcNow; await using var tx = await db.Database.BeginTransactionAsync(); - if (request.ResultsOpen) + if (resultsOpen) { await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results)); } @@ -44,9 +44,9 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new VoteStatusResponse(voters, ready, waiting)); } - public async Task GrantJokerAsync(GrantJokerRequest request) + public async Task GrantJokerAsync(Guid playerId) { - var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return EndpointHelpers.NotFoundError("Player not found."); @@ -88,18 +88,18 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminDeletePlayerResponse(playerId)); } - public async Task LinkSuggestionsAsync(Player adminPlayer, LinkSuggestionsRequest request) + public async Task LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId) { - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayer.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - if (request.SourceSuggestionId == request.TargetSuggestionId) + if (sourceSuggestionId == targetSuggestionId) return EndpointHelpers.BadRequestError("Pick two different games to link."); var suggestions = await db.Suggestions.ToListAsync(); - var source = suggestions.FirstOrDefault(s => s.Id == request.SourceSuggestionId); - var target = suggestions.FirstOrDefault(s => s.Id == request.TargetSuggestionId); + 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."); @@ -143,14 +143,14 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync())); } - public async Task UnlinkSuggestionsAsync(Player adminPlayer, UnlinkSuggestionsRequest request) + public async Task UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId) { - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayer.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); var suggestions = await db.Suggestions.ToListAsync(); - var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId); + var target = suggestions.FirstOrDefault(s => s.Id == suggestionId); if (target is null) return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0)); diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index 2bad7b0..2840f0c 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -18,7 +18,7 @@ public static class ResultsEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetResultsAsync(player); + return await service.GetResultsAsync(player.Id); }); } } diff --git a/Endpoints/ResultsWorkflowService.cs b/Endpoints/ResultsWorkflowService.cs index 5fe14eb..2854bcb 100644 --- a/Endpoints/ResultsWorkflowService.cs +++ b/Endpoints/ResultsWorkflowService.cs @@ -7,13 +7,13 @@ namespace GameList.Endpoints; internal sealed class ResultsWorkflowService(AppDbContext db) { - public async Task GetResultsAsync(Player player) + public async Task GetResultsAsync(Guid playerId) { var appState = await db.AppState.AsNoTracking().FirstAsync(); if (!appState.ResultsOpen) return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Results) return EndpointHelpers.PhaseMismatch(Phase.Results, phase); @@ -33,7 +33,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db) Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score), Votes = s.Votes.Select(v => v.Score).ToList(), MyVote = s.Votes - .Where(v => v.PlayerId == player.Id) + .Where(v => v.PlayerId == playerId) .Select(v => (int?)v.Score) .FirstOrDefault(), s.ScreenshotUrl, diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 9a57648..6c78d4a 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -17,7 +17,7 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetMineAsync(player); + return await service.GetMineAsync(player.Id); }); group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -26,7 +26,19 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.CreateAsync(player, request); + return await service.CreateAsync( + player.Id, + new SuggestionInput( + request.Name, + request.Genre, + request.Description, + request.ScreenshotUrl, + request.YoutubeUrl, + request.GameUrl, + request.MinPlayers, + request.MaxPlayers + ) + ); }).AddEndpointFilter(new PhaseOrJokerFilter()); group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -35,8 +47,7 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); - return await service.DeleteAsync(player, isAdmin, id); + return await service.DeleteAsync(player.Id, id); }); group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -45,8 +56,20 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - var isAdmin = player.IsAdmin; - return await service.UpdateAsync(player, isAdmin, id, request); + return await service.UpdateAsync( + player.Id, + id, + new SuggestionInput( + request.Name, + request.Genre, + request.Description, + request.ScreenshotUrl, + request.YoutubeUrl, + request.GameUrl, + request.MinPlayers, + request.MaxPlayers + ) + ); }); group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -55,7 +78,7 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetAllAsync(player); + return await service.GetAllAsync(player.Id); }); } } diff --git a/Endpoints/SuggestionInput.cs b/Endpoints/SuggestionInput.cs new file mode 100644 index 0000000..b2532c1 --- /dev/null +++ b/Endpoints/SuggestionInput.cs @@ -0,0 +1,12 @@ +namespace GameList.Endpoints; + +internal readonly record struct SuggestionInput( + string Name, + string? Genre, + string? Description, + string? ScreenshotUrl, + string? YoutubeUrl, + string? GameUrl, + int? MinPlayers, + int? MaxPlayers +); diff --git a/Endpoints/SuggestionValidator.cs b/Endpoints/SuggestionValidator.cs index e998503..84ea3bf 100644 --- a/Endpoints/SuggestionValidator.cs +++ b/Endpoints/SuggestionValidator.cs @@ -1,27 +1,25 @@ -using GameList.Contracts; - namespace GameList.Endpoints; internal static class SuggestionValidator { - public static async Task ValidateAsync(SuggestionRequest request, IHttpClientFactory httpFactory) + public static async Task ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory) { - if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) + if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100) return "Name is required and must be <= 100 characters."; - if (!EndpointHelpers.IsValidImageUrl(request.ScreenshotUrl)) + if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl)) return "Screenshot URL must be http(s) and end with an image file extension."; - if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, httpFactory)) + if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory)) return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)."; - if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl)) + if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl)) return "Game URL must be http or https."; - if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl)) + if (!EndpointHelpers.IsValidHttpUrl(input.YoutubeUrl)) return "YouTube URL must be http or https."; - return ValidatePlayers(request.MinPlayers, request.MaxPlayers); + return ValidatePlayers(input.MinPlayers, input.MaxPlayers); } private static string? ValidatePlayers(int? minPlayers, int? maxPlayers) diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs index dd5757a..242cc36 100644 --- a/Endpoints/SuggestionWorkflowService.cs +++ b/Endpoints/SuggestionWorkflowService.cs @@ -7,11 +7,11 @@ namespace GameList.Endpoints; internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory) { - public async Task GetMineAsync(Player player) + public async Task GetMineAsync(Guid playerId) { var mine = await db.Suggestions .AsNoTracking() - .Where(s => s.PlayerId == player.Id) + .Where(s => s.PlayerId == playerId) .Select(s => new { s.Id, @@ -36,35 +36,45 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact return Results.Ok(ordered); } - public async Task CreateAsync(Player player, SuggestionRequest request) + public async Task CreateAsync(Guid playerId, SuggestionInput input) { - var validationError = await SuggestionValidator.ValidateAsync(request, httpFactory); + var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); if (validationError is not null) return EndpointHelpers.BadRequestError(validationError); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - var usingJoker = phase == Phase.Vote && player.HasJoker; + var playerState = await db.Players + .AsNoTracking() + .Where(p => p.Id == playerId) + .Select(p => new + { + p.DisplayName, + p.HasJoker + }) + .FirstAsync(); + + 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); - if (string.IsNullOrWhiteSpace(player.DisplayName)) + if (string.IsNullOrWhiteSpace(playerState.DisplayName)) return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions."); - var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id); + var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId); if (!usingJoker && existingCount >= 5) return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit."); var suggestion = new Suggestion { - PlayerId = player.Id, - Name = request.Name.Trim(), - Genre = EndpointHelpers.TrimTo(request.Genre, 50), - Description = EndpointHelpers.TrimTo(request.Description, 500), - ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048), - YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048), - GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048), - MinPlayers = request.MinPlayers, - MaxPlayers = request.MaxPlayers + PlayerId = playerId, + Name = input.Name.Trim(), + Genre = EndpointHelpers.TrimTo(input.Genre, 50), + Description = EndpointHelpers.TrimTo(input.Description, 500), + ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048), + YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048), + GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048), + MinPlayers = input.MinPlayers, + MaxPlayers = input.MaxPlayers }; await using var tx = await db.Database.BeginTransactionAsync(); @@ -73,7 +83,9 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact if (usingJoker) { - player.HasJoker = false; + await db.Players + .Where(p => p.Id == playerId) + .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false)); await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); } @@ -83,18 +95,28 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id)); } - public async Task DeleteAsync(Player player, bool isAdmin, int suggestionId) + public async Task DeleteAsync(Guid playerId, int suggestionId) { + var actor = await db.Players + .AsNoTracking() + .Where(p => p.Id == playerId) + .Select(p => new + { + p.IsAdmin + }) + .FirstAsync(); + + var isAdmin = actor.IsAdmin; if (!isAdmin) { - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Suggest) return EndpointHelpers.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 == player.Id); + : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId); if (suggestion == null) return EndpointHelpers.NotFoundError("Suggestion not found."); @@ -112,40 +134,50 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact return Results.NoContent(); } - public async Task UpdateAsync(Player player, bool isAdmin, int suggestionId, SuggestionRequest request) + public async Task UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) { - var validationError = await SuggestionValidator.ValidateAsync(request, httpFactory); + var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); if (validationError is not null) return EndpointHelpers.BadRequestError(validationError); + var actor = await db.Players + .AsNoTracking() + .Where(p => p.Id == playerId) + .Select(p => new + { + p.IsAdmin + }) + .FirstAsync(); + var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId); if (suggestion == null) return EndpointHelpers.NotFoundError("Suggestion not found."); + var isAdmin = actor.IsAdmin; if (!isAdmin) { - if (suggestion.PlayerId != player.Id) + if (suggestion.PlayerId != playerId) return EndpointHelpers.UnauthorizedError(); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase == Phase.Results) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); if (phase == Phase.Suggest) { - suggestion.Name = request.Name.Trim(); + suggestion.Name = input.Name.Trim(); } else if (phase != Phase.Vote) { return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); } - ApplyEditableFields(suggestion, request); + ApplyEditableFields(suggestion, input); } else { - suggestion.Name = request.Name.Trim(); - ApplyEditableFields(suggestion, request); + suggestion.Name = input.Name.Trim(); + ApplyEditableFields(suggestion, input); } await db.SaveChangesAsync(); @@ -163,9 +195,9 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact )); } - public async Task GetAllAsync(Player player) + public async Task GetAllAsync(Guid playerId) { - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase < Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); @@ -186,7 +218,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact Author = s.Player!.DisplayName, s.CreatedAt, s.ParentSuggestionId, - IsOwner = s.PlayerId == player.Id + IsOwner = s.PlayerId == playerId }) .ToListAsync(); @@ -219,14 +251,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact return Results.Ok(ordered); } - private static void ApplyEditableFields(Suggestion suggestion, SuggestionRequest request) + private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input) { - suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50); - suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500); - suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048); - suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048); - suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048); - suggestion.MinPlayers = request.MinPlayers; - suggestion.MaxPlayers = request.MaxPlayers; + suggestion.Genre = EndpointHelpers.TrimTo(input.Genre, 50); + suggestion.Description = EndpointHelpers.TrimTo(input.Description, 500); + suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048); + suggestion.YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048); + suggestion.GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048); + suggestion.MinPlayers = input.MinPlayers; + suggestion.MaxPlayers = input.MaxPlayers; } } diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index fbb600e..0fdbf75 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -17,7 +17,7 @@ public static class VoteEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetMineAsync(player); + return await service.GetMineAsync(player.Id); }); group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => @@ -25,7 +25,7 @@ public static class VoteEndpoints var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.UpsertAsync(player, request); + return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score); }); group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => @@ -34,7 +34,7 @@ public static class VoteEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.SetFinalizeAsync(player, request); + return await service.SetFinalizeAsync(player.Id, request.Final); }); } } diff --git a/Endpoints/VoteWorkflowService.cs b/Endpoints/VoteWorkflowService.cs index 83a4a72..3aeec65 100644 --- a/Endpoints/VoteWorkflowService.cs +++ b/Endpoints/VoteWorkflowService.cs @@ -7,15 +7,15 @@ namespace GameList.Endpoints; internal sealed class VoteWorkflowService(AppDbContext db) { - public async Task GetMineAsync(Player player) + public async Task GetMineAsync(Guid playerId) { - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); var votes = await db.Votes .AsNoTracking() - .Where(v => v.PlayerId == player.Id) + .Where(v => v.PlayerId == playerId) .Select(v => new { v.SuggestionId, @@ -26,19 +26,29 @@ internal sealed class VoteWorkflowService(AppDbContext db) return Results.Ok(votes); } - public async Task UpsertAsync(Player player, VoteRequest request) + public async Task UpsertAsync(Guid playerId, int suggestionId, int score) { - if (request.Score is < 0 or > 10) + if (score is < 0 or > 10) return EndpointHelpers.BadRequestError("Score must be between 0 and 10."); - if (player.VotesFinal) + var playerState = await db.Players + .AsNoTracking() + .Where(p => p.Id == playerId) + .Select(p => new + { + p.VotesFinal, + p.DisplayName + }) + .FirstAsync(); + + if (playerState.VotesFinal) return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores."); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - if (string.IsNullOrWhiteSpace(player.DisplayName)) + if (string.IsNullOrWhiteSpace(playerState.DisplayName)) return EndpointHelpers.BadRequestError("Set a display name before voting."); var linkMap = await db.Suggestions @@ -50,46 +60,48 @@ internal sealed class VoteWorkflowService(AppDbContext db) }) .ToListAsync(); var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); - if (!rootIndex.ContainsKey(request.SuggestionId)) + if (!rootIndex.ContainsKey(suggestionId)) return EndpointHelpers.BadRequestError("Suggestion not found."); - var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex); + var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex); if (linkedIds.Count == 0) - linkedIds.Add(request.SuggestionId); + linkedIds.Add(suggestionId); var existingVotes = await db.Votes - .Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)) + .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .ToListAsync(); - foreach (var suggestionId in linkedIds) + foreach (var linkedSuggestionId in linkedIds) { - var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId); + var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); if (vote == null) { db.Votes.Add(new Vote { - PlayerId = player.Id, - SuggestionId = suggestionId, - Score = request.Score + PlayerId = playerId, + SuggestionId = linkedSuggestionId, + Score = score }); } else { - vote.Score = request.Score; + vote.Score = score; } } await db.SaveChangesAsync(); - return Results.Ok(new VoteUpsertResponse(linkedIds, request.Score)); + return Results.Ok(new VoteUpsertResponse(linkedIds, score)); } - public async Task SetFinalizeAsync(Player player, VoteFinalizeRequest request) + public async Task SetFinalizeAsync(Guid playerId, bool final) { - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - player.VotesFinal = request.Final; + var player = await db.Players.FirstAsync(p => p.Id == playerId); + + player.VotesFinal = final; await db.SaveChangesAsync(); return Results.Ok(new VoteFinalizeResponse(player.VotesFinal)); }