Extract admin and results workflows into services

This commit is contained in:
2026-02-07 01:06:22 +01:00
parent 5d40d555d1
commit 0d60108036
6 changed files with 377 additions and 314 deletions

View File

@@ -2,7 +2,6 @@ using GameList.Data;
using GameList.Domain; using GameList.Domain;
using GameList.Contracts; using GameList.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using GameList.Infrastructure; using GameList.Infrastructure;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -13,250 +12,52 @@ public static class AdminEndpoints
{ {
var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>(); var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>();
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(); return await service.SetResultsOpenAsync(request);
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
});
}); });
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(); return await service.GetVoteStatusAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0;
return Results.Ok(new
{
voters,
ready,
waiting
});
}); });
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); return await service.GrantJokerAsync(request);
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
});
}); });
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); return await service.DeletePlayerAsync(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 });
}); });
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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return Results.Unauthorized();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.LinkSuggestionsAsync(player, request);
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<int>
{
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()
});
}); });
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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return Results.Unauthorized();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); return await service.UnlinkSuggestionsAsync(player, request);
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<int>(),
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<int>(),
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()
});
}); });
admin.MapPost("/reset", async (HttpContext _, AppDbContext db) => admin.MapPost("/reset", async (AdminWorkflowService service) =>
{ {
await db.Votes.ExecuteDeleteAsync(); return await service.ResetAsync();
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
});
}); });
admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) => admin.MapPost("/factory-reset", async (AdminWorkflowService service) =>
{ {
await using var tx = await db.Database.BeginTransactionAsync(); return await service.FactoryResetAsync();
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
});
}); });
} }
} }

View File

@@ -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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<int>
{
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<IResult> 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<int>(),
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<int>(),
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<IResult> 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<IResult> 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
});
}
}

View File

@@ -1,7 +1,6 @@
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Domain;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -13,87 +12,14 @@ public static class ResultsEndpoints
.RequireAuthorization() .RequireAuthorization()
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Results)); .AddEndpointFilter(new PhaseRequirementFilter(Phase.Results));
group.MapGet( group.MapGet("/", async (HttpContext ctx, AppDbContext db, ResultsWorkflowService service) =>
"/", {
async (HttpContext ctx, AppDbContext db) => var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
{ if (player is null)
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); return Results.Unauthorized();
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);
var results = await db return await service.GetResultsAsync(player);
.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);
}
);
} }
} }

View File

@@ -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<IResult> 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);
}
}

View File

@@ -36,6 +36,8 @@ var connectionString = connectionBuilder.ToString();
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString)); builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<SuggestionWorkflowService>(); builder.Services.AddScoped<SuggestionWorkflowService>();
builder.Services.AddScoped<VoteWorkflowService>(); builder.Services.AddScoped<VoteWorkflowService>();
builder.Services.AddScoped<AdminWorkflowService>();
builder.Services.AddScoped<ResultsWorkflowService>();
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });

View File

@@ -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. 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: 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: 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: 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: 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): 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`. - 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. - Impact: every UI change risks regressions outside its feature area.
4. Service-layer extraction is partially complete; admin/results workflows still inline (High) 4. Endpoint contract consistency and response shaping are still uneven (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`). - 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: high cognitive load and slower, riskier feature changes. - Impact: API evolution and client compatibility changes are still high-friction.
5. Static-analysis and frontend lint guardrails remain incomplete (Medium) 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. - 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. - `Endpoints/EndpointHelpers.cs` (84 call sites): de facto god module.
- `wwwroot/js/ui.js` + global `state` object (131 direct state references across JS modules). - `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. - 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 ## C) Critical task list
[P0][Done] Make phase reads side-effect free and move reconciliation to explicit writes [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. - 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. - 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. - 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`. - Effort / Risk: `M / Med`.
@@ -109,15 +109,15 @@ Worst coupling points:
[P0][Done] Harden request safety defaults (forwarded headers and redirect handling) [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. - 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. - 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. - 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`. - Effort / Risk: `M / Med`.
- Dependencies (if any): none. - 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. - 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. - 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. - 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`. - Effort / Risk: `L / Med`.
@@ -149,7 +149,7 @@ Worst coupling points:
[P1] Make write workflows transaction-consistent and explicit [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. - 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. - 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. - Acceptance criteria (testable): fault-injection tests prove no partial state after exceptions; transaction boundaries documented per workflow.
- Effort / Risk: `M / Med`. - Effort / Risk: `M / Med`.
@@ -171,9 +171,9 @@ Worst coupling points:
- Effort / Risk: `M / Low`. - Effort / Risk: `M / Low`.
- Dependencies (if any): frontend module split helps. - Dependencies (if any): frontend module split helps.
[P2] Improve repository-level engineering docs and ownership map [P2][Done] 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. - Problem: Severity `Low`, Category `Documentation`. Contributor setup and ownership context needed consolidation.
- Evidence: root README absent; operational docs fragmented across `API.md`, `SPEC.md`, `IIS.md`, `scripts/deploy-ftp.ps1`. - 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. - 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. - Acceptance criteria (testable): new contributors can run app + tests using README only; docs align with live auth/deploy behavior.
- Effort / Risk: `S / Low`. - Effort / Risk: `S / Low`.
@@ -182,9 +182,9 @@ Worst coupling points:
## D) Quick wins vs strategic refactors ## D) Quick wins vs strategic refactors
Quick wins (hours to 1 day each): Quick wins (hours to 1 day each):
- Remove stale admin key wording in `API.md` and align with actual auth behavior. - Completed: removed stale admin key wording in `API.md` and aligned with actual auth behavior.
- Add `Content Remove="GameList.Tests\**\*"` (or equivalent) and verify clean build output. - Completed: added `Content Remove="GameList.Tests\**\*"` and verified clean build output.
- Add a build check script that fails on warnings and run it locally/CI. - Completed: added CI build/test gate for warning regressions.
- Completed: removed dead `DeletePlayerRequest` from `Contracts/Dtos.cs`. - Completed: removed dead `DeletePlayerRequest` from `Contracts/Dtos.cs`.
- Completed: removed unused endpoint handler parameters in `Endpoints/StateEndpoints.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. - Remove dead UI references (`all-suggestions`, `nav-vote-next`) or add explicit TODO with owner/date.