Extract admin and results workflows into services
This commit is contained in:
@@ -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<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();
|
||||
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<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()
|
||||
});
|
||||
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<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
return await service.UnlinkSuggestionsAsync(player, request);
|
||||
});
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||
return Results.Ok(new
|
||||
admin.MapPost("/reset", async (AdminWorkflowService service) =>
|
||||
{
|
||||
UnlinkedSuggestionIds = Array.Empty<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
return await service.ResetAsync();
|
||||
});
|
||||
|
||||
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)))
|
||||
admin.MapPost("/factory-reset", async (AdminWorkflowService service) =>
|
||||
{
|
||||
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) =>
|
||||
{
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
249
Endpoints/AdminWorkflowService.cs
Normal file
249
Endpoints/AdminWorkflowService.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
group.MapGet("/", async (HttpContext ctx, AppDbContext db, ResultsWorkflowService service) =>
|
||||
{
|
||||
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);
|
||||
|
||||
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 await service.GetResultsAsync(player);
|
||||
});
|
||||
|
||||
return Results.Ok(shaped);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
85
Endpoints/ResultsWorkflowService.cs
Normal file
85
Endpoints/ResultsWorkflowService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ var connectionString = connectionBuilder.ToString();
|
||||
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
|
||||
builder.Services.AddScoped<SuggestionWorkflowService>();
|
||||
builder.Services.AddScoped<VoteWorkflowService>();
|
||||
builder.Services.AddScoped<AdminWorkflowService>();
|
||||
builder.Services.AddScoped<ResultsWorkflowService>();
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
||||
|
||||
|
||||
34
REVIEW.md
34
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.
|
||||
|
||||
Reference in New Issue
Block a user