diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 8a8d01d..de2a9bb 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -2,7 +2,6 @@ using GameList.Data; using GameList.Domain; using GameList.Contracts; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using GameList.Infrastructure; namespace GameList.Endpoints; @@ -13,250 +12,52 @@ public static class AdminEndpoints { var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter(); - admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, HttpContext _, AppDbContext db) => + admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => { - var state = await db.AppState.FirstAsync(); - state.ResultsOpen = request.ResultsOpen; - state.UpdatedAt = DateTimeOffset.UtcNow; - - if (request.ResultsOpen) - { - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results)); - } - else - { - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); - } - - await db.SaveChangesAsync(); - var currentState = await db.AppState.AsNoTracking().FirstAsync(); - return Results.Ok(new - { - currentState.ResultsOpen, - currentState.UpdatedAt - }); + return await service.SetResultsOpenAsync(request); }); - admin.MapGet("/vote-status", async (HttpContext _, AppDbContext db) => + admin.MapGet("/vote-status", async (AdminWorkflowService service) => { - 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.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 - { - voters, - ready, - waiting - }); + return await service.GetVoteStatusAsync(); }); - admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext _, AppDbContext db) => + admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => { - var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId); - if (player is null) - return Results.NotFound(new { error = "Player not found." }); - - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase != Phase.Vote) - return Results.BadRequest(new { error = "Player must be in the Vote phase to receive a joker." }); - - player.HasJoker = true; - player.VotesFinal = false; - await db.SaveChangesAsync(); - - return Results.Ok(new - { - player.Id, - player.HasJoker - }); + return await service.GrantJokerAsync(request); }); - admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext _, AppDbContext db) => + admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => { - var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); - if (player is null) - return Results.NotFound(new { error = "Player not found." }); - - await using var tx = await db.Database.BeginTransactionAsync(); - - // Remove votes cast by the player - await db.Votes.Where(v => v.PlayerId == playerId).ExecuteDeleteAsync(); - - // Collect suggestions authored by the player - var suggestionIds = player.Suggestions.Select(s => s.Id).ToList(); - if (suggestionIds.Count > 0) - { - // Break links pointing to these suggestions - await db.Suggestions.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); - - // Remove votes for these suggestions to avoid orphaned rows - await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); - } - - // Delete player (cascades suggestions) - db.Players.Remove(player); - await db.SaveChangesAsync(); - await tx.CommitAsync(); - - return Results.Ok(new { DeletedPlayerId = playerId }); + return await service.DeletePlayerAsync(playerId); }); - admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) => + admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService 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); - - if (request.SourceSuggestionId == request.TargetSuggestionId) - return Results.BadRequest(new { error = "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); - if (source is null || target is null) - return Results.NotFound(new { error = "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 Results.NotFound(new { error = "Suggestion not found." }); - - if (sourceRoot == targetRoot) - return Results.BadRequest(new { error = "These games are already linked." }); - - var affectedRootIds = new HashSet - { - sourceRoot, - targetRoot - }; - var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList(); - - await using var tx = await db.Database.BeginTransactionAsync(); - - foreach (var suggestion in suggestions) - { - var root = rootIndex.GetValueOrDefault(suggestion.Id); - if (root == targetRoot) - { - suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot; - } - else if (root == sourceRoot) - { - suggestion.ParentSuggestionId = targetRoot; - } - } - - await db.SaveChangesAsync(); - - await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); - - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); - - await tx.CommitAsync(); - - return Results.Ok(new - { - RootId = targetRoot, - LinkedSuggestionIds = affectedIds, - UnfinalizedPlayers = await db.Players.CountAsync() - }); + return await service.LinkSuggestionsAsync(player, request); }); - admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) => + admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService 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 suggestions = await db.Suggestions.ToListAsync(); - var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId); - if (target is null) - return Results.Ok(new - { - UnlinkedSuggestionIds = Array.Empty(), - UnfinalizedPlayers = 0 - }); - - var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); - if (!rootIndex.TryGetValue(target.Id, out var rootId)) - return Results.Ok(new - { - UnlinkedSuggestionIds = Array.Empty(), - UnfinalizedPlayers = 0 - }); - - var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList(); - - await using var tx = await db.Database.BeginTransactionAsync(); - - foreach (var suggestion in suggestions.Where(s => groupIds.Contains(s.Id))) - { - suggestion.ParentSuggestionId = null; - } - - await db.SaveChangesAsync(); - - await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); - - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); - - await tx.CommitAsync(); - - return Results.Ok(new - { - UnlinkedSuggestionIds = groupIds, - UnfinalizedPlayers = await db.Players.CountAsync() - }); + return await service.UnlinkSuggestionsAsync(player, request); }); - admin.MapPost("/reset", async (HttpContext _, AppDbContext db) => + admin.MapPost("/reset", async (AdminWorkflowService service) => { - await db.Votes.ExecuteDeleteAsync(); - await db.Suggestions.ExecuteDeleteAsync(); - - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false)); - var state = await db.AppState.FirstAsync(); - state.ResultsOpen = false; - state.UpdatedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(); - - return Results.Ok(new - { - Phase = Phase.Suggest, - state.ResultsOpen, - state.UpdatedAt - }); + return await service.ResetAsync(); }); - admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) => + admin.MapPost("/factory-reset", async (AdminWorkflowService service) => { - await using var tx = await db.Database.BeginTransactionAsync(); - - await db.Votes.ExecuteDeleteAsync(); - await db.Suggestions.ExecuteDeleteAsync(); - await db.Players.ExecuteDeleteAsync(); - await db.AppState.ExecuteDeleteAsync(); - - var fresh = EndpointHelpers.NewAppState(); - db.AppState.Add(fresh); - await db.SaveChangesAsync(); - - await tx.CommitAsync(); - - return Results.Ok(new - { - Phase = Phase.Suggest, - fresh.ResultsOpen, - fresh.UpdatedAt - }); + return await service.FactoryResetAsync(); }); } } diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs new file mode 100644 index 0000000..c7e6717 --- /dev/null +++ b/Endpoints/AdminWorkflowService.cs @@ -0,0 +1,249 @@ +using GameList.Contracts; +using GameList.Data; +using GameList.Domain; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +internal sealed class AdminWorkflowService(AppDbContext db) +{ + public async Task SetResultsOpenAsync(ResultsOpenRequest request) + { + var state = await db.AppState.FirstAsync(); + state.ResultsOpen = request.ResultsOpen; + state.UpdatedAt = DateTimeOffset.UtcNow; + + if (request.ResultsOpen) + { + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results)); + } + else + { + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); + } + + await db.SaveChangesAsync(); + var currentState = await db.AppState.AsNoTracking().FirstAsync(); + return Results.Ok(new + { + currentState.ResultsOpen, + currentState.UpdatedAt + }); + } + + 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.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 + { + voters, + ready, + waiting + }); + } + + public async Task GrantJokerAsync(GrantJokerRequest request) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId); + if (player is null) + return Results.NotFound(new { error = "Player not found." }); + + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + if (phase != Phase.Vote) + return Results.BadRequest(new { error = "Player must be in the Vote phase to receive a joker." }); + + player.HasJoker = true; + player.VotesFinal = false; + await db.SaveChangesAsync(); + + return Results.Ok(new + { + player.Id, + player.HasJoker + }); + } + + public async Task DeletePlayerAsync(Guid playerId) + { + var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); + if (player is null) + return Results.NotFound(new { error = "Player not found." }); + + await using var tx = await db.Database.BeginTransactionAsync(); + + await db.Votes.Where(v => v.PlayerId == playerId).ExecuteDeleteAsync(); + + var suggestionIds = player.Suggestions.Select(s => s.Id).ToList(); + if (suggestionIds.Count > 0) + { + await db.Suggestions + .Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); + + await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); + } + + db.Players.Remove(player); + await db.SaveChangesAsync(); + await tx.CommitAsync(); + + return Results.Ok(new { DeletedPlayerId = playerId }); + } + + public async Task LinkSuggestionsAsync(Player adminPlayer, LinkSuggestionsRequest request) + { + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayer.Id); + if (phase != Phase.Vote) + return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + + if (request.SourceSuggestionId == request.TargetSuggestionId) + return Results.BadRequest(new { error = "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); + if (source is null || target is null) + return Results.NotFound(new { error = "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 Results.NotFound(new { error = "Suggestion not found." }); + + if (sourceRoot == targetRoot) + return Results.BadRequest(new { error = "These games are already linked." }); + + var affectedRootIds = new HashSet + { + sourceRoot, + targetRoot + }; + var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList(); + + await using var tx = await db.Database.BeginTransactionAsync(); + + foreach (var suggestion in suggestions) + { + var root = rootIndex.GetValueOrDefault(suggestion.Id); + if (root == targetRoot) + { + suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot; + } + else if (root == sourceRoot) + { + suggestion.ParentSuggestionId = targetRoot; + } + } + + await db.SaveChangesAsync(); + + await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); + + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + + await tx.CommitAsync(); + + return Results.Ok(new + { + RootId = targetRoot, + LinkedSuggestionIds = affectedIds, + UnfinalizedPlayers = await db.Players.CountAsync() + }); + } + + public async Task UnlinkSuggestionsAsync(Player adminPlayer, UnlinkSuggestionsRequest request) + { + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayer.Id); + 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); + if (target is null) + return Results.Ok(new + { + UnlinkedSuggestionIds = Array.Empty(), + UnfinalizedPlayers = 0 + }); + + var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); + if (!rootIndex.TryGetValue(target.Id, out var rootId)) + return Results.Ok(new + { + UnlinkedSuggestionIds = Array.Empty(), + UnfinalizedPlayers = 0 + }); + + var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList(); + + await using var tx = await db.Database.BeginTransactionAsync(); + + foreach (var suggestion in suggestions.Where(s => groupIds.Contains(s.Id))) + { + suggestion.ParentSuggestionId = null; + } + + await db.SaveChangesAsync(); + + await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); + + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + + await tx.CommitAsync(); + + return Results.Ok(new + { + UnlinkedSuggestionIds = groupIds, + UnfinalizedPlayers = await db.Players.CountAsync() + }); + } + + public async Task ResetAsync() + { + await db.Votes.ExecuteDeleteAsync(); + await db.Suggestions.ExecuteDeleteAsync(); + + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false)); + var state = await db.AppState.FirstAsync(); + state.ResultsOpen = false; + state.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + return Results.Ok(new + { + Phase = Phase.Suggest, + state.ResultsOpen, + state.UpdatedAt + }); + } + + public async Task FactoryResetAsync() + { + await using var tx = await db.Database.BeginTransactionAsync(); + + await db.Votes.ExecuteDeleteAsync(); + await db.Suggestions.ExecuteDeleteAsync(); + await db.Players.ExecuteDeleteAsync(); + await db.AppState.ExecuteDeleteAsync(); + + var fresh = EndpointHelpers.NewAppState(); + db.AppState.Add(fresh); + await db.SaveChangesAsync(); + + await tx.CommitAsync(); + + return Results.Ok(new + { + Phase = Phase.Suggest, + fresh.ResultsOpen, + fresh.UpdatedAt + }); + } +} diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index 2501bf3..57cae20 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -1,7 +1,6 @@ using GameList.Data; -using GameList.Domain; -using Microsoft.EntityFrameworkCore; using GameList.Infrastructure; +using GameList.Domain; namespace GameList.Endpoints; @@ -13,87 +12,14 @@ public static class ResultsEndpoints .RequireAuthorization() .AddEndpointFilter(new PhaseRequirementFilter(Phase.Results)); - group.MapGet( - "/", - async (HttpContext ctx, AppDbContext db) => - { - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); - if (player is null) - return Results.Unauthorized(); - var appState = await db.AppState.AsNoTracking().FirstAsync(); - if (!appState.ResultsOpen) - return Results.BadRequest(new { error = "Results are locked until the admin enables them." }); - var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (phase != Phase.Results) - return EndpointHelpers.PhaseMismatch(Phase.Results, phase); + group.MapGet("/", async (HttpContext ctx, AppDbContext db, ResultsWorkflowService service) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) + return Results.Unauthorized(); - var results = await db - .Suggestions.AsNoTracking() - .Include(s => s.Player) - .Include(s => s.Votes) - .Select(s => new - { - s.Id, - s.Name, - Author = s.Player!.DisplayName, - s.MinPlayers, - s.MaxPlayers, - Total = s.Votes.Sum(v => v.Score), - s.Votes.Count, - 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) - .Select(v => (int?)v.Score) - .FirstOrDefault(), - s.ScreenshotUrl, - s.YoutubeUrl, - s.GameUrl, - s.Description, - s.Genre, - s.ParentSuggestionId - }) - .OrderByDescending(r => r.Average) - .ToListAsync(); - - 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 => - { - var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex) - .Where(id => id != r.Id) - .ToList(); - - return new - { - r.Id, - r.Name, - r.Author, - r.MinPlayers, - r.MaxPlayers, - r.Total, - r.Count, - r.Average, - r.Votes, - r.MyVote, - r.ScreenshotUrl, - r.YoutubeUrl, - r.GameUrl, - r.Description, - r.Genre, - r.ParentSuggestionId, - LinkedIds = linkedIds, - LinkedTitles = linkedIds - .Where(nameLookup.ContainsKey) - .Select(id => nameLookup[id]) - .ToList() - }; - }); - - return Results.Ok(shaped); - } - ); + return await service.GetResultsAsync(player); + }); } } diff --git a/Endpoints/ResultsWorkflowService.cs b/Endpoints/ResultsWorkflowService.cs new file mode 100644 index 0000000..3aff578 --- /dev/null +++ b/Endpoints/ResultsWorkflowService.cs @@ -0,0 +1,85 @@ +using GameList.Data; +using GameList.Domain; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +internal sealed class ResultsWorkflowService(AppDbContext db) +{ + public async Task GetResultsAsync(Player player) + { + var appState = await db.AppState.AsNoTracking().FirstAsync(); + if (!appState.ResultsOpen) + return Results.BadRequest(new { error = "Results are locked until the admin enables them." }); + + var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); + if (phase != Phase.Results) + return EndpointHelpers.PhaseMismatch(Phase.Results, phase); + + var results = await db + .Suggestions.AsNoTracking() + .Include(s => s.Player) + .Include(s => s.Votes) + .Select(s => new + { + s.Id, + s.Name, + Author = s.Player!.DisplayName, + s.MinPlayers, + s.MaxPlayers, + Total = s.Votes.Sum(v => v.Score), + s.Votes.Count, + 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) + .Select(v => (int?)v.Score) + .FirstOrDefault(), + s.ScreenshotUrl, + s.YoutubeUrl, + s.GameUrl, + s.Description, + s.Genre, + s.ParentSuggestionId + }) + .OrderByDescending(r => r.Average) + .ToListAsync(); + + 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 => + { + var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex) + .Where(id => id != r.Id) + .ToList(); + + return new + { + r.Id, + r.Name, + r.Author, + r.MinPlayers, + r.MaxPlayers, + r.Total, + r.Count, + r.Average, + r.Votes, + r.MyVote, + r.ScreenshotUrl, + r.YoutubeUrl, + r.GameUrl, + r.Description, + r.Genre, + r.ParentSuggestionId, + LinkedIds = linkedIds, + LinkedTitles = linkedIds + .Where(nameLookup.ContainsKey) + .Select(id => nameLookup[id]) + .ToList() + }; + }); + + return Results.Ok(shaped); + } +} diff --git a/Program.cs b/Program.cs index c2de581..61d38b6 100644 --- a/Program.cs +++ b/Program.cs @@ -36,6 +36,8 @@ var connectionString = connectionBuilder.ToString(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.AddScoped(); builder.Services.AddScoped(); +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 3e6aad4..b8b14cc 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -4,12 +4,12 @@ This codebase is functional and reasonably tested on backend behavior, but long-term change safety is currently limited by concentration risk (god modules), hidden side effects in read paths, and drifting operational contracts. -Progress update (as of February 6, 2026): +Progress update (as of February 7, 2026): - Completed: phase reads are side-effect free with explicit reconciliation on write routes (`Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:62`). - 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:42`, `Program.cs:106`, `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:44`, `Program.cs:108`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`). Top 5 maintainability risks (priority order): @@ -29,9 +29,9 @@ 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 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. +4. Endpoint contract consistency and response shaping are still uneven (High) +- Service-layer extraction is now in place for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:7`), but response shapes are still mostly anonymous objects and ad-hoc error payloads. +- Impact: API evolution and client compatibility changes are still high-friction. 5. Static-analysis and frontend lint guardrails remain incomplete (Medium) - Build/test CI exists (`.github/workflows/ci.yml`) and project content rules are fixed (`GameList.csproj:17`-`GameList.csproj:21`), but analyzers/lint/format gates are still absent. @@ -71,13 +71,13 @@ Worst coupling points: - `Endpoints/EndpointHelpers.cs` (84 call sites): de facto god module. - `wwwroot/js/ui.js` + global `state` object (131 direct state references across JS modules). - Suggestion and phase workflows span endpoint functions, helper functions, filters, and duplicated client-side checks. -- Operational behavior is split across `API.md` and runtime filters with contradictory contracts. +- Operational behavior is documented but still spread across multiple files (`API.md`, `IIS.md`, `README.md`) without a single versioned runbook artifact. ## C) Critical task list [P0][Done] Make phase reads side-effect free and move reconciliation to explicit writes - Problem: Severity `Critical`, Category `Architecture`. Read endpoints/filters previously relied on mutating phase reads. Impact: unsafe refactors and non-deterministic behavior. -- Evidence: `Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:20`, `Infrastructure/PhaseRequirementFilter.cs:17`, `Endpoints/ResultsEndpoints.cs:26`, `GameList.Tests/StateTests.cs:236`, `GameList.Tests/FiltersTests.cs:55`. +- Evidence: `Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:20`, `Infrastructure/PhaseRequirementFilter.cs:17`, `Endpoints/ResultsWorkflowService.cs:9`, `GameList.Tests/StateTests.cs:236`, `GameList.Tests/FiltersTests.cs:55`. - Recommendation: Split into `GetCurrentPhaseAsync` (pure read) and explicit `ReconcilePhaseAsync` (write command). Run reconciliation only on intentional transition points (admin toggle, phase change commands, migration job), not on GET paths. - Acceptance criteria (testable): GET `/api/state` and GET `/api/me` never call `SaveChangesAsync`; integration tests verify no phase mutations occur during read-only requests; filters perform one phase check path without side effects. - 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:42`, `Program.cs:72`, `Program.cs:106`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`, `IIS.md:17`. +- Evidence: `Program.cs:44`, `Program.cs:74`, `Program.cs:108`, `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][Partial] Extract service-layer workflows from endpoint lambdas +[P1][Done] 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: 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`. +- Evidence: extraction completed for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:7`, `Program.cs:37`, `Program.cs:38`, `Program.cs:39`, `Program.cs:40`). - 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/SuggestionWorkflowService.cs:71`, `Endpoints/SuggestionWorkflowService.cs:75`, `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/AdminWorkflowService.cs:74`, `Endpoints/AdminWorkflowService.cs:208`, `Endpoints/AdminWorkflowService.cs:227`. - 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`. @@ -171,9 +171,9 @@ Worst coupling points: - Effort / Risk: `M / Low`. - Dependencies (if any): frontend module split helps. -[P2] Improve repository-level engineering docs and ownership map -- Problem: Severity `Low`, Category `Documentation`. There is no root README/architecture map/runbook tying module ownership, local setup, and deployment flow together. -- Evidence: root README absent; operational docs fragmented across `API.md`, `SPEC.md`, `IIS.md`, `scripts/deploy-ftp.ps1`. +[P2][Done] Improve repository-level engineering docs and ownership map +- Problem: Severity `Low`, Category `Documentation`. Contributor setup and ownership context needed consolidation. +- Evidence: root `README.md` now provides setup/ownership map and links to `API.md`, `SPEC.md`, and `IIS.md`. - Recommendation: add concise `README.md` with architecture diagram, local run/test commands, operational boundaries, and links to deeper docs. - Acceptance criteria (testable): new contributors can run app + tests using README only; docs align with live auth/deploy behavior. - Effort / Risk: `S / Low`. @@ -182,9 +182,9 @@ Worst coupling points: ## D) Quick wins vs strategic refactors Quick wins (hours to 1 day each): -- Remove stale admin key wording in `API.md` and align with actual auth behavior. -- Add `Content Remove="GameList.Tests\**\*"` (or equivalent) and verify clean build output. -- Add a build check script that fails on warnings and run it locally/CI. +- Completed: removed stale admin key wording in `API.md` and aligned with actual auth behavior. +- Completed: added `Content Remove="GameList.Tests\**\*"` and verified clean build output. +- Completed: added CI build/test gate for warning regressions. - Completed: removed dead `DeletePlayerRequest` from `Contracts/Dtos.cs`. - Completed: removed unused endpoint handler parameters in `Endpoints/StateEndpoints.cs`. - Remove dead UI references (`all-suggestions`, `nav-vote-next`) or add explicit TODO with owner/date.