Slett en hel turnering, permanent (ADR-086)
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 <noreply@anthropic.com>
This commit is contained in:
parent
82343e522b
commit
207750acba
6 changed files with 454 additions and 1 deletions
|
|
@ -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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -12369,3 +12369,39 @@ Neste steg:
|
||||||
|
|
||||||
**Rullet ut:** venter på bekreftelse (ren frontend, ingen
|
**Rullet ut:** venter på bekreftelse (ren frontend, ingen
|
||||||
migrasjon).
|
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.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from ..email import send_session_result_email
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||||
from .auth import _hash_secret
|
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 .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
|
||||||
from .. import storage
|
from .. import storage
|
||||||
|
|
||||||
|
|
@ -246,6 +246,84 @@ async def update_tournament(
|
||||||
return _tournament_from_row(row)
|
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(
|
@router.post(
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
||||||
response_model=Tournament,
|
response_model=Tournament,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type React from "react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -342,6 +343,7 @@ export function IndividualTournamentDetail({
|
||||||
tournamentId: string
|
tournamentId: string
|
||||||
tournamentName: string
|
tournamentName: string
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
|
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
|
||||||
const [participants, setParticipants] = useState<ApiParticipant[]>([])
|
const [participants, setParticipants] = useState<ApiParticipant[]>([])
|
||||||
const [rounds, setRounds] = useState<ApiRound[]>([])
|
const [rounds, setRounds] = useState<ApiRound[]>([])
|
||||||
|
|
@ -350,9 +352,51 @@ export function IndividualTournamentDetail({
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup")
|
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}`
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -692,6 +736,17 @@ export function IndividualTournamentDetail({
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-13">
|
<div className="flex flex-wrap items-center gap-2 pl-13">
|
||||||
<TournamentStatusPicker status={tournament.status} onChange={updateStatus} />
|
<TournamentStatusPicker status={tournament.status} onChange={updateStatus} />
|
||||||
<JoinCodeChip code={tournament.join_code} />
|
<JoinCodeChip code={tournament.join_code} />
|
||||||
|
{isOrgAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteTournament()}
|
||||||
|
disabled={deleting}
|
||||||
|
aria-label="Slett turnering"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
|
|
@ -94,6 +95,7 @@ export function TournamentDetail({
|
||||||
tournamentId: string
|
tournamentId: string
|
||||||
tournamentName: string
|
tournamentName: string
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
|
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
|
||||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||||
const [joinCode, setJoinCode] = useState<string | null>(null)
|
const [joinCode, setJoinCode] = useState<string | null>(null)
|
||||||
|
|
@ -101,9 +103,56 @@ export function TournamentDetail({
|
||||||
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(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)
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -443,6 +492,17 @@ export function TournamentDetail({
|
||||||
</div>
|
</div>
|
||||||
{status && <TournamentStatusPicker status={status} onChange={updateStatus} />}
|
{status && <TournamentStatusPicker status={status} onChange={updateStatus} />}
|
||||||
{joinCode && <JoinCodeChip code={joinCode} />}
|
{joinCode && <JoinCodeChip code={joinCode} />}
|
||||||
|
{isOrgAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteTournament()}
|
||||||
|
disabled={deleting}
|
||||||
|
aria-label="Slett turnering"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
|
|
|
||||||
142
tests/test_delete_tournament.py
Normal file
142
tests/test_delete_tournament.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""
|
||||||
|
Full sletting av en hel turnering (ADR-086) -- kritisk å teste med EKTE
|
||||||
|
populert data i begge formater, ikke bare en tom turnering, siden skjemaet
|
||||||
|
har to RESTRICT-FK-er MIDT INNI kaskade-grafen (match_participant.
|
||||||
|
team_roster_id, tournament_round_participant.tournament_participant_id) som
|
||||||
|
kan avvise en naiv enkelt-setnings-kaskade. Se delete_tournament() sin egen
|
||||||
|
kommentar i app/routers/tournaments.py for full begrunnelse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
from app.routers.tournaments import delete_tournament
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
add_membership,
|
||||||
|
create_course,
|
||||||
|
create_match,
|
||||||
|
create_match_participant,
|
||||||
|
create_org,
|
||||||
|
create_org_hole,
|
||||||
|
create_player,
|
||||||
|
create_session,
|
||||||
|
create_team,
|
||||||
|
create_team_roster,
|
||||||
|
create_tee,
|
||||||
|
create_tournament,
|
||||||
|
create_tournament_participant,
|
||||||
|
create_tournament_round,
|
||||||
|
create_tournament_round_participant,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
from app import db as app_db
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_team_tournament_with_real_matches_and_scores(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
tee_id = await create_tee(org_id, course_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id)
|
||||||
|
team_a = await create_team(org_id, tournament_id)
|
||||||
|
team_b = await create_team(org_id, tournament_id)
|
||||||
|
match_id = await create_match(org_id, session_id, team_a, team_b)
|
||||||
|
player_a = await create_player(org_id)
|
||||||
|
player_b = await create_player(org_id)
|
||||||
|
roster_a = await create_team_roster(org_id, team_a, player_a)
|
||||||
|
roster_b = await create_team_roster(org_id, team_b, player_b)
|
||||||
|
mp_a = await create_match_participant(org_id, match_id, "a", roster_a, tee_id)
|
||||||
|
mp_b = await create_match_participant(org_id, match_id, "b", roster_b, tee_id)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, "
|
||||||
|
"hole_number, gross_strokes) VALUES ($1, $2, 'a', $3, 1, 4)",
|
||||||
|
org_id, match_id, mp_a,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, "
|
||||||
|
"hole_number, gross_strokes) VALUES ($1, $2, 'b', $3, 1, 5)",
|
||||||
|
org_id, match_id, mp_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = CurrentUser(user_id=owner_id)
|
||||||
|
await delete_tournament(tournament_id, organization_id=org_id, user=user)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is False
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM session WHERE tournament_id = $1", tournament_id) == 0
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM match WHERE id = $1", match_id) == 0
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM match_participant WHERE match_id = $1", match_id) == 0
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM hole_score WHERE match_id = $1", match_id) == 0
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM team_roster WHERE team_id = $1", team_a) == 0
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id) == 0
|
||||||
|
# Spillerne selv (org-scopet spillerpool) skal IKKE slettes -- kun
|
||||||
|
# turneringens egne koblinger til dem.
|
||||||
|
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM player WHERE id = $1)", player_a) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_individual_tournament_with_real_rounds_and_scores(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_id, hole_number=n)
|
||||||
|
tee_id = await create_tee(org_id, course_id)
|
||||||
|
round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||||
|
player_id = await create_player(org_id)
|
||||||
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||||
|
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO tournament_round_hole (organization_id, tournament_round_participant_id, "
|
||||||
|
"hole_number, gross_strokes) VALUES ($1, $2, 1, 4)",
|
||||||
|
org_id, rp_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = CurrentUser(user_id=owner_id)
|
||||||
|
await delete_tournament(tournament_id, organization_id=org_id, user=user)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is False
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM tournament_round WHERE tournament_id = $1", tournament_id) == 0
|
||||||
|
assert await conn.fetchval("SELECT count(*) FROM tournament_participant WHERE tournament_id = $1", tournament_id) == 0
|
||||||
|
assert await conn.fetchval(
|
||||||
|
"SELECT count(*) FROM tournament_round_participant WHERE tournament_round_id = $1", round_id
|
||||||
|
) == 0
|
||||||
|
assert await conn.fetchval(
|
||||||
|
"SELECT count(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp_id
|
||||||
|
) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_requires_org_admin_not_just_member(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
member_id = await create_user()
|
||||||
|
await add_membership(org_id, member_id, role="member")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
|
||||||
|
user = CurrentUser(user_id=member_id)
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await delete_tournament(tournament_id, organization_id=org_id, user=user)
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_nonexistent_tournament_is_404(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
|
||||||
|
user = CurrentUser(user_id=owner_id)
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await delete_tournament("00000000-0000-0000-0000-000000000000", organization_id=org_id, user=user)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
Loading…
Reference in a new issue