Implement admin back-pass flow and guarded admin actions

This commit is contained in:
2026-02-08 14:20:38 +01:00
parent 4ee327fb4e
commit 5595bfd3b1
25 changed files with 572 additions and 109 deletions

View File

@@ -1,6 +1,7 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using GameList.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
@@ -21,7 +22,21 @@ internal sealed class AdminWorkflowService(AppDbContext db)
}
else
{
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
var playersWithSuggestions = await db.Suggestions.Select(s => s.PlayerId).Distinct().ToListAsync();
if (playersWithSuggestions.Count == 0)
{
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
}
else
{
await db.Players
.Where(p => playersWithSuggestions.Contains(p.Id))
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
await db.Players
.Where(p => !playersWithSuggestions.Contains(p.Id))
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
}
}
await db.SaveChangesAsync();
@@ -39,11 +54,32 @@ internal sealed class AdminWorkflowService(AppDbContext db)
.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 waiting = voters.Where(v => v.Phase == Phase.Vote && !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0;
return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
}
public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase)
{
if (phase != Phase.Suggest)
return EndpointHelpers.BadRequestError("Players can only be moved back to the Suggest phase.");
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
var current = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (current != Phase.Vote)
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase to move back.");
player.CurrentPhase = Phase.Suggest;
player.VotesFinal = false;
await db.SaveChangesAsync();
var state = await db.AppState.AsNoTracking().SingleAsync();
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, state.ResultsOpen));
}
public async Task<IResult> GrantJokerAsync(Guid playerId)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
@@ -61,8 +97,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
}
public async Task<IResult> DeletePlayerAsync(Guid playerId)
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string? adminPassword)
{
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
if (!passwordCheck.IsValid)
return passwordCheck.Error!;
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
@@ -178,8 +218,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
}
public async Task<IResult> ResetAsync()
public async Task<IResult> ResetAsync(Guid adminPlayerId, string? adminPassword)
{
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
if (!passwordCheck.IsValid)
return passwordCheck.Error!;
await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync();
@@ -195,8 +239,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
}
public async Task<IResult> FactoryResetAsync()
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string? adminPassword)
{
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
if (!passwordCheck.IsValid)
return passwordCheck.Error!;
await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync();
@@ -212,4 +260,19 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
}
private async Task<(bool IsValid, IResult? Error)> ValidateAdminPasswordAsync(Guid adminPlayerId, string? adminPassword)
{
if (string.IsNullOrEmpty(adminPassword))
return (false, EndpointHelpers.BadRequestError("Admin password is required."));
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId);
if (admin is null)
return (false, EndpointHelpers.UnauthorizedError());
if (!PasswordHasher.Verify(adminPassword, admin.PasswordHash, admin.PasswordSalt))
return (false, EndpointHelpers.UnauthorizedError("Invalid admin password."));
return (true, null);
}
}