From 207750acbadaee40bf136aa285f8affcc65203df Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 18 Aug 2026 12:59:37 +0200 Subject: [PATCH] Slett en hel turnering, permanent (ADR-086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nytt DELETE /orgs/{id}/tournaments/{id}, kun org-eier/admin. Eksplisitt riktig-rekkefølge-sletting (ikke naiv kaskade) siden to RESTRICT-FK-er sitter midt inni kaskade-grafen for begge turneringsformater -- verifisert med fire nye backend-tester mot ekte populerte data. Søppelbøtte-ikon i begge turnering-headerne med sterk confirm()-tekst. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE_DECISIONS.md | 82 ++++++++++ CHANGELOG.md | 36 +++++ app/routers/tournaments.py | 80 +++++++++- .../individual-tournament-detail.tsx | 55 +++++++ frontend/components/tournament-detail.tsx | 60 ++++++++ tests/test_delete_tournament.py | 142 ++++++++++++++++++ 6 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 tests/test_delete_tournament.py diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 3b85461..ab6cf44 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -8820,6 +8820,88 @@ revet ned. --- +## ADR-086: Slett en hel turnering, permanent (2026-08-18) + +Bruker: "Jeg mangler mulighet til å slette HELE turneringen." Undersøkt +FØR bygging: ingen `DELETE`-endepunkt for selve `tournament`-raden fantes +noe sted -- alle eksisterende `DELETE`-endepunkter i `tournaments.py`/ +`individual_tournaments.py` treffer kun UNDER-ressurser (én økt, én runde, +ett medlem osv.), og de fleste av DEM nekter i tillegg sletting så snart +det finnes ekte barn (409 "ikke tom"). Genuint manglende funksjonalitet, +ikke bare uklikkbar. + +**Autorisasjon: strengere enn resten av turneringsoppsettet, bevisst.** +Alt annet i `tournaments.py` krever kun org-medlemskap (`get_authorized_ +org`) -- dokumentert som en bevisst grense i filens egen header-kommentar +("dette er organisator-arbeid"). En hel-turnering-sletting er +kategorisk mer ødeleggende (fjerner ALL ekte score-/matchdata permanent, +ikke tomt oppsett) -- ny, strengere sperre: `is_org_admin` (eier/admin), +samme funksjon `messaging.py` allerede bruker for moderasjon. Håndheves +server-side (403 for vanlige medlemmer); frontend skjuler i tillegg selve +knappen for dem via `/auth/me` sin rolle -- ren UX-hjelp, ikke den reelle +sperren. + +**Eksplisitt, riktig-rekkefølge-sletting, IKKE naiv enkelt-setnings- +kaskade.** Skjemaet har to RESTRICT-FK-er MIDT INNI kaskade-grafen, ikke +bare i kantene: `match_participant.team_roster_id` (mens `team_roster` +kaskaderer fra `team`, og `match_participant` OGSÅ kaskaderer fra +`match`→`session` -- en "diamant"), og tilsvarende `tournament_round_ +participant.tournament_participant_id` for individuell-formatet. Siden +RESTRICT (ulikt NO ACTION) sjekkes UMIDDELBART, ikke utsettbart, kunne en +enkel `DELETE FROM tournament` feilt avhengig av Postgres sin interne +kaskade-rekkefølge -- KUN når det faktisk finnes ekte kamper/runder (en +tom turnering ville aldri avslørt dette). Løst med eksplisitt sletting i +riktig rekkefølge (fjerner RESTRICT-blokkerende rader FØR det de peker på +forsvinner) i stedet for å stole blindt på kaskaden -- `org_connection` +sin egen transaksjon gjør alt atomisk uansett. + +**Spillerne selv (`player`, org-scopet pool) røres ALDRI** -- kun +turneringens egne koblinger til dem (roster/deltakelse) forsvinner. + +**Ingen soft-delete/arkivering.** Sjekket: ingen `deleted_at`/`is_ +deleted`-mønster finnes noe sted i skjemaet. `tournament.status` sin +`'archived'`-verdi er et rent visningsmerke uten noen reell backend- +oppførsel (ingen skjuling/beskyttelse) -- IKKE en soft-delete-erstatning. +Ekte, permanent `DELETE` er den etablerte normen i denne kodebasen +(speiler `DELETE /rounds/{id}` i `rounds.py`, den nærmeste presedensen +for "slett en hel ting med barn"). + +**UI:** søppelbøtte-ikon i hoved-header, ved siden av +`TournamentStatusPicker`, i BÅDE `tournament-detail.tsx` (lag-format) og +`individual-tournament-detail.tsx` (individuell) -- identisk mønster i +begge, kun synlig for eier/admin. Bekreftelse via nettleserens `confirm()` +med eksplisitt, sterk ordlyd ("Kan ikke angres") -- samme mønster som +`round-detail.tsx` sin runde-sletting og `org-members.tsx` sin +medlem-fjerning, ingen ny bekreftelses-konvensjon oppfunnet. + +**Bevisst IKKE bygget:** ingen statusbasert sperre (f.eks. "kan ikke +slette en `active`/`completed` turnering") -- status kan i dag settes +fritt av hvem som helst med ett klikk, usynkronisert med faktisk +datainnhold, så en statusbasert sperre ville vært en falsk trygghet. +Ekte barnesjekk (finnes det score) ble vurdert, men forkastet til fordel +for enkelhet -- eier/admin-sperren + den sterke `confirm()`-teksten ble +ansett tilstrekkelig for en bevisst, sjelden handling. + +**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Fire nye +backend-tester (`tests/test_delete_tournament.py`, 111→115 totalt) -- +begge formater med EKTE populerte data (lag-format: kamp+roster+ +match_participant+hole_score; individuelt: runde+deltaker+round_ +participant+round_hole), bekrefter RESTRICT-diamant-scenarioet faktisk +fungerer, pluss 403 for vanlig medlem og 404 for ikke-eksisterende +turnering. Full nettleser-scratch-verifisering (database+API+next dev) +med et ekte scratch-scenario med reell 2FA-fullføring (TOTP-hemmelighet +lest fra oppsettskjermen, kode beregnet med `pyotp`, siden org-eier/admin +krever obligatorisk 2FA ved innlogging) -- bekreftet søppelbøtte-ikonet +vises, `confirm()`-dialogen viser riktig tekst, og etter bekreftelse: +navigasjon til dashbord OG faktisk 0 rader igjen i `tournament`/`team`/ +`team_roster`/`match`/`match_participant`/`hole_score`/`session`, mens +spillerne selv (2 stk) fortsatt fantes uendret. Scratch-stacken revet +ned. + +**Rullet ut:** venter på bekreftelse. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c818f7..cfcd3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12369,3 +12369,39 @@ Neste steg: **Rullet ut:** venter på bekreftelse (ren frontend, ingen migrasjon). + +110. **Slett en hel turnering, permanent (ADR-086) — 2026-08-18.** + Bruker: "Jeg mangler mulighet til å slette HELE turneringen." + Bekreftet: genuint manglende -- ingen `DELETE`-endepunkt for selve + `tournament`-raden fantes, kun for under-ressurser (som i tillegg + nekter sletting så snart de har barn). + + Nytt `DELETE /orgs/{id}/tournaments/{id}` (`app/routers/ + tournaments.py`) -- strengere sperre enn resten av turnerings- + oppsettet (kun org-eier/admin, `is_org_admin`, ikke bare medlemskap + som alt annet her), siden dette er kategorisk mer ødeleggende. + **Kritisk skjemafunn:** to RESTRICT-FK-er sitter MIDT INNI + kaskade-grafen (`match_participant.team_roster_id`, + `tournament_round_participant.tournament_participant_id`) -- en + naiv enkelt-setnings-kaskade kunne feilt avhengig av Postgres sin + interne rekkefølge, KUN når det faktisk fantes ekte kamper/runder. + Løst med eksplisitt sletting i riktig rekkefølge i stedet for å + stole blindt på kaskaden. Spillerne selv (`player`) røres aldri -- + kun turneringens koblinger til dem. + + UI: søppelbøtte-ikon i header ved siden av statusvelgeren, i begge + turneringstyper (lag- og individuell-format), kun synlig for eier/ + admin. `confirm()`-bekreftelse med sterk ordlyd, samme mønster som + runde-sletting/medlem-fjerning -- ingen ny bekreftelses-konvensjon. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Fire nye + backend-tester (111→115) -- begge formater med EKTE populerte data, + bekrefter RESTRICT-diamant-scenarioet faktisk fungerer, pluss 403/ + 404-grensetilfeller. Full nettleser-scratch-verifisering med ekte + 2FA-fullføring (TOTP-kode beregnet med `pyotp` fra oppsettskjermens + hemmelighet) -- bekreftet knapp, `confirm()`-tekst, og at ALT + (turnering/lag/roster/kamper/match_participant/hole_score/økt) + faktisk ble borte mens spillerne selv besto uendret. Scratch- + stacken revet ned. + + **Rullet ut:** venter på bekreftelse. diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index cb98e6f..2323ad1 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -26,7 +26,7 @@ from ..email import send_session_result_email from ..errors import app_error, translate_db_errors from ..handicap import compute_and_store_side_handicaps, parse_allowance_config from .auth import _hash_secret -from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant +from ..team_authz import is_org_admin, is_org_member, user_is_team_captain, user_is_tournament_participant from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state from .. import storage @@ -246,6 +246,84 @@ async def update_tournament( return _tournament_from_row(row) +@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}", status_code=204) +async def delete_tournament( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> None: + """Sletter HELE turneringen -- permanent, ingen angre (ADR-086, + 2026-08-18). Strengere sperre enn resten av denne fila (kun org- + medlemskap for oppsett) -- kun org-eier/admin, siden dette er + kategorisk mer ødeleggende enn noe annet i turneringsoppsettet + (fjerner ALL score-/matchdata permanent, ikke bare tomt oppsett). + + Eksplisitt, RIKTIG REKKEFØLGE-sletting i stedet for å stole blindt på + én kaskaderende `DELETE FROM tournament` -- to steder i skjemaet har + en RESTRICT-FK midt INNI kaskade-grafen (`match_participant. + team_roster_id` og `tournament_round_participant.tournament_ + participant_id`, begge migrasjon 001/040), som kan avvise en + enkelt-setnings-kaskade avhengig av Postgres sin interne + kaskade-rekkefølge når det faktisk finnes ekte kamper/runder. Fjerner + derfor RESTRICT-blokkerende rader FØR det de peker på slettes. + `org_connection` sin egen transaksjon gjør alt atomisk uansett -- + feiler ett steg, ruller alt tilbake.""" + async with org_connection(organization_id) as conn, translate_db_errors(): + if not await is_org_admin(conn, organization_id, user.user_id): + raise app_error( + 403, + "NOT_ORG_ADMIN", + "Kun organisasjonseier/administrator kan slette en turnering.", + ) + exists = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id + ) + if not exists: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + + # Lag-format: fjern match_participant FØR team_roster forsvinner + # (RESTRICT-FK-en over) -- kaskaderer selv videre til hole_score. + await conn.execute( + """ + DELETE FROM match_participant + WHERE match_id IN ( + SELECT m.id FROM match m + JOIN session s ON m.session_id = s.id + WHERE s.tournament_id = $1 + ) + """, + tournament_id, + ) + await conn.execute( + "DELETE FROM match WHERE session_id IN (SELECT id FROM session WHERE tournament_id = $1)", + tournament_id, + ) + await conn.execute("DELETE FROM session WHERE tournament_id = $1", tournament_id) + await conn.execute( + "DELETE FROM team_roster WHERE team_id IN (SELECT id FROM team WHERE tournament_id = $1)", + tournament_id, + ) + await conn.execute("DELETE FROM team WHERE tournament_id = $1", tournament_id) + + # Individuell-format: samme prinsipp -- tournament_round_participant + # FØR tournament_participant/tournament_round forsvinner. + await conn.execute( + """ + DELETE FROM tournament_round_participant + WHERE tournament_round_id IN (SELECT id FROM tournament_round WHERE tournament_id = $1) + OR tournament_participant_id IN (SELECT id FROM tournament_participant WHERE tournament_id = $1) + """, + tournament_id, + ) + await conn.execute("DELETE FROM tournament_round WHERE tournament_id = $1", tournament_id) + await conn.execute("DELETE FROM tournament_participant WHERE tournament_id = $1", tournament_id) + + # Resten (klasser, sponsorer, påmelding, feed/chat-meldinger, + # OoM-lenke) kaskaderer trygt fra selve tournament-raden -- ingen + # RESTRICT-FK-er igjen på dette punktet. + await conn.execute("DELETE FROM tournament WHERE id = $1", tournament_id) + + @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/hero-image", response_model=Tournament, diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index c3ba019..577d88b 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -15,6 +15,7 @@ import type React from "react" import { useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" import dynamic from "next/dynamic" +import { useRouter } from "next/navigation" import type { FlagMapEntry } from "@/components/flag-map-overview" import { ArrowLeft, @@ -342,6 +343,7 @@ export function IndividualTournamentDetail({ tournamentId: string tournamentName: string }) { + const router = useRouter() const [tournament, setTournament] = useState(null) const [participants, setParticipants] = useState([]) const [rounds, setRounds] = useState([]) @@ -350,9 +352,51 @@ export function IndividualTournamentDetail({ const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup") + // Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- samme + // sperre/begrunnelse som tournament-detail.tsx (lagformatet). + const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null) + const [deleting, setDeleting] = useState(false) const base = `/orgs/${organizationId}/tournaments/${tournamentId}` + useEffect(() => { + let cancelled = false + fetch("/auth/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => { + if (cancelled || !me) return + setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [organizationId]) + const isOrgAdmin = myRole === "owner" || myRole === "admin" + + async function deleteTournament() { + if ( + !confirm( + `Slette "${tournamentName}" permanent? Dette fjerner ALT -- deltakere, alle runder og all scoring. Kan ikke angres.`, + ) + ) { + return + } + setDeleting(true) + setError(null) + try { + const res = await fetch(base, { method: "DELETE", credentials: "include" }) + if (!res.ok && res.status !== 204) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å slette turneringen.") + } + router.replace("/dashboard") + } catch (err) { + setError(err instanceof Error ? err.message : "Klarte ikke å slette turneringen.") + setDeleting(false) + } + } + useEffect(() => { let cancelled = false async function load() { @@ -692,6 +736,17 @@ export function IndividualTournamentDetail({
+ {isOrgAdmin && ( + + )}
diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx index 7671259..0fb9ad3 100644 --- a/frontend/components/tournament-detail.tsx +++ b/frontend/components/tournament-detail.tsx @@ -3,6 +3,7 @@ import type React from "react" import { useEffect, useMemo, useState } from "react" import Link from "next/link" +import { useRouter } from "next/navigation" import { ArrowLeft, ArrowLeftRight, @@ -94,6 +95,7 @@ export function TournamentDetail({ tournamentId: string tournamentName: string }) { + const router = useRouter() const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null]) const [pool, setPool] = useState([]) const [joinCode, setJoinCode] = useState(null) @@ -101,9 +103,56 @@ export function TournamentDetail({ const [classes, setClasses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + // Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- strengere + // sperre enn resten av denne siden, som kun krever org-medlemskap. Håndheves + // reelt server-side; dette skjuler bare knappen for noen den uansett ville + // fått 403 på. + const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null) + const [deleting, setDeleting] = useState(false) const [showImport, setShowImport] = useState(false) + useEffect(() => { + let cancelled = false + fetch("/auth/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => { + if (cancelled || !me) return + setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [organizationId]) + const isOrgAdmin = myRole === "owner" || myRole === "admin" + + async function deleteTournament() { + if ( + !confirm( + `Slette "${tournamentName}" permanent? Dette fjerner ALT -- lag, spillere sin rostring, alle kamper og all scoring. Kan ikke angres.`, + ) + ) { + return + } + setDeleting(true) + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok && res.status !== 204) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å slette turneringen.") + } + router.replace("/dashboard") + } catch (err) { + setError(err instanceof Error ? err.message : "Klarte ikke å slette turneringen.") + setDeleting(false) + } + } + useEffect(() => { let cancelled = false async function load() { @@ -443,6 +492,17 @@ export function TournamentDetail({ {status && } {joinCode && } + {isOrgAdmin && ( + + )}