302 lines
12 KiB
C#
302 lines
12 KiB
C#
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<IResult> 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<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.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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<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 AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
|
|
}
|
|
|
|
public async Task<IResult> 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<int>(), 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<int>(), 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<IResult> 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<IResult> 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<IResult?> 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<AuthAttemptMonitor>();
|
|
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;
|
|
}
|
|
}
|