diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index d992299..ba92482 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -1,8 +1,6 @@ using GameList.Contracts; using GameList.Data; -using GameList.Domain; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using GameList.Infrastructure; namespace GameList.Endpoints; @@ -13,247 +11,51 @@ public static class SuggestEndpoints { var group = app.MapGroup("/api/suggestions").RequireAuthorization(); - group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) => + group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - var mine = await db.Suggestions.AsNoTracking().Where(s => s.PlayerId == player.Id).Select(s => new - { - s.Id, - s.PlayerId, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - s.GameUrl, - s.CreatedAt, - s.MinPlayers, - s.MaxPlayers, - s.ParentSuggestionId - }).ToListAsync(); - - var 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)); - - return Results.Ok(ordered); + return await service.GetMineAsync(player); }); - group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) => + group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { - var validationError = await SuggestionValidator.ValidateAsync(request, http); - if (validationError is not null) - return Results.BadRequest(new { error = validationError }); - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - var usingJoker = phase == Phase.Vote && player.HasJoker; - if (phase != Phase.Suggest && !usingJoker) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); - - if (string.IsNullOrWhiteSpace(player.DisplayName)) - { - return Results.BadRequest(new { error = "Set a display name before submitting suggestions." }); - } - - var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id); - if (!usingJoker && existingCount >= 5) - { - return Results.BadRequest(new { error = "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 - }; - - db.Suggestions.Add(suggestion); - - if (usingJoker) - { - player.HasJoker = false; - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); - } - - await db.SaveChangesAsync(); - - return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id }); + return await service.CreateAsync(player, request); }).AddEndpointFilter(new PhaseOrJokerFilter()); - group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db) => + group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); - - if (!isAdmin) - { - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase != Phase.Suggest) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); - } - - var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id); - if (suggestion == null) - return Results.NotFound(new { error = "Suggestion not found." }); - - // Break any links that pointed at this suggestion - await db.Suggestions.Where(s => s.ParentSuggestionId == suggestion.Id).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); - - // Remove votes for this suggestion to avoid orphaned vote rows or FK errors - await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync(); - - db.Suggestions.Remove(suggestion); - await db.SaveChangesAsync(); - return Results.NoContent(); + return await service.DeleteAsync(player, isAdmin, id); }); - group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) => - { - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); - var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); - - if (!isAdmin && player is null) - return Results.Unauthorized(); - - var validationError = await SuggestionValidator.ValidateAsync(request, http); - if (validationError is not null) - return Results.BadRequest(new { error = validationError }); - - var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id); - if (suggestion == null) - return Results.NotFound(new { error = "Suggestion not found." }); - - if (!isAdmin) - { - if (suggestion.PlayerId != player!.Id) - return Results.Unauthorized(); - - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase == Phase.Results) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); - - var inSuggest = phase == Phase.Suggest; - var inVote = phase == Phase.Vote; - - if (inSuggest) - { - suggestion.Name = request.Name.Trim(); - } - else if (inVote) - { - // Title locked in vote; allow other fields - } - else - { - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); - } - - 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; - } - else - { - // Admins can edit anytime - suggestion.Name = request.Name.Trim(); - 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; - } - - await db.SaveChangesAsync(); - - return Results.Ok(new - { - suggestion.Id, - suggestion.Name, - suggestion.Genre, - suggestion.Description, - suggestion.ScreenshotUrl, - suggestion.YoutubeUrl, - suggestion.GameUrl, - suggestion.MinPlayers, - suggestion.MaxPlayers - }); - }); - - group.MapGet("/all", async (HttpContext ctx, AppDbContext db) => + group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase < Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + var isAdmin = player.IsAdmin; + return await service.UpdateAsync(player, isAdmin, id, request); + }); - var all = await db.Suggestions.AsNoTracking().Include(s => s.Player).Select(s => new - { - s.Id, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - s.GameUrl, - s.MinPlayers, - s.MaxPlayers, - Author = s.Player!.DisplayName, - s.CreatedAt, - s.ParentSuggestionId, - IsOwner = s.PlayerId == player.Id - }).ToListAsync(); + group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) + return Results.Unauthorized(); - 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 => - { - var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList(); - - return new - { - s.Id, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - s.GameUrl, - s.MinPlayers, - s.MaxPlayers, - s.Author, - s.ParentSuggestionId, - s.IsOwner, - LinkedIds = linkedIds, - LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() - }; - }); - - return Results.Ok(ordered); + return await service.GetAllAsync(player); }); } } diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs new file mode 100644 index 0000000..57cb461 --- /dev/null +++ b/Endpoints/SuggestionWorkflowService.cs @@ -0,0 +1,227 @@ +using GameList.Contracts; +using GameList.Data; +using GameList.Domain; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory) +{ + public async Task GetMineAsync(Player player) + { + var mine = await db.Suggestions + .AsNoTracking() + .Where(s => s.PlayerId == player.Id) + .Select(s => new + { + s.Id, + s.PlayerId, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + s.GameUrl, + s.CreatedAt, + s.MinPlayers, + s.MaxPlayers, + s.ParentSuggestionId + }) + .ToListAsync(); + + var 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)); + + return Results.Ok(ordered); + } + + public async Task CreateAsync(Player player, SuggestionRequest request) + { + var validationError = await SuggestionValidator.ValidateAsync(request, httpFactory); + if (validationError is not null) + return Results.BadRequest(new { error = validationError }); + + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + var usingJoker = phase == Phase.Vote && player.HasJoker; + if (phase != Phase.Suggest && !usingJoker) + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + + if (string.IsNullOrWhiteSpace(player.DisplayName)) + return Results.BadRequest(new { error = "Set a display name before submitting suggestions." }); + + var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id); + if (!usingJoker && existingCount >= 5) + return Results.BadRequest(new { error = "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 + }; + + db.Suggestions.Add(suggestion); + + if (usingJoker) + { + player.HasJoker = false; + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + } + + await db.SaveChangesAsync(); + + return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id }); + } + + public async Task DeleteAsync(Player player, bool isAdmin, int suggestionId) + { + if (!isAdmin) + { + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + 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); + if (suggestion == null) + return Results.NotFound(new { error = "Suggestion not found." }); + + await db.Suggestions + .Where(s => s.ParentSuggestionId == suggestion.Id) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); + + await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync(); + + db.Suggestions.Remove(suggestion); + await db.SaveChangesAsync(); + return Results.NoContent(); + } + + public async Task UpdateAsync(Player player, bool isAdmin, int suggestionId, SuggestionRequest request) + { + var validationError = await SuggestionValidator.ValidateAsync(request, httpFactory); + if (validationError is not null) + return Results.BadRequest(new { error = validationError }); + + var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId); + if (suggestion == null) + return Results.NotFound(new { error = "Suggestion not found." }); + + if (!isAdmin) + { + if (suggestion.PlayerId != player.Id) + return Results.Unauthorized(); + + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + if (phase == Phase.Results) + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + + if (phase == Phase.Suggest) + { + suggestion.Name = request.Name.Trim(); + } + else if (phase != Phase.Vote) + { + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + } + + ApplyEditableFields(suggestion, request); + } + else + { + suggestion.Name = request.Name.Trim(); + ApplyEditableFields(suggestion, request); + } + + await db.SaveChangesAsync(); + + return Results.Ok(new + { + suggestion.Id, + suggestion.Name, + suggestion.Genre, + suggestion.Description, + suggestion.ScreenshotUrl, + suggestion.YoutubeUrl, + suggestion.GameUrl, + suggestion.MinPlayers, + suggestion.MaxPlayers + }); + } + + public async Task GetAllAsync(Player player) + { + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + if (phase < Phase.Vote) + return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + + var all = await db.Suggestions + .AsNoTracking() + .Include(s => s.Player) + .Select(s => new + { + s.Id, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + s.GameUrl, + s.MinPlayers, + s.MaxPlayers, + Author = s.Player!.DisplayName, + s.CreatedAt, + s.ParentSuggestionId, + IsOwner = s.PlayerId == player.Id + }) + .ToListAsync(); + + 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 => + { + var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList(); + + return new + { + s.Id, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + s.GameUrl, + s.MinPlayers, + s.MaxPlayers, + s.Author, + s.ParentSuggestionId, + s.IsOwner, + LinkedIds = linkedIds, + LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() + }; + }); + + return Results.Ok(ordered); + } + + private static void ApplyEditableFields(Suggestion suggestion, SuggestionRequest request) + { + 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; + } +} diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index 38cae66..29a390c 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -1,9 +1,7 @@ using GameList.Contracts; using GameList.Data; -using GameList.Domain; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using GameList.Infrastructure; +using GameList.Domain; namespace GameList.Endpoints; @@ -13,97 +11,30 @@ public static class VoteEndpoints { var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote)); - group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) => + group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, VoteWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - - var votes = await db.Votes.AsNoTracking().Where(v => v.PlayerId == player.Id).Select(v => new - { - v.SuggestionId, - v.Score - }).ToListAsync(); - - return Results.Ok(votes); + return await service.GetMineAsync(player); }); - group.MapPost("/", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) => + group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => { - if (request.Score is < 0 or > 10) - return Results.BadRequest(new { error = "Score must be between 0 and 10." }); - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - if (player.VotesFinal) - return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." }); - - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - - if (string.IsNullOrWhiteSpace(player.DisplayName)) - return Results.BadRequest(new { error = "Set a display name before voting." }); - - var linkMap = await db.Suggestions.AsNoTracking().Select(s => new - { - s.Id, - s.ParentSuggestionId - }).ToListAsync(); - var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); - if (!rootIndex.ContainsKey(request.SuggestionId)) - return Results.BadRequest(new { error = "Suggestion not found." }); - - var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex); - if (linkedIds.Count == 0) - linkedIds.Add(request.SuggestionId); - - var existingVotes = await db.Votes.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)).ToListAsync(); - - foreach (var suggestionId in linkedIds) - { - var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId); - if (vote == null) - { - db.Votes.Add(new Vote - { - PlayerId = player.Id, - SuggestionId = suggestionId, - Score = request.Score - }); - } - else - { - vote.Score = request.Score; - } - } - - await db.SaveChangesAsync(); - return Results.Ok(new - { - SuggestionIds = linkedIds, - request.Score - }); + return await service.UpsertAsync(player, request); }); - group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) => + group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); - - player.VotesFinal = request.Final; - await db.SaveChangesAsync(); - return Results.Ok(new { player.VotesFinal }); + return await service.SetFinalizeAsync(player, request); }); } } diff --git a/Endpoints/VoteWorkflowService.cs b/Endpoints/VoteWorkflowService.cs new file mode 100644 index 0000000..e0b4fb9 --- /dev/null +++ b/Endpoints/VoteWorkflowService.cs @@ -0,0 +1,100 @@ +using GameList.Contracts; +using GameList.Data; +using GameList.Domain; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +internal sealed class VoteWorkflowService(AppDbContext db) +{ + public async Task GetMineAsync(Player player) + { + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + if (phase != Phase.Vote) + return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + + var votes = await db.Votes + .AsNoTracking() + .Where(v => v.PlayerId == player.Id) + .Select(v => new + { + v.SuggestionId, + v.Score + }) + .ToListAsync(); + + return Results.Ok(votes); + } + + public async Task UpsertAsync(Player player, VoteRequest request) + { + if (request.Score is < 0 or > 10) + return Results.BadRequest(new { error = "Score must be between 0 and 10." }); + + if (player.VotesFinal) + return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." }); + + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + if (phase != Phase.Vote) + return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + + if (string.IsNullOrWhiteSpace(player.DisplayName)) + return Results.BadRequest(new { error = "Set a display name before voting." }); + + var linkMap = await db.Suggestions + .AsNoTracking() + .Select(s => new + { + s.Id, + s.ParentSuggestionId + }) + .ToListAsync(); + var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); + if (!rootIndex.ContainsKey(request.SuggestionId)) + return Results.BadRequest(new { error = "Suggestion not found." }); + + var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex); + if (linkedIds.Count == 0) + linkedIds.Add(request.SuggestionId); + + var existingVotes = await db.Votes + .Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)) + .ToListAsync(); + + foreach (var suggestionId in linkedIds) + { + var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId); + if (vote == null) + { + db.Votes.Add(new Vote + { + PlayerId = player.Id, + SuggestionId = suggestionId, + Score = request.Score + }); + } + else + { + vote.Score = request.Score; + } + } + + await db.SaveChangesAsync(); + return Results.Ok(new + { + SuggestionIds = linkedIds, + request.Score + }); + } + + public async Task SetFinalizeAsync(Player player, VoteFinalizeRequest request) + { + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + if (phase != Phase.Vote) + return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + + player.VotesFinal = request.Final; + await db.SaveChangesAsync(); + return Results.Ok(new { player.VotesFinal }); + } +} diff --git a/Program.cs b/Program.cs index 3b75f9e..c2de581 100644 --- a/Program.cs +++ b/Program.cs @@ -34,6 +34,8 @@ else if (!Path.IsPathRooted(connectionBuilder.DataSource)) var connectionString = connectionBuilder.ToString(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); diff --git a/REVIEW.md b/REVIEW.md index cc0d435..3e6aad4 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -9,7 +9,7 @@ Progress update (as of February 6, 2026): - Completed: admin auth docs aligned to account-based admin sessions (`API.md:3`). - Completed: build/test guardrails added (`.github/workflows/ci.yml`) and root ownership/setup docs added (`README.md:1`). - Completed: backend validators centralized for suggestions and auth (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`). -- Completed: request safety hardened for redirects and forwarded headers (`Program.cs:40`, `Program.cs:104`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`). +- Completed: request safety hardened for redirects and forwarded headers (`Program.cs:42`, `Program.cs:106`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`). Top 5 maintainability risks (priority order): @@ -19,7 +19,7 @@ Top 5 maintainability risks (priority order): - Impact: hard-to-debug regressions and fragile refactors in UI workflows. 2. Rule duplication still present between backend/frontend validations (High) -- Suggestion validation is centralized on the backend (`Endpoints/SuggestEndpoints.cs:45`, `Endpoints/SuggestEndpoints.cs:133`, `Endpoints/SuggestionValidator.cs:7`) but frontend still duplicates parts (`wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`). +- Suggestion validation is centralized on the backend (`Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:109`, `Endpoints/SuggestionValidator.cs:7`) but frontend still duplicates parts (`wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`). - Auth validation is centralized on the backend (`Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`) while frontend length checks remain duplicated (`wwwroot/app.js:92`, `wwwroot/app.js:121`). - Impact: inconsistent behavior and repeated fixes across server/client. @@ -29,8 +29,8 @@ Top 5 maintainability risks (priority order): - Hidden module coupling through globals: `wwwroot/js/data.js:131`-`wwwroot/js/data.js:134`, plus `window` callbacks consumed in `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:696`, `wwwroot/js/ui.js:1009`. - Impact: every UI change risks regressions outside its feature area. -4. Service-layer extraction is still pending in large endpoint files (High) -- Endpoint lambdas still own orchestration and persistence logic in `Endpoints/SuggestEndpoints.cs`, `Endpoints/AdminEndpoints.cs`, `Endpoints/VoteEndpoints.cs`, and `Endpoints/ResultsEndpoints.cs`. +4. Service-layer extraction is partially complete; admin/results workflows still inline (High) +- Suggestion and vote workflows have moved to services (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`), but admin/results orchestration remains endpoint-heavy (`Endpoints/AdminEndpoints.cs:105`, `Endpoints/ResultsEndpoints.cs:30`). - Impact: high cognitive load and slower, riskier feature changes. 5. Static-analysis and frontend lint guardrails remain incomplete (Medium) @@ -101,7 +101,7 @@ Worst coupling points: [P0][Partial] Centralize validation rules to stop backend/frontend drift - Problem: Severity `High`, Category `Complexity/Duplication`. Validation rules are duplicated in multiple backend endpoints and frontend forms. Impact: inconsistent behavior and repeated fixes. -- Evidence: backend centralized in `Endpoints/SuggestEndpoints.cs:45`, `Endpoints/SuggestEndpoints.cs:133`, `Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`; frontend duplicates remain in `wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`, `wwwroot/app.js:92`. +- Evidence: backend centralized in `Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:109`, `Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`; frontend duplicates remain in `wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`, `wwwroot/app.js:92`. - Recommendation: Extract backend validators (e.g., `SuggestionValidator`, `AuthValidator`) and reuse in create/update paths; simplify frontend to UX-only prechecks and rely on server responses for source-of-truth. - Acceptance criteria (testable): create/update share one backend validator path; tests cover validator once and both endpoints; frontend no longer re-implements server-only security rules. - Effort / Risk: `M / Med`. @@ -109,15 +109,15 @@ Worst coupling points: [P0][Done] Harden request safety defaults (forwarded headers and redirect handling) - Problem: Severity `High`, Category `Security`. Forwarded headers are trusted without explicit proxy/network allowlist, and image validation likely follows redirects despite "no redirects" policy. -- Evidence: `Program.cs:40`, `Program.cs:70`, `Program.cs:104`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`, `IIS.md:17`. +- Evidence: `Program.cs:42`, `Program.cs:72`, `Program.cs:106`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`, `IIS.md:17`. - Recommendation: Configure known proxies/networks for forwarded headers; enforce `AllowAutoRedirect = false` in image validation client and add tests for redirect-chain and private-host edge cases. - Acceptance criteria (testable): integration tests prove redirected URLs are rejected; forwarded header spoofing test fails when source is untrusted; documentation updated with trusted proxy requirements. - Effort / Risk: `M / Med`. - Dependencies (if any): none. -[P1] Extract service-layer workflows from endpoint lambdas +[P1][Partial] Extract service-layer workflows from endpoint lambdas - Problem: Severity `High`, Category `Architecture`. Endpoint files contain business orchestration, persistence, and policy logic inline; large lambdas are hard to reason about and reuse. -- Evidence: `Endpoints/SuggestEndpoints.cs:43`, `Endpoints/AdminEndpoints.cs:105`, `Endpoints/VoteEndpoints.cs:35`, `Endpoints/ResultsEndpoints.cs:30`. +- Evidence: extraction completed for suggestions and votes (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Program.cs:37`, `Program.cs:38`); remaining inline orchestration is concentrated in `Endpoints/AdminEndpoints.cs:105`, `Endpoints/ResultsEndpoints.cs:30`. - Recommendation: Introduce focused application services (`SuggestionService`, `VoteService`, `AdminWorkflowService`) and keep endpoints as transport adapters. - Acceptance criteria (testable): endpoint handlers reduced to routing + DTO mapping + service calls; domain rule tests target service methods directly; endpoint tests remain green. - Effort / Risk: `L / Med`. @@ -149,7 +149,7 @@ Worst coupling points: [P1] Make write workflows transaction-consistent and explicit - Problem: Severity `Medium`, Category `Correctness/Architecture`. Several multi-step state changes rely on multiple DB commands without explicit transaction grouping. -- Evidence: `Endpoints/SuggestEndpoints.cs:103`-`Endpoints/SuggestEndpoints.cs:109`, `Endpoints/AdminEndpoints.cs:16`-`Endpoints/AdminEndpoints.cs:31`, `Endpoints/AdminEndpoints.cs:220`-`Endpoints/AdminEndpoints.cs:229`. +- Evidence: `Endpoints/SuggestionWorkflowService.cs:71`, `Endpoints/SuggestionWorkflowService.cs:75`, `Endpoints/AdminEndpoints.cs:16`-`Endpoints/AdminEndpoints.cs:31`, `Endpoints/AdminEndpoints.cs:220`-`Endpoints/AdminEndpoints.cs:229`. - Recommendation: Wrap multi-entity updates in explicit transactions where consistency matters, or refactor into idempotent command handlers with compensating behavior. - Acceptance criteria (testable): fault-injection tests prove no partial state after exceptions; transaction boundaries documented per workflow. - Effort / Risk: `M / Med`.