Add admin status combobox to move voters back to suggest

This commit is contained in:
2026-02-08 15:00:09 +01:00
parent fadd72d5c4
commit 96a47020d8
17 changed files with 156 additions and 5 deletions

1
API.md
View File

@@ -36,5 +36,6 @@ POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and al
GET /api/admin/vote-status — readiness overview (who finalized)
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
POST /api/admin/reset — clear suggestions/votes; keep players; reset phases/vote-final flags
POST /api/admin/factory-reset — wipe players, suggestions, votes, state

View File

@@ -19,3 +19,5 @@ public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestio
public record UnlinkSuggestionsRequest(int SuggestionId);
public record GrantJokerRequest(Guid PlayerId);
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);

View File

@@ -24,6 +24,8 @@ public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset Updated
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);

View File

@@ -17,6 +17,8 @@ public static class AdminEndpoints
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => await service.DeletePlayerAsync(playerId));
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>

View File

@@ -66,6 +66,26 @@ internal sealed class AdminWorkflowService(AppDbContext db)
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> DeletePlayerAsync(Guid playerId)
{
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);

View File

@@ -59,6 +59,63 @@ public class AdminTests
Assert.Equal(HttpStatusCode.BadRequest, give.StatusCode);
}
[Fact]
public async Task Admin_can_move_vote_player_back_to_suggest()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
await player.CreateSuggestionAsync("Game");
await player.PostAsJsonAsync("/api/me/phase/next", new { });
await factory.WithDbContextAsync(async db =>
{
var p = await db.Players.SingleAsync(x => x.Username == "player");
p.VotesFinal = true;
await db.SaveChangesAsync();
});
var resp = await admin.PostAsJsonAsync("/api/admin/player-phase", new
{
playerId = await player.GetProfileIdAsync(),
phase = nameof(Phase.Suggest)
});
resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var p = await db.Players.SingleAsync(x => x.Username == "player");
Assert.Equal(Phase.Suggest, p.CurrentPhase);
Assert.False(p.VotesFinal);
});
}
[Fact]
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
var wrongTarget = await admin.PostAsJsonAsync("/api/admin/player-phase", new
{
playerId = await player.GetProfileIdAsync(),
phase = nameof(Phase.Results)
});
Assert.Equal(HttpStatusCode.BadRequest, wrongTarget.StatusCode);
var wrongCurrentPhase = await admin.PostAsJsonAsync("/api/admin/player-phase", new
{
playerId = await player.GetProfileIdAsync(),
phase = nameof(Phase.Suggest)
});
Assert.Equal(HttpStatusCode.BadRequest, wrongCurrentPhase.StatusCode);
}
[Fact]
public async Task Delete_player_cascades_suggestions_and_votes()
{

View File

@@ -26,6 +26,7 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Players see only their own votes; can finalize/unfinalize their ballot
- **Linked games**: admins can link duplicates; linked games share a vote group. Moving a slider on one updates all linked siblings.
- Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again
- Admin status controls can move a player from Vote back to Suggest for exceptional cases
- The “new/linked games” vote popup appears only when the vote list changes after the player has already seen that vote list
## Results Phase

View File

@@ -8,7 +8,7 @@ Purpose: full coverage of backend + critical UI flows using a mock (in-memory) S
| --- | --- | --- | --- | --- |
| Unauthenticated visitor | No API access; only static assets | — | — | Health check only |
| Player (non-admin) | Create/see own suggestions (≤5), edit all fields, delete own; can advance to Vote; title locks after leaving phase | View all suggestions, vote 010, finalize/unfinalize, use joker once to add a game; cannot go backward | Read leaderboard only when resultsOpen=true; no writes | Login/logout, read /state and /me |
| Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward |
| Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players, move a voter back to Suggest | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward |
## Phase/Permission Chart (for tests)
```mermaid
@@ -68,6 +68,7 @@ stateDiagram-v2
- POST /admin/results toggles resultsOpen and aligns all player phases (to Results or back to Vote clearing votesFinal); updates UpdatedAt.
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
- DELETE /admin/players/{id}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.

View File

@@ -47,7 +47,7 @@ async function refreshWithUiErrorHandling() {
function scheduleNextRefresh() {
refreshTimerId = window.setTimeout(async () => {
if (!document.hidden) {
if (!document.hidden && !state.adminStatusSelectActive) {
await refreshWithUiErrorHandling();
}
scheduleNextRefresh();
@@ -59,7 +59,7 @@ function startRefreshScheduler() {
refreshSchedulerStarted = true;
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
if (!document.hidden && !state.adminStatusSelectActive) {
refreshWithUiErrorHandling();
}
});

View File

@@ -55,3 +55,9 @@
border: 1px solid #e3d4bd;
background: #fffaf3;
}
.admin-status-select {
width: 100%;
min-width: 140px;
background: #fffaf3;
}

View File

@@ -150,6 +150,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
### Was können Admin-Konten tun?
- Joker während der Abstimmung vergeben
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
- Doppelte Vorschläge verknüpfen oder trennen
- Vorschläge löschen
- Abstimmungsstatus einsehen (wer finalisiert hat)

View File

@@ -154,6 +154,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
### What can admin accounts do?
- Grant jokers during Vote
- Move a voter back to Suggest (stronger than a joker; use sparingly)
- Link or unlink duplicate suggestions
- Delete suggestions
- View vote readiness (who has finalized)

View File

@@ -121,6 +121,8 @@
"admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting",
"admin.statusFinished": "Finished",
"admin.statusMoveToSuggest": "Move to Suggest",
"admin.statusUpdated": "Player phase updated",
"admin.deleteTitle": "Delete account?",
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
"admin.deleteConfirm": "Delete",
@@ -281,6 +283,8 @@
"admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig",
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
"admin.statusUpdated": "Spielerphase aktualisiert",
"admin.deleteTitle": "Konto löschen?",
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
"admin.deleteConfirm": "Löschen",

View File

@@ -15,8 +15,20 @@ function displayPlayerStatus(player) {
return phase;
}
function buildStatusSelect(player) {
const statusText = displayPlayerStatus(player);
const canMoveToSuggest = player.phase === "Vote";
return `
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
<option value="" selected>${statusText}</option>
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
</select>
`;
}
export function renderAdminVoteStatus() {
if (!state.me?.isAdmin) return;
if (state.adminStatusSelectActive) return;
const statusBadge = $("admin-ready-status");
const table = $("admin-player-table")?.querySelector("tbody");
if (!state.adminVoteStatus || !statusBadge || !table) return;
@@ -24,14 +36,13 @@ export function renderAdminVoteStatus() {
table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr");
const statusText = displayPlayerStatus(v);
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = `
<td title="${escapeHtml(v.name)}">${nameText}</td>
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${statusText}</td>
<td>${buildStatusSelect(v)}</td>
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>

View File

@@ -59,6 +59,8 @@ export const adminApi = {
reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
setPlayerPhase: (playerId, phase) =>
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),

View File

@@ -91,6 +91,44 @@ function setupLinkApply(runSerializedRefresh) {
function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table");
if (!playerTable) return;
const phaseSelectSelector = "[data-set-player-phase]";
playerTable.addEventListener("focusin", (e) => {
if (e.target.matches?.(phaseSelectSelector)) {
state.adminStatusSelectActive = true;
}
});
playerTable.addEventListener("focusout", (e) => {
if (!e.target.matches?.(phaseSelectSelector)) return;
window.setTimeout(() => {
const focused = document.activeElement;
state.adminStatusSelectActive =
!!focused?.matches?.(phaseSelectSelector);
}, 0);
});
playerTable.addEventListener("change", async (e) => {
const select = e.target.closest(phaseSelectSelector);
if (!select) return;
const playerId = select.dataset.setPlayerPhase;
const phase = select.value;
if (!playerId || !phase) return;
select.disabled = true;
try {
await adminApi.setPlayerPhase(playerId, phase);
toast(t("admin.statusUpdated"));
state.adminStatusSelectActive = false;
await runSerializedRefresh();
} catch (err) {
select.value = "";
toast(err.message, true);
} finally {
select.disabled = false;
state.adminStatusSelectActive = false;
}
});
playerTable.addEventListener("click", async (e) => {
const grantBtn = e.target.closest("[data-grant-joker]");

View File

@@ -15,6 +15,7 @@ export const state = {
results: [],
votesRendered: false,
adminVoteStatus: null,
adminStatusSelectActive: false,
};
export function clearUserState() {
@@ -31,6 +32,7 @@ export function clearUserState() {
state.myVotes = [];
state.results = [];
state.votesRendered = false;
state.adminStatusSelectActive = false;
const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden");
}