using GameList.Contracts; using GameList.Data; using GameList.Domain; using GameList.Infrastructure; using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; internal sealed class AdminWorkflowService(AppDbContext db) { public async Task SetResultsOpenAsync(bool resultsOpen) { var state = await db.AppState.SingleAsync(); state.ResultsOpen = resultsOpen; state.UpdatedAt = DateTimeOffset.UtcNow; await using var tx = await db.Database.BeginTransactionAsync(); if (resultsOpen) { await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results)); } else { await db.Players .Where(p => p.Suggestions.Any()) .ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); await db.Players .Where(p => !p.Suggestions.Any()) .ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false)); } await db.SaveChangesAsync(); await tx.CommitAsync(); var currentState = await db.AppState.AsNoTracking().SingleAsync(); return Results.Ok(new AdminResultsStateResponse(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.IsAdmin, p.IsOwner, 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 VoteStatusResponse(voters, ready, waiting)); } public async Task GrantJokerAsync(Guid playerId) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return EndpointHelpers.NotFoundError("Player not found."); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); if (phase != Phase.Vote) return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker."); player.HasJoker = true; player.VotesFinal = false; await db.SaveChangesAsync(); return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker)); } public async Task SetPlayerPhaseAsync(Guid playerId, Phase phase) { if (phase != Phase.Suggest) return EndpointHelpers.BadRequestError("Only transition to Suggest is supported."); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return EndpointHelpers.NotFoundError("Player not found."); var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); if (currentPhase != Phase.Vote) return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase."); player.CurrentPhase = Phase.Suggest; player.VotesFinal = false; await db.SaveChangesAsync(); return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal)); } public async Task SetPlayerAdminAsync(Guid playerId, bool isAdmin) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return EndpointHelpers.NotFoundError("Player not found."); if (player.IsOwner) return EndpointHelpers.BadRequestError("Owner permissions cannot be changed."); player.IsAdmin = isAdmin; await db.SaveChangesAsync(); return Results.Ok(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin)); } public async Task DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) return passwordError; var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return EndpointHelpers.NotFoundError("Player not found."); if (player.IsOwner) return EndpointHelpers.BadRequestError("Owner account cannot be deleted."); 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 AdminDeletePlayerResponse(playerId)); } public async Task LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); if (sourceSuggestionId == targetSuggestionId) return EndpointHelpers.BadRequestError("Pick two different games to link."); var suggestions = await db.Suggestions.ToListAsync(); var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId); var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId); if (source is null || target is null) return EndpointHelpers.NotFoundError("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 EndpointHelpers.NotFoundError("Suggestion not found."); if (sourceRoot == targetRoot) return EndpointHelpers.BadRequestError("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 AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync())); } public async Task UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); var suggestions = await db.Suggestions.ToListAsync(); var target = suggestions.FirstOrDefault(s => s.Id == suggestionId); if (target is null) return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0)); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); if (!rootIndex.TryGetValue(target.Id, out var rootId)) return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty(), 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 AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync())); } public async Task ResetAsync(Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) return passwordError; await using var tx = await db.Database.BeginTransactionAsync(); 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.SingleAsync(); state.ResultsOpen = false; state.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); await tx.CommitAsync(); return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt)); } public async Task FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) return passwordError; 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 AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt)); } private async Task ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx) { if (string.IsNullOrWhiteSpace(password)) return EndpointHelpers.BadRequestError("Admin password is required."); var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin); if (admin is null) return EndpointHelpers.UnauthorizedError(); var monitor = ctx.RequestServices.GetRequiredService(); var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt); if (!verified) { monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password"); return EndpointHelpers.BadRequestError("Invalid admin password."); } monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername); return null; } }