diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 19c9797..c06c0c2 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -967,6 +967,62 @@ scratch-verifisering og utrulling. --- +## ADR-024: Walkover/konsesjon + +Reist 2026-07-19, direkte oppfølging av det tidligere åpne punktet i +FEATURE_BACKLOG.md: en side som aldri stiller nok spillere får ALDRI +beregnet handicap, og matchen kan derfor ALDRI avgjøres — den henger uendelig. +Ventet tidligere på Brukerroller (ADR-023), som nå er avgjort. Fire +delspørsmål, alle avklart eksplisitt med bruker før bygging. + +**Beslutning A — Kun den TAPENDE siden (eller org-eier/admin) kan erklære, +til fordel for motstanderen.** Speiler ekte golf-etikette: du gir bort DITT +EGET tap, du krever ikke seier på motstanderens vegne. Bruker +`app/team_authz.py` sin eksisterende `user_is_team_captain` (ADR-023) på det +KONSEDERENDE laget spesifikt — ingen ny autorisasjonsfunksjon nødvendig. Rene +no-show-tilfeller (den tapende siden har ingen innlogget/rostret spiller i +det hele tatt) dekkes av org-admin-fallbacken som allerede finnes i +`user_is_team_captain`. + +**Beslutning B — Ensidig erklæring, ingen bekreftelse fra motparten.** +Samme tillitsnivå som all annen scoring i appen (allerede en upsert uten +godkjenning). Ingen ny "venter på bekreftelse"-tilstand eller +varslingsmekanisme bygget. + +**Beslutning C — Både match- og turnering-nivå, i samme runde.** +`POST /orgs/{id}/matches/{id}/concede` (`app/routers/scoring.py`) for én +match. `POST /orgs/{id}/tournaments/{id}/concede` +(`app/routers/tournaments.py`) for å gi opp ALLE ikke-avgjorte matcher laget +har i turneringen, i én operasjon — v1 er låst til nøyaktig to lag +(ADR-011), så det finnes bare ÉN motstander uansett hvor mange +sesjoner/matcher turneringen har, og turnering-nivå-konsesjon er derfor +bare match-nivå-logikken (`apply_concession`) kjørt per ikke-avgjorte match. +Hull-nivå konsesjon er bevisst IKKE en egen mekanisme — `hole_result`-modus +dekker det allerede (rapporter bare hvem som vant hullet). + +**Beslutning D — Kan erklæres uansett hvor mange hull som allerede er +registrert.** I match-play teller kun seier/tap/delt for poeng, ikke +marginen — det er derfor ingen reell forskjell, poengmessig, på "ga opp +etter 5 hull" og "ga opp før start". `apply_concession` overstyrer status/ +poeng direkte, uavhengig av `_compute_hole_results`; allerede registrerte +hull forblir urørt i `hole_score`/`match_hole_result` (kun matchens +avgjørelses-felt endres), så scorekortet fortsatt viser nøyaktig hva som ble +spilt før konsesjonen. + +**Gjenbruk, ikke duplisering:** `apply_concession` skriver til nøyaktig de +samme fire kolonnene (`status_text`/`points_side_a/b`/`leading_side`) som +`recompute_and_cache_match_state` — samme "matchen er avgjort"-signal +(`points_side_a IS NOT NULL`) som allerede stopper videre hull-innsending +(`submit_hole_score`/`submit_hole_result`), ingen ny låsemekanisme +nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme +"(bokstav)"-visningskonvensjon som `compute_match_state.describe()` sine +egne strenger (f.eks. "9&7 (A)"). + +**Status: ✅ BYGGET 2026-07-19.** Ingen migrasjon (ren applogikk, ingen +skjemaendring). Se CLAUDE.md-status for scratch-verifisering og utrulling. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index c0be890..f33748e 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -352,19 +352,22 @@ Turnering har et `status`-felt (draft/active/completed/archived) men INGEN endepunkt endrer det ennå — organisator kan ikke markere en turnering ferdig via API-et i dag (eget, senere punkt). -- **Manglende WO/konsesjon:** hvis en side aldri stiller nok spillere - (`match_participant`-antallet når aldri det økten krever), beregnes - handicap ALDRI (se `app/handicap.py`), og matchen kan derfor ALDRI få et - hull-resultat — den blir hengende uavgjort for alltid. Det finnes ingen - «gi bort hullet/matchen/turneringen»-mekanisme (walkover/konsesjon) i det - hele tatt ennå — verken datamodell eller endepunkt. +- **Walkover/konsesjon — ✅ BYGGET 2026-07-19 (ADR-024).** Løste det + opprinnelige hullet: en side som aldri stiller nok spillere kan nå + avsluttes ved at den TAPENDE siden (kaptein, eller org-admin) erklærer + walkover — ensidig, ingen bekreftelse fra motparten. `POST /orgs/{id}/ + matches/{id}/concede` (én match) og `POST /orgs/{id}/tournaments/{id}/ + concede` (gi opp ALLE ikke-avgjorte matcher laget har, i én operasjon — + v1 er låst til nøyaktig to lag, så det finnes bare én motstander). Kan + erklæres uansett hvor mange hull som allerede er registrert (poengmessig + teller kun seier/tap/delt, ikke marginen) — allerede registrerte hull + forblir urørt i scorekortet. Se ARCHITECTURE_DECISIONS.md ADR-024. + Frontend: `session-scorecard.tsx` (gi opp én match) og + `tournament-detail.tsx` (gi opp resten av turneringen, per lag). - **Avgjort 2026-07-16:** - **Match-lås ved avgjørelse: ✅ bygget** — se eget punkt over. Automatisk (ikke en handling noen utfører), så «hvem får låse» ble aldri et spørsmål som trengte avklaring. - - **Walkover/konsesjon kan nå tas fatt på** — brukerroller (kaptein/ - deltaker-avgrensning) er avgjort og bygget (ADR-023), grunnlaget den - tidligere ventet på er ikke lenger løst. - **Fortsatt åpent:** (a) ~~skal score-føring begrenses til faktiske matchdeltakere~~ ✅ avgjort/bygget 2026-07-19 (ADR-023, se over). (b) skal korrigering kreve motpartens godkjenning, eller er upsert-modellen god nok diff --git a/app/routers/scoring.py b/app/routers/scoring.py index 529e334..7bfa1cb 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -30,6 +30,13 @@ status med en mindre komplett. prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen. + +Walkover/konsesjon (ADR-024): `apply_concession` er en egen, enklere vei til +akkurat de samme fire kolonnene (`status_text`/`points_side_a/b`/ +`leading_side`) som `recompute_and_cache_match_state` skriver -- brukt når +matchen avgjøres ved at en side GIR SEG, ikke ved utledning fra faktiske +hull. Delt med tournaments.py sin turnering-nivå-konsesjon (gir opp ALLE +ikke-avgjorte matcher laget har, i én operasjon). """ import json @@ -43,7 +50,7 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors from ..handicap import parse_allowance_config, relative_strokes_for_match -from ..team_authz import user_is_match_participant +from ..team_authz import user_is_match_participant, user_is_team_captain router = APIRouter() @@ -210,6 +217,97 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None: ) +class ConcedeOut(BaseModel): + match_id: str + status_text: str + points_side_a: float + points_side_b: float + leading_side: str + + +async def apply_concession(conn, match_id: str, conceding_side: str, points_per_match: float) -> ConcedeOut: + """Setter matchen som avgjort ved walkover -- full poengsum til motstanderen, + uansett hvor mange hull som ev. allerede er registrert (marginen betyr + ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller MÅ selv + ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er + avgjort FØR denne kalles -- se de to kallstedene (matches.py sin + concede_match, tournaments.py sin concede_tournament).""" + winner = "b" if conceding_side == "a" else "a" + points_a = 0.0 if conceding_side == "a" else points_per_match + points_b = points_per_match if conceding_side == "a" else 0.0 + status_text = f"Walkover ({winner.upper()})" + await conn.execute( + """ + UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, leading_side = $4 + WHERE id = $5 + """, + status_text, + points_a, + points_b, + winner, + match_id, + ) + return ConcedeOut( + match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner + ) + + +class ConcedeRequest(BaseModel): + conceding_team_id: str + + +@router.post( + "/orgs/{organization_id}/matches/{match_id}/concede", + response_model=ConcedeOut, + status_code=201, +) +async def concede_match( + match_id: str, + body: ConcedeRequest, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> ConcedeOut: + """Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget + som selv gir seg -- speiler ekte golf-etikette, du kan ikke kreve seier + på motstanderens vegne) eller org-eier/admin kan erklære. Ensidig, ingen + bekreftelse fra motparten kreves -- samme tillitsnivå som all annen + scoring. Kan erklæres uansett hvor mange hull som allerede er + registrert.""" + async with org_connection(organization_id) as conn, translate_db_errors(): + match = await conn.fetchrow( + """ + SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, + m.points_side_a::float AS points_side_a, + s.points_per_match::float AS points_per_match + FROM match m JOIN session s ON s.id = m.session_id + WHERE m.id = $1 + FOR UPDATE OF m + """, + match_id, + ) + if match is None: + raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") + if match["points_side_a"] is not None: + raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.") + + if body.conceding_team_id == match["team_a_id"]: + conceding_side = "a" + elif body.conceding_team_id == match["team_b_id"]: + conceding_side = "b" + else: + raise app_error(400, "VALIDATION_FAILED", "Laget hører ikke til denne matchen.") + + if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id): + raise app_error( + 403, + "NOT_TEAM_CAPTAIN", + "Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å erklære walkover.", + ) + + result = await apply_concession(conn, match_id, conceding_side, match["points_per_match"]) + return result + + class HoleScoreCreate(BaseModel): team_side: str = Field(pattern="^[ab]$") match_participant_id: str | None = None diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 259f283..df4fde7 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -15,12 +15,13 @@ import asyncpg from fastapi import APIRouter, Depends, UploadFile from pydantic import BaseModel, Field -from ..auth import get_authorized_org +from ..auth import CurrentUser, get_authorized_org, get_current_user from ..blind_draw import locked_team_ids from ..db import org_connection from ..errors import app_error, translate_db_errors from ..handicap import compute_and_store_side_handicaps, parse_allowance_config -from .scoring import recompute_and_cache_match_state +from ..team_authz import user_is_team_captain +from .scoring import apply_concession, recompute_and_cache_match_state from .. import storage router = APIRouter() @@ -268,6 +269,75 @@ async def create_team( return Team(**dict(row)) +# --------------------------------------------------------------------------- +# Turnering-nivå walkover/konsesjon (ADR-024) +# +# v1 er låst til nøyaktig to lag (ADR-011), så det finnes bare ÉN motstander +# uansett hvor mange sesjoner/matcher turneringen har -- "gi opp turneringen" +# er derfor bare "gi opp hver ikke-avgjorte match laget har" i én operasjon, +# gjenbruker match-nivå-logikken (apply_concession, scoring.py) per match. +# Samme autorisasjon som enkelt-match-konsesjon: kaptein for laget som gir +# seg selv, eller org-eier/admin. +# --------------------------------------------------------------------------- + + +class ConcedeTournamentRequest(BaseModel): + conceding_team_id: str + + +class ConcedeTournamentOut(BaseModel): + matches_conceded: int + + +@router.post( + "/orgs/{organization_id}/tournaments/{tournament_id}/concede", + response_model=ConcedeTournamentOut, + status_code=201, +) +async def concede_tournament( + tournament_id: str, + body: ConcedeTournamentRequest, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> ConcedeTournamentOut: + async with org_connection(organization_id) as conn, translate_db_errors(): + team = await conn.fetchrow( + "SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1", + body.conceding_team_id, + ) + if team is None or team["tournament_id"] != tournament_id: + raise app_error(404, "NOT_FOUND", "Laget finnes ikke i denne turneringen.") + + if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id): + raise app_error( + 403, + "NOT_TEAM_CAPTAIN", + "Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å gi opp turneringen.", + ) + + # FOR UPDATE OF m: låser alle berørte match-rader FØR de endres, samme + # race-vern som enkelt-match-konsesjon og recompute_and_cache_match_state. + matches = await conn.fetch( + """ + SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, + s.points_per_match::float AS points_per_match + FROM match m + JOIN session s ON s.id = m.session_id + WHERE s.tournament_id = $1 + AND m.points_side_a IS NULL + AND (m.team_a_id = $2 OR m.team_b_id = $2) + FOR UPDATE OF m + """, + tournament_id, + body.conceding_team_id, + ) + for m in matches: + side = "a" if m["team_a_id"] == body.conceding_team_id else "b" + await apply_concession(conn, m["id"], side, m["points_per_match"]) + + return ConcedeTournamentOut(matches_conceded=len(matches)) + + # --------------------------------------------------------------------------- # Roster (spiller-pool per lag, ADR-007) # --------------------------------------------------------------------------- diff --git a/frontend/components/session-scorecard.tsx b/frontend/components/session-scorecard.tsx index 7caf06d..6f8fcf0 100644 --- a/frontend/components/session-scorecard.tsx +++ b/frontend/components/session-scorecard.tsx @@ -115,6 +115,8 @@ export function SessionScorecard({ const [error, setError] = useState(null) const [currentHole, setCurrentHole] = useState(null) const [showSummary, setShowSummary] = useState(false) + const [showConcede, setShowConcede] = useState(false) + const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null) async function refetchScorecard() { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { @@ -211,6 +213,31 @@ export function SessionScorecard({ } } + // Walkover/konsesjon (ADR-024): kun kaptein for laget som GIR SEG (eller + // org-admin) får lov -- backend håndhever dette, UI-et viser bare begge + // knappene og lar en 403 melde seg som en vanlig feiltekst, samme mønster + // som resten av appen (ingen klientside-forhåndsfiltrering på kapteinstatus). + async function concede(teamId: string) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/concede`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ conceding_team_id: teamId }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å registrere walkover.") + } + setConcedeTeamIndex(null) + setShowConcede(false) + await refetchScorecard() + } catch (e) { + setError(e instanceof Error ? e.message : "Klarte ikke å registrere walkover.") + } + } + async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) { setError(null) try { @@ -444,6 +471,73 @@ export function SessionScorecard({ )} +
+ + {showConcede && ( +
+

+ Kun kapteinen for laget som gir seg (eller en organisasjonsadministrator) kan + gjøre dette. Motstanderen får full poengsum for matchen, uansett hvor mange hull + som allerede er registrert. +

+ {teams.map((team, i) => + concedeTeamIndex === i ? ( +
+ + Er du sikker på at {team.name} gir seg? + +
+ + +
+
+ ) : ( + + ), + )} +
+ )} +
+ {session.scoring_mode === "stroke" ? (
diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx index 31543b6..ed2ea13 100644 --- a/frontend/components/tournament-detail.tsx +++ b/frontend/components/tournament-detail.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, Check, Copy, + Flag, KeyRound, MoreVertical, Plus, @@ -228,6 +229,33 @@ export function TournamentDetail({ } } + // Walkover/konsesjon på turnering-nivå (ADR-024): gir opp ALLE + // ikke-avgjorte matcher laget har i turneringen, i én operasjon. Kun + // kaptein for laget som gir seg (eller org-admin) får lov -- backend + // håndhever dette, en 403 vises bare som vanlig feiltekst. + async function concedeTournament(teamId: string): Promise { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/concede`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ conceding_team_id: teamId }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + const message: string = body?.detail?.message ?? "Klarte ikke å gi opp turneringen." + setError(message) + return message + } + return null + } catch { + const message = "Klarte ikke å gi opp turneringen." + setError(message) + return message + } + } + // Hvilket lag er en gitt spiller (fra pool) allerede rostret på, om noen? function findPlayerTeam(playerId: string): Team | null { for (const t of teams) { @@ -319,6 +347,7 @@ export function TournamentDetail({ onRemovePlayer={removePlayer} onAddExisting={addExistingPlayer} onAddNew={addNewPlayer} + onConcedeTournament={concedeTournament} /> ) } @@ -466,6 +495,7 @@ function TeamPanel({ onRemovePlayer, onAddExisting, onAddNew, + onConcedeTournament, }: { team: Team otherTeam: Team | null @@ -475,8 +505,23 @@ function TeamPanel({ onRemovePlayer: (teamId: string, rosterId: string) => void onAddExisting: (teamId: string, playerId: string) => void onAddNew: (teamId: string, name: string, handicap?: number) => void + onConcedeTournament: (teamId: string) => Promise }) { const [confirmingId, setConfirmingId] = useState(null) + const [confirmingGiveUp, setConfirmingGiveUp] = useState(false) + const [giveUpError, setGiveUpError] = useState(null) + const [givingUp, setGivingUp] = useState(false) + + async function handleGiveUp() { + setGivingUp(true) + const message = await onConcedeTournament(team.id) + setGivingUp(false) + if (message) { + setGiveUpError(message) + } else { + setConfirmingGiveUp(false) + } + } return (
+ +
+ {confirmingGiveUp ? ( +
+ {giveUpError && ( +

+ {giveUpError} +

+ )} +

+ Er du sikker på at {team.name} gir opp + resten av turneringen? Motstanderen får full poengsum for ALLE ikke-avgjorte + matcher, med én gang. +

+
+ + +
+
+ ) : ( + + )} +
) }