Add admin status combobox to move voters back to suggest
This commit is contained in:
1
API.md
1
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
1
SPEC.md
1
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
|
||||
|
||||
3
TESTS.md
3
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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,3 +55,9 @@
|
||||
border: 1px solid #e3d4bd;
|
||||
background: #fffaf3;
|
||||
}
|
||||
|
||||
.admin-status-select {
|
||||
width: 100%;
|
||||
min-width: 140px;
|
||||
background: #fffaf3;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user