From 96a47020d8fc2f70f1980cc43d2afb11c57734e3 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 8 Feb 2026 15:00:09 +0100 Subject: [PATCH] Add admin status combobox to move voters back to suggest --- API.md | 1 + Contracts/Dtos.cs | 2 + Contracts/Responses.cs | 2 + Endpoints/AdminEndpoints.cs | 2 + Endpoints/AdminWorkflowService.cs | 20 ++++++++++ GameList.Tests/AdminTests.cs | 57 +++++++++++++++++++++++++++++ SPEC.md | 1 + TESTS.md | 3 +- wwwroot/app.js | 4 +- wwwroot/css/admin.css | 6 +++ wwwroot/data/i18n/faq/de.md | 1 + wwwroot/data/i18n/faq/en.md | 1 + wwwroot/data/i18n/translations.json | 4 ++ wwwroot/js/admin-ui.js | 15 +++++++- wwwroot/js/api.js | 2 + wwwroot/js/app-admin-handlers.js | 38 +++++++++++++++++++ wwwroot/js/state.js | 2 + 17 files changed, 156 insertions(+), 5 deletions(-) diff --git a/API.md b/API.md index 242d5a4..e3b99c0 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index d9baa5f..ba166ce 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -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); diff --git a/Contracts/Responses.cs b/Contracts/Responses.cs index 62b4ada..e8dccc6 100644 --- a/Contracts/Responses.cs +++ b/Contracts/Responses.cs @@ -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 LinkedSuggestionIds, int UnfinalizedPlayers); diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index d19fb77..0138e97 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -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) => diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index dbed167..3c48a7c 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -66,6 +66,26 @@ internal sealed class AdminWorkflowService(AppDbContext db) 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 DeletePlayerAsync(Guid playerId) { var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index 673a902..31ead46 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -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() { diff --git a/SPEC.md b/SPEC.md index 2ca5be1..1d2b9a7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,6 +26,7 @@ Help a small Discord group (4–8 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 diff --git a/TESTS.md b/TESTS.md index c9c4f02..83dc015 100644 --- a/TESTS.md +++ b/TESTS.md @@ -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 0–10, 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. diff --git a/wwwroot/app.js b/wwwroot/app.js index 8c993c6..1a94be1 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -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(); } }); diff --git a/wwwroot/css/admin.css b/wwwroot/css/admin.css index 6104cb8..e208814 100644 --- a/wwwroot/css/admin.css +++ b/wwwroot/css/admin.css @@ -55,3 +55,9 @@ border: 1px solid #e3d4bd; background: #fffaf3; } + +.admin-status-select { + width: 100%; + min-width: 140px; + background: #fffaf3; +} diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 842e8a0..d3aa6b5 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -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) diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index 85ded74..bfa9bbd 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -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) diff --git a/wwwroot/data/i18n/translations.json b/wwwroot/data/i18n/translations.json index 3f7d1c1..21608b8 100644 --- a/wwwroot/data/i18n/translations.json +++ b/wwwroot/data/i18n/translations.json @@ -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", diff --git a/wwwroot/js/admin-ui.js b/wwwroot/js/admin-ui.js index d7e355f..6ef9c64 100644 --- a/wwwroot/js/admin-ui.js +++ b/wwwroot/js/admin-ui.js @@ -15,8 +15,20 @@ function displayPlayerStatus(player) { return phase; } +function buildStatusSelect(player) { + const statusText = displayPlayerStatus(player); + const canMoveToSuggest = player.phase === "Vote"; + return ` + + `; +} + 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 = ` ${nameText} ${userText} - ${statusText} + ${buildStatusSelect(v)} ${v.suggestionCount ?? 0} diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 2df4315..a71d4e1 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -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 } }), diff --git a/wwwroot/js/app-admin-handlers.js b/wwwroot/js/app-admin-handlers.js index 8a38c19..a43f023 100644 --- a/wwwroot/js/app-admin-handlers.js +++ b/wwwroot/js/app-admin-handlers.js @@ -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]"); diff --git a/wwwroot/js/state.js b/wwwroot/js/state.js index 1317e42..e734751 100644 --- a/wwwroot/js/state.js +++ b/wwwroot/js/state.js @@ -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"); }