From fcd23edc6b918d122d0ea2cfbaa3c592ab9293e5 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 5 Feb 2026 11:23:41 +0100 Subject: [PATCH] Add unlink flow for linked games --- Contracts/Dtos.cs | 1 + Endpoints/AdminEndpoints.cs | 55 +++++++++++++++++++++++++++++++++++++ wwwroot/js/api.js | 2 ++ wwwroot/js/i18n.js | 10 +++++++ wwwroot/js/ui.js | 30 +++++++++++++++++++- 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 505b36f..3a3a95c 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -8,3 +8,4 @@ public record ResultsOpenRequest(bool ResultsOpen); public record VoteFinalizeRequest(bool Final); public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized); public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId); +public record UnlinkSuggestionsRequest(int SuggestionId); diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 288c044..53edd68 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -124,6 +124,61 @@ public static class AdminEndpoints }); }); + admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null || !await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); + + var phase = await EndpointHelpers.GetPhase(db, player.Id); + if (phase != Phase.Vote) + return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + + var suggestions = await db.Suggestions.ToListAsync(); + var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId); + if (target is null) + return Results.NotFound(new { error = "Suggestion not found." }); + + var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); + if (!rootIndex.TryGetValue(target.Id, out var rootId)) + return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty(), UnfinalizedPlayers = 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(); + + var affectedPlayerIds = await db.Votes + .Where(v => groupIds.Contains(v.SuggestionId)) + .Select(v => v.PlayerId) + .Distinct() + .ToListAsync(); + + await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); + + if (affectedPlayerIds.Count > 0) + { + await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)) + .ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + } + + await tx.CommitAsync(); + + return Results.Ok(new + { + UnlinkedSuggestionIds = groupIds, + UnfinalizedPlayers = affectedPlayerIds.Count + }); + }); + admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => { if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 2d10aa3..adbb3f6 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -58,4 +58,6 @@ export const adminApi = { factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }), linkSuggestions: (sourceSuggestionId, targetSuggestionId) => request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }), + unlinkSuggestions: (suggestionId) => + request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }), }; diff --git a/wwwroot/js/i18n.js b/wwwroot/js/i18n.js index a2c81dd..930a65b 100644 --- a/wwwroot/js/i18n.js +++ b/wwwroot/js/i18n.js @@ -118,6 +118,11 @@ const translations = { "admin.linkTargetPlaceholder": "Select game B (parent)", "admin.linkValidation": "Choose two different games to link.", "admin.linkDone": "Games linked. Votes cleared.", + "admin.unlinkTitle": "Remove links?", + "admin.unlinkBody": "Remove all links involving \"{name}\"? This clears votes and unfinalizes voters in this group: {peers}.", + "admin.unlinkConfirm": "Remove links", + "admin.unlinkDone": "Links removed. Votes cleared.", + "admin.unlinkUnknownPeers": "linked games", "toast.unexpected": "Unexpected error", "toast.registered": "Registered", @@ -258,6 +263,11 @@ const translations = { "admin.linkTargetPlaceholder": "Spiel B (Eltern) wählen", "admin.linkValidation": "Wähle zwei verschiedene Spiele aus.", "admin.linkDone": "Spiele verknüpft. Stimmen gelöscht.", + "admin.unlinkTitle": "Links entfernen?", + "admin.unlinkBody": "Alle Links zu \"{name}\" entfernen? Dadurch werden Stimmen gelöscht und Finalisierungen aufgehoben für: {peers}.", + "admin.unlinkConfirm": "Links entfernen", + "admin.unlinkDone": "Links entfernt. Stimmen gelöscht.", + "admin.unlinkUnknownPeers": "verknüpfte Spiele", "toast.unexpected": "Unerwarteter Fehler", "toast.registered": "Registriert", diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 018f23d..cb28ec6 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -3,6 +3,7 @@ import { t } from "./i18n.js"; import { state, getSavedUsername, setSavedUsername } from "./state.js"; import { $, toast } from "./dom.js"; import { setupCardVisualHover, triggerCelebration } from "./effects.js"; +import { adminApi } from "./api.js"; export function setAuthUI(isAuthed) { const main = document.querySelector("main"); @@ -307,7 +308,9 @@ export function buildCard( ? t("card.linkedWith", { names: linkedTitles.join(", ") }) : t("card.linked") : ""; - const linkChip = linked ? `🔗` : ""; + const linkChip = linked + ? `` + : ""; const visual = hasImage ? `` : `
`; @@ -351,6 +354,10 @@ export function buildCard( setupCardVisualHover(btn, s.screenshotUrl); btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name)); } + if (linked && state.me?.isAdmin) { + const unlinkBtn = card.querySelector("[data-unlink]"); + unlinkBtn?.addEventListener("click", () => openUnlinkConfirm(s)); + } if (allowEdit) { const editBtn = card.querySelector("[data-edit]"); editBtn?.addEventListener("click", () => @@ -890,6 +897,27 @@ function syncLinkedSliders(sourceEl, value) { }); } +function openUnlinkConfirm(s) { + const peers = linkedPeerTitles(s); + const names = peers.length ? peers.join(", ") : t("admin.unlinkUnknownPeers"); + openConfirmModal({ + title: t("admin.unlinkTitle"), + body: t("admin.unlinkBody", { name: s.name, peers: names }), + confirmLabel: t("admin.unlinkConfirm"), + cancelLabel: t("modal.cancel"), + onConfirm: async (close) => { + try { + await adminApi.unlinkSuggestions(s.id); + toast(t("admin.unlinkDone")); + close(); + await window.refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } + } + }); +} + export function updatePhaseNav() { const isAdmin = !!state.me?.isAdmin; const phase = state.phase;