teecup/app/routers/scoring.py

1084 lines
45 KiB
Python
Raw Permalink Normal View History

"""
Scoring (ADR-012): to moduser per økt, cachet matchstatus.
'stroke' -> hole_score (brutto per hull, motoren utleder netto/hull-resultat).
'hole_result' -> match_hole_result (bare hvem som vant hullet, ingen motor for
selve hull-resultatet -- handicapen er allerede bakt inn i
match_participant fra oppsettsrunden).
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
Autorisasjon: begrenset til den enkelte matchens FAKTISKE deltakere (en
match_participant-rad for brukeren i akkurat denne matchen), ikke bare
"noen på laget" og UAVHENGIG av kapteinmerket -- se app/team_authz.py sin
user_is_match_participant. Byttet fra "rostret på laget" 2026-07-19
(Brukerroller-runden, ADR-023).
Individuell-vs-delt-ball (FEATURE_BACKLOG sitt app-lags-punkt, lukkes her):
singles/fourball ha match_participant_id; foursome/greensome/scramble
IKKE ha det. Ingenting i skjemaet håndhever dette.
Upsert i stedet for avvisning ved dobbel-innsending -- bevisst valg for at
live-scoring skal tåle rettelser. Ingen audit-trail korrigeringer ennå.
Matchstatus-recompute låser match-raden FØRST (FOR UPDATE) for å serialisere
samtidige scoreinnsendinger for SAMME match (typisk to fourball-partnere som
taster inn ulike hull samtidig) -- uten det kan to transaksjoner lese
hverandres data før commit og siste UPDATE kan overskrive en mer komplett
status med en mindre komplett.
"Hopp over uferdige hull": compute_match_state teller POSISJON i listen
(len(hole_results)), ikke hullnummer. Derfor tas kun den SAMMENHENGENDE
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
2026-07-17 21:40:42 +02:00
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from handicap_engine import (
HoleResult,
allocate_over_played_holes,
compute_match_state,
high_low_high_points_for_hole,
high_low_high_running_score,
money_ball_hole_score,
shamble_hole_score,
stableford_points_for_hole,
)
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
from ..auth import CurrentUser, get_current_user
from ..db import org_connection
2026-07-17 21:40:42 +02:00
from ..errors import app_error, translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..realtime import broadcast_live_update
from ..hole_history import resolve_match_course_key
from ..target_points import TargetPoint, get_target_points
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
from ..team_authz import (
is_org_member,
user_is_match_participant,
user_is_team_captain,
user_is_tournament_participant,
)
router = APIRouter()
_INDIVIDUAL_FORMATS = {"singles", "fourball", "shamble", "money_ball", "high_low_high"}
Update Todos Undersøk kode-kontekst for alle tre byggeoppgavene Avklar åpne designspørsmål med bruker Bygg: flytte spiller mellom lag (backend + frontend) Bygg: individuell rangering i turnering (backend + frontend) Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend) Typesjekket produksjonsbuild av frontend Scratch-verifiser alt tre + full regresjon Browser-verifiser alt tre (Chrome DevTools) Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Rull ut mot ekte systemer (med bekreftelse) Alle tre er live. Oppsummert: Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match. Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen. Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre. Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
def played_hole_numbers(hole_config: str) -> list[int]:
if hole_config == "front_9":
return list(range(1, 10))
if hole_config == "back_9":
return list(range(10, 19))
return list(range(1, 19))
def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int) -> int | None:
"""Sidens NETTO for ett hull, eller None hvis ikke avgjørbart ennå."""
if format_ not in _INDIVIDUAL_FORMATS:
if len(rows) != 1:
return None
r = rows[0]
per_hole = strokes_per_hole.get(r["team_side"])
if per_hole is None or hole_number not in per_hole:
return None
return r["gross_strokes"] - per_hole[hole_number]
# singles: forventer nøyaktig 1 rad. fourball: forventer nøyaktig 2
# (begge partnere) og tar den BESTE (laveste) netto -- klassisk
# better-ball-regel. Ufullstendig antall rader = ikke avgjørbart ennå.
expected_rows = 1 if format_ == "singles" else 2
if len(rows) != expected_rows:
return None
nets = []
for r in rows:
per_hole = strokes_per_hole.get(r["match_participant_id"])
if per_hole is None or hole_number not in per_hole:
return None
nets.append(r["gross_strokes"] - per_hole[hole_number])
return min(nets)
async def _compute_shamble_hole_results(conn, match_id: str, match, played: list[int]) -> list[HoleResult]:
"""Shamble (2026-07-30): lagets hullscore = sum av de `session.
shamble_best_n` laveste INDIVIDUELLE brutto-scorene blant sidens
spillere -- brutto, INGEN handicap-justering (kilden nevner ingen
egen allowance for selve hullscoren, kun for hvem som spiller sammen).
Krever at BEGGE sider har akkurat like mange registrerte spillere som
faktisk står laget (variabel 2-4, satt av organisator) -- egen,
selvstendig sti, IKKE _side_net (som forutsetter en FAST enhetsstørrelse
per format)."""
best_n = match["shamble_best_n"]
if best_n is None:
return []
score_rows = await conn.fetch(
"SELECT hole_number, team_side::text AS team_side, gross_strokes "
"FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
by_hole_side: dict[tuple[int, str], list[int]] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r["gross_strokes"])
side_size_rows = await conn.fetch(
"SELECT team_side::text AS team_side, count(*) AS n FROM match_participant "
"WHERE match_id = $1 GROUP BY team_side",
match_id,
)
expected_by_side = {r["team_side"]: r["n"] for r in side_size_rows}
results = []
for h in played:
scores_a = by_hole_side.get((h, "a"), [])
scores_b = by_hole_side.get((h, "b"), [])
if len(scores_a) != expected_by_side.get("a", -1) or len(scores_b) != expected_by_side.get("b", -1):
break
if not (1 <= best_n <= len(scores_a)) or not (1 <= best_n <= len(scores_b)):
break
score_a = shamble_hole_score(scores_a, best_n)
score_b = shamble_hole_score(scores_b, best_n)
if score_a < score_b:
results.append(HoleResult.SIDE_A)
elif score_b < score_a:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def _compute_money_ball_hole_results(conn, match_id: str, played: list[int]) -> list[HoleResult]:
"""Money Ball/Lone Ranger (2026-07-30): lagets hullscore = money-ball-
spillerens brutto + laveste av de tre andre -- brutto, INGEN
handicap-justering (samme begrunnelse som Shamble). Rotasjonen er
basert POSISJON i den spilte rekkefølgen (1. spilte hull =
rotasjonsindeks 0), ikke rått hullnummer."""
score_rows = await conn.fetch(
"SELECT hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, "
"gross_strokes FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
by_hole_side: dict[tuple[int, str], dict[str, int]] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), {})[r["match_participant_id"]] = r["gross_strokes"]
lineup_rows = await conn.fetch(
"SELECT id::text AS id, team_side::text AS team_side, lineup_order "
"FROM match_participant WHERE match_id = $1",
match_id,
)
ordered_by_side: dict[str, list[str]] = {"a": [None] * 4, "b": [None] * 4}
for r in lineup_rows:
if r["team_side"] in ordered_by_side and r["lineup_order"] is not None:
ordered_by_side[r["team_side"]][r["lineup_order"]] = r["id"]
results = []
for position, h in enumerate(played, start=1):
side_scores: dict[str, int] = {}
ok = True
for side in ("a", "b"):
order = ordered_by_side[side]
if any(pid is None for pid in order):
ok = False
break
hole_scores = by_hole_side.get((h, side), {})
if len(hole_scores) != 4 or any(pid not in hole_scores for pid in order):
ok = False
break
scores_in_order = [hole_scores[pid] for pid in order]
side_scores[side] = money_ball_hole_score(scores_in_order, position)
if not ok:
break
if side_scores["a"] < side_scores["b"]:
results.append(HoleResult.SIDE_A)
elif side_scores["b"] < side_scores["a"]:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
Update Todos Undersøk kode-kontekst for alle tre byggeoppgavene Avklar åpne designspørsmål med bruker Bygg: flytte spiller mellom lag (backend + frontend) Bygg: individuell rangering i turnering (backend + frontend) Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend) Typesjekket produksjonsbuild av frontend Scratch-verifiser alt tre + full regresjon Browser-verifiser alt tre (Chrome DevTools) Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Rull ut mot ekte systemer (med bekreftelse) Alle tre er live. Oppsummert: Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match. Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen. Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre. Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
played = played_hole_numbers(match["hole_config"])
if match["scoring_mode"] == "hole_result":
rows = await conn.fetch(
"""
SELECT hole_number, winning_side::text AS winning_side
FROM match_hole_result WHERE match_id = $1
""",
match_id,
)
by_hole = {r["hole_number"]: r["winning_side"] for r in rows}
results = []
for h in played:
if h not in by_hole:
break
ws = by_hole[h]
results.append(
HoleResult.SIDE_A if ws == "a" else HoleResult.SIDE_B if ws == "b" else HoleResult.HALVED
)
return results
if match["format"] == "shamble":
return await _compute_shamble_hole_results(conn, match_id, match, played)
if match["format"] == "money_ball":
return await _compute_money_ball_hole_results(conn, match_id, played)
if match["format"] == "high_low_high":
# Passer IKKE inn i den ternære HoleResult-modellen under (som
# forutsetter ÉN sammenlignbar verdi per side per hull) -- High-
# low-high trenger BEGGE spilleres individuelle Stableford-poeng
# for selve høy/lav-rangeringen, og literal poengsum (0/1/2 per
# hull), ikke bare hvem som vant. Egen, dedikert GET-endepunkt
# (/high-low-high) dekker den faktiske stillingen -- match sin
# vanlige status_text/leading_side forblir bevisst null for dette
# formatet i v1 (samme "regn ut ved lesing i et eget endepunkt"-
# mønster som Nassau, ikke tvunget inn i denne cachen).
return []
# 'stroke'-modus
allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
config = parse_allowance_config(match["format"], allowance_override)
relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
if not relative:
return [] # handicap ikke klart for én eller flere enheter ennå
stroke_index_rows = await conn.fetch(
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
match["course_id"],
)
all_18_si = [r["stroke_index"] for r in stroke_index_rows]
# Uten alle 18 hull (f.eks. en egendefinert bane der hull aldri ble lagt
# til) kan slagfordelingen (allocate_over_played_holes) ikke gjøres --
# avvis tydelig FØR beregning i stedet for å krasje rått på tom liste.
if len(all_18_si) != 18:
raise app_error(
400,
"VALIDATION_FAILED",
"Banen mangler registrerte hull -- kan ikke beregne slagfordeling for stroke-modus.",
)
strokes_per_hole: dict[str, dict[int, int]] = {
unit: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
for unit, total in relative.items()
}
score_rows = await conn.fetch(
"""
SELECT hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes
FROM hole_score WHERE match_id = $1
""",
match_id,
)
by_hole_side: dict[tuple[int, str], list] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r)
results = []
for h in played:
net_a = _side_net(by_hole_side.get((h, "a"), []), strokes_per_hole, match["format"], h)
net_b = _side_net(by_hole_side.get((h, "b"), []), strokes_per_hole, match["format"], h)
if net_a is None or net_b is None:
break
if net_a < net_b:
results.append(HoleResult.SIDE_A)
elif net_b < net_a:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def recompute_and_cache_match_state(conn, match_id: str) -> None:
match = await conn.fetchrow(
"""
SELECT m.id::text, s.format, s.scoring_mode,
s.hole_config::text AS hole_config, s.course_id::text AS course_id,
s.allowance_override::text AS allowance_override,
s.points_per_match::float AS points_per_match,
s.tournament_id::text AS tournament_id, s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
FOR UPDATE OF m
""",
match_id,
)
results = await _compute_hole_results(conn, match_id, match)
Update Todos Undersøk kode-kontekst for alle tre byggeoppgavene Avklar åpne designspørsmål med bruker Bygg: flytte spiller mellom lag (backend + frontend) Bygg: individuell rangering i turnering (backend + frontend) Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend) Typesjekket produksjonsbuild av frontend Scratch-verifiser alt tre + full regresjon Browser-verifiser alt tre (Chrome DevTools) Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Rull ut mot ekte systemer (med bekreftelse) Alle tre er live. Oppsummert: Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match. Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen. Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre. Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
played = played_hole_numbers(match["hole_config"])
state = compute_match_state(results, total_holes=len(played))
# ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne
# (i tillegg til den menneskelesbare status_text), slik at frontend kan
# style etter et strukturert felt i stedet for å parse tekst. Gjelder
# BÅDE pågående og avgjorte matcher -- vinnersiden i en avgjort match
# er alltid den samme som lead sitt fortegn allerede pekte på.
if state.lead > 0:
leading_side = "a"
elif state.lead < 0:
leading_side = "b"
else:
leading_side = None
complete = state.is_closed or state.holes_remaining == 0
if complete:
ppm = match["points_per_match"]
if state.lead > 0:
points_a, points_b = ppm, 0.0
elif state.lead < 0:
points_a, points_b = 0.0, ppm
else:
points_a = points_b = ppm / 2
await conn.execute(
"""
UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3,
leading_side = $4
WHERE id = $5
""",
state.describe(),
points_a,
points_b,
leading_side,
match_id,
)
else:
await conn.execute(
"UPDATE match SET status_text = $1, leading_side = $2 WHERE id = $3",
state.describe(),
leading_side,
match_id,
)
# ADR-027: "Følg live"-siden lyttes til over WebSocket -- kringkast et
# "noe endret seg, hent på nytt"-signal til enhver som følger DENNE
# turneringen. Trygt å kalle uansett om noen faktisk lytter (no-op da).
await broadcast_live_update(match["tournament_id"])
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, tournament_id: str
) -> 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 selv
ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er
avgjort FØR denne kalles -- se de to kallstedene (denne filens
concede_match, tournaments.py sin concede_tournament).
`tournament_id` trengs KUN for ADR-027 sin "Følg live"-kringkasting."""
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,
)
await broadcast_live_update(tournament_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,
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
organization_id: str,
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
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,
s.tournament_id::text AS tournament_id
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"], match["tournament_id"]
)
return result
class HoleScoreCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$")
match_participant_id: str | None = None
hole_number: int = Field(ge=1, le=18)
gross_strokes: int = Field(ge=1, le=20)
# Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme
# funksjon som frittstående runder fikk 2026-07-25). KUN meningsfullt for
# delt-ball-hull (match_participant_id er None) -- helt valgfritt.
selected_participant_id: str | None = None
# Optimistisk versjonssjekk (ADR-060, samme mønster som round_hole/
# ADR-057). None = hopp over sjekken -- eneste rimelige verdi for et
# hull som ikke er registrert fra før (klienten kan ikke kjenne en
# versjon som ikke finnes ennå), og fortsatt bakoverkompatibelt med
# eldre klienter som ikke sender feltet.
expected_version: int | None = None
class HoleScoreOut(BaseModel):
hole_number: int
team_side: str
match_participant_id: str | None
gross_strokes: int
selected_participant_id: str | None = None
version: int
@router.post(
"/orgs/{organization_id}/matches/{match_id}/hole-scores",
response_model=HoleScoreOut,
status_code=201,
)
async def submit_hole_score(
match_id: str,
body: HoleScoreCreate,
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> HoleScoreOut:
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
SELECT m.points_side_a::float AS points_side_a,
s.format, s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
2026-07-17 21:40:42 +02:00
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
2026-07-17 21:40:42 +02:00
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if match["scoring_mode"] != "stroke":
2026-07-17 21:40:42 +02:00
raise app_error(
400,
"WRONG_SCORING_MODE",
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
)
Update Todos Undersøk kode-kontekst for alle tre byggeoppgavene Avklar åpne designspørsmål med bruker Bygg: flytte spiller mellom lag (backend + frontend) Bygg: individuell rangering i turnering (backend + frontend) Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend) Typesjekket produksjonsbuild av frontend Scratch-verifiser alt tre + full regresjon Browser-verifiser alt tre (Chrome DevTools) Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Rull ut mot ekte systemer (med bekreftelse) Alle tre er live. Oppsummert: Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match. Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen. Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre. Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
played = played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
2026-07-17 21:40:42 +02:00
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
is_individual = match["format"] in _INDIVIDUAL_FORMATS
if is_individual and body.match_participant_id is None:
2026-07-17 21:40:42 +02:00
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"Dette formatet krever match_participant_id (individuell ball).",
)
if not is_individual and body.match_participant_id is not None:
2026-07-17 21:40:42 +02:00
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
)
if body.match_participant_id is not None:
participant = await conn.fetchrow(
"""
SELECT team_side::text AS team_side FROM match_participant
WHERE id = $1 AND match_id = $2
""",
body.match_participant_id,
match_id,
)
if participant is None:
2026-07-17 21:40:42 +02:00
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.")
if participant["team_side"] != body.team_side:
2026-07-17 21:40:42 +02:00
raise app_error(
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
)
# Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039) --
# gir kun mening for delt-ball-hull, og må tilhøre samme side som
# selve scoren registreres for (samme sjekk som round.py sin
# tilsvarende validering for frittstående runder).
if body.selected_participant_id is not None:
if body.match_participant_id is not None:
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"selected_participant_id gjelder kun delt-ball-hull, ikke individuell ball.",
)
selected = await conn.fetchrow(
"SELECT team_side::text AS team_side FROM match_participant WHERE id = $1 AND match_id = $2",
body.selected_participant_id,
match_id,
)
if selected is None:
raise app_error(404, "NOT_FOUND", "Den valgte spilleren finnes ikke i denne matchen.")
if selected["team_side"] != body.team_side:
raise app_error(
400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne siden."
)
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
if not await user_is_match_participant(
conn, organization_id, match_id, user.user_id, team_side=body.team_side
):
raise app_error(
403,
"NOT_MATCH_PARTICIPANT",
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
)
# Versjonssjekken (ADR-060) trengs KUN på DO UPDATE-grenen -- en helt
# ny rad (første registrering av dette hullet) tar alltid den rene
# INSERT-veien og bryr seg aldri om expected_version, uansett hva
# klienten sendte (det finnes ingen tidligere versjon å sjekke mot).
# Et `row is None`-resultat herfra betyr derfor ALLTID en ekte
# versjonskonflikt -- aldri "finnes ikke" (INSERT-grenen dekker det
# tilfellet transparent), ulikt round_hole som trengte en egen
# eksistenssjekk for å skille de to.
if body.match_participant_id is not None:
row = await conn.fetchrow(
"""
INSERT INTO hole_score
(organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now(),
version = hole_score.version + 1
WHERE $7::int IS NULL OR hole_score.version = $7::int
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id, version
""",
organization_id,
match_id,
body.team_side,
body.match_participant_id,
body.hole_number,
body.gross_strokes,
body.expected_version,
)
else:
row = await conn.fetchrow(
"""
INSERT INTO hole_score
(organization_id, match_id, team_side, match_participant_id, hole_number,
gross_strokes, selected_participant_id)
VALUES ($1, $2, $3, NULL, $4, $5, $6)
ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes,
selected_participant_id = EXCLUDED.selected_participant_id,
updated_at = now(), version = hole_score.version + 1
WHERE $7::int IS NULL OR hole_score.version = $7::int
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id, version
""",
organization_id,
match_id,
body.team_side,
body.hole_number,
body.gross_strokes,
body.selected_participant_id,
body.expected_version,
)
if row is None:
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await recompute_and_cache_match_state(conn, match_id)
return HoleScoreOut(**dict(row))
class HoleResultCreate(BaseModel):
hole_number: int = Field(ge=1, le=18)
winning_side: str | None = Field(default=None, pattern="^[ab]$")
# Se HoleScoreCreate.expected_version -- samme begrunnelse, samme mønster.
expected_version: int | None = None
class HoleResultOut(BaseModel):
hole_number: int
winning_side: str | None
version: int
@router.post(
"/orgs/{organization_id}/matches/{match_id}/hole-results",
response_model=HoleResultOut,
status_code=201,
)
async def submit_hole_result(
match_id: str,
body: HoleResultCreate,
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> HoleResultOut:
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
SELECT m.points_side_a::float AS points_side_a,
s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
2026-07-17 21:40:42 +02:00
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
2026-07-17 21:40:42 +02:00
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if match["scoring_mode"] != "hole_result":
2026-07-17 21:40:42 +02:00
raise app_error(
400,
"WRONG_SCORING_MODE",
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
)
Update Todos Undersøk kode-kontekst for alle tre byggeoppgavene Avklar åpne designspørsmål med bruker Bygg: flytte spiller mellom lag (backend + frontend) Bygg: individuell rangering i turnering (backend + frontend) Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend) Typesjekket produksjonsbuild av frontend Scratch-verifiser alt tre + full regresjon Browser-verifiser alt tre (Chrome DevTools) Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Rull ut mot ekte systemer (med bekreftelse) Alle tre er live. Oppsummert: Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match. Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen. Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre. Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
played = played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
2026-07-17 21:40:42 +02:00
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
# Begge sider kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må selv være deltaker i DENNE matchen,
# på hvilken som helst av de to sidene (team_side=None).
if not await user_is_match_participant(conn, organization_id, match_id, user.user_id):
2026-07-17 21:40:42 +02:00
raise app_error(
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
403,
"NOT_MATCH_PARTICIPANT",
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
)
row = await conn.fetchrow(
"""
INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side)
VALUES ($1, $2, $3, $4)
ON CONFLICT (match_id, hole_number)
DO UPDATE SET winning_side = EXCLUDED.winning_side,
version = match_hole_result.version + 1
WHERE $5::int IS NULL OR match_hole_result.version = $5::int
RETURNING hole_number, winning_side::text AS winning_side, version
""",
organization_id,
match_id,
body.hole_number,
body.winning_side,
body.expected_version,
)
if row is None:
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await recompute_and_cache_match_state(conn, match_id)
return HoleResultOut(**dict(row))
class ScorecardHole(BaseModel):
hole_number: int
result: str
class Scorecard(BaseModel):
match_id: str
status_text: str | None
points_side_a: float | None
points_side_b: float | None
holes: list[ScorecardHole]
# Rå oppføringer -- UTEN dette kan ikke skjermen vise/redigere hva som
# faktisk er tastet inn, kun det utledede vinn/tap/delt-resultatet
# (utilstrekkelig for en gjenlastet scorekort-side). Nøyaktig ett av de
# to feltene er fylt ut, avhengig av øktens scoring_mode.
stroke_entries: list[HoleScoreOut] | None = None
hole_result_entries: list[HoleResultOut] | None = None
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
async def fetch_scorecard(conn, match_id: str) -> Scorecard:
"""Delt mellom den innloggede get_scorecard under og den offentlige
/public/tournaments/{id}/matches/{id}/scorecard (registration.py,
tilskuer-rolle/ADR-026). Ingen egen synlighetsfiltrering her -- den
offentlige ruten avgjør SELV, FØR denne kalles, om matchen i det hele
tatt er avslørt (blind draw, ADR-013) og om turneringen er synlig."""
match = await conn.fetchrow(
"""
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
m.points_side_b::float AS points_side_b,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override,
s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
results = await _compute_hole_results(conn, match_id, match)
stroke_entries = None
hole_result_entries = None
if match["scoring_mode"] == "stroke":
rows = await conn.fetch(
"""
SELECT hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id, version
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
)
stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
else:
rows = await conn.fetch(
"""
SELECT hole_number, winning_side::text AS winning_side, version
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
)
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
Update Todos Undersøk kode-kontekst for alle tre byggeoppgavene Avklar åpne designspørsmål med bruker Bygg: flytte spiller mellom lag (backend + frontend) Bygg: individuell rangering i turnering (backend + frontend) Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend) Typesjekket produksjonsbuild av frontend Scratch-verifiser alt tre + full regresjon Browser-verifiser alt tre (Chrome DevTools) Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Rull ut mot ekte systemer (med bekreftelse) Alle tre er live. Oppsummert: Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match. Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen. Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre. Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
played = played_hole_numbers(match["hole_config"])
holes = [
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
]
return Scorecard(
stroke_entries=stroke_entries,
hole_result_entries=hole_result_entries,
match_id=match["id"],
status_text=match["status_text"],
points_side_a=match["points_side_a"],
points_side_b=match["points_side_b"],
holes=holes,
)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/scorecard",
response_model=Scorecard,
)
async def get_scorecard(
match_id: str,
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> Scorecard:
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
"""2026-07-21 (deltaker-tilgang-runden): denne hadde tidligere INGEN
egen sjekk utover get_authorized_org (org-medlemskap) -- å bare fjerne
den ville latt EN HVILKEN SOM HELST autentisert bruker se ethvert
scorekort i organisasjonen. Erstattet med et eksplisitt OR: org-medlem
(bevarer eksisterende oppførsel for dagens brukere uendret) ELLER
deltaker i turneringen matchen hører til (ny -- gir en rostret/påmeldt
spiller uten org-medlemskap lesetilgang til scorekort i EGEN turnering,
samme brede "kan se andres matcher i egen turnering"-nivå som
tilskuer-siden ADR-026 allerede gir enhver som kan se turneringen)."""
async with org_connection(organization_id) as conn:
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster) Bygge HCP-historikk over tid Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort: Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder". Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match). Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
tournament_id = await conn.fetchval(
"""
SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if tournament_id is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, tournament_id, user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
return await fetch_scorecard(conn, match_id)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/holes/{hole_number}/target-points",
response_model=list[TargetPoint],
)
async def get_match_hole_target_points(
match_id: str,
hole_number: int,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[TargetPoint]:
"""Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp
som frittstående runders og individuell-turnerings target-points, her
for lag-matchplay (Ryder Cup-format). Samme tilgangsnivå/-mønster som
`get_scorecard` over (org-medlem ELLER deltaker i turneringen)."""
async with org_connection(organization_id) as conn:
tournament_id = await conn.fetchval(
"""
SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if tournament_id is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, tournament_id, user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
course_key = await resolve_match_course_key(conn, match_id)
points = await get_target_points(conn, course_key, hole_number)
return points
# ---------------------------------------------------------------------------
# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre
# parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull-
# data (_compute_hole_results, allerede posisjonert 1:1 mot hullnummer for
# hole_config='full_18' -- se played_hole_numbers), hver kjørt gjennom den
# allerede eksisterende compute_match_state. Samme "regn ut ved lesing,
# aldri cache"-filosofi som resten av formatresultat-endepunktene.
# ---------------------------------------------------------------------------
class NassauWindowOut(BaseModel):
lead: int
holes_played: int
holes_remaining: int
is_closed: bool
is_dormie: bool
status_text: str
class NassauResultOut(BaseModel):
ready: bool
front9: NassauWindowOut | None = None
back9: NassauWindowOut | None = None
overall: NassauWindowOut | None = None
def _nassau_window(hole_results: list[HoleResult], total_holes: int) -> NassauWindowOut:
state = compute_match_state(hole_results, total_holes=total_holes)
return NassauWindowOut(
lead=state.lead,
holes_played=state.holes_played,
holes_remaining=state.holes_remaining,
is_closed=state.is_closed,
is_dormie=state.is_dormie,
status_text=state.describe(),
)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/nassau",
response_model=NassauResultOut,
)
async def get_nassau_result(
match_id: str,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> NassauResultOut:
"""Samme autorisasjon som get_scorecard over -- org-medlem ELLER
deltaker i turneringen matchen hører til."""
async with org_connection(organization_id) as conn:
match = await conn.fetchrow(
"""
SELECT m.id::text AS id, s.tournament_id::text AS tournament_id,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id,
s.allowance_override::text AS allowance_override, s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, match["tournament_id"], user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
if match["hole_config"] != "full_18":
raise app_error(400, "VALIDATION_FAILED", "Nassau krever 18 hull.")
hole_results = await _compute_hole_results(conn, match_id, match)
if not hole_results:
return NassauResultOut(ready=False)
return NassauResultOut(
ready=True,
front9=_nassau_window(hole_results[0:9], total_holes=9),
back9=_nassau_window(hole_results[9:18], total_holes=9),
overall=_nassau_window(hole_results[0:18], total_holes=18),
)
# ---------------------------------------------------------------------------
# High-low-high (2026-07-30) -- egen, dedikert visning (som Nassau) --
# passer IKKE inn i den ternære HoleResult-/status_text-cachen (se
# _compute_hole_results sin egen kommentar). Beregnes helt ved LESING.
# ---------------------------------------------------------------------------
class HighLowHighHoleOut(BaseModel):
hole_number: int
points_a: int
points_b: int
class HighLowHighResultOut(BaseModel):
ready: bool
points_a: int | None = None
points_b: int | None = None
holes: list[HighLowHighHoleOut] | None = None
@router.get(
"/orgs/{organization_id}/matches/{match_id}/high-low-high",
response_model=HighLowHighResultOut,
)
async def get_high_low_high_result(
match_id: str,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> HighLowHighResultOut:
"""Samme autorisasjon som get_scorecard/get_nassau_result over."""
async with org_connection(organization_id) as conn:
match = await conn.fetchrow(
"""
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.tournament_id::text AS tournament_id, s.format, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, match["tournament_id"], user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
if match["format"] != "high_low_high":
raise app_error(400, "VALIDATION_FAILED", "Denne matchen bruker ikke High-low-high.")
allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
config = parse_allowance_config(match["format"], allowance_override)
relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
if not relative:
return HighLowHighResultOut(ready=False)
participant_rows = await conn.fetch(
"SELECT id::text AS id, team_side::text AS team_side FROM match_participant WHERE match_id = $1",
match_id,
)
by_side: dict[str, list[str]] = {"a": [], "b": []}
for p in participant_rows:
by_side[p["team_side"]].append(p["id"])
if len(by_side["a"]) != 2 or len(by_side["b"]) != 2:
return HighLowHighResultOut(ready=False)
stroke_index_rows = await conn.fetch(
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
match["course_id"],
)
all_18_si = [r["stroke_index"] for r in stroke_index_rows]
if len(all_18_si) != 18:
raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
played = played_hole_numbers(match["hole_config"])
strokes_per_hole = {
pid: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
for pid, total in relative.items()
}
score_rows = await conn.fetch(
"SELECT hole_number, match_participant_id::text AS match_participant_id, gross_strokes "
"FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
par_rows = await conn.fetch(
"SELECT hole_number, par FROM hole WHERE course_id = $1", match["course_id"]
)
par_by_hole = {r["hole_number"]: r["par"] for r in par_rows}
gross_by_hole: dict[int, dict[str, int]] = {}
for r in score_rows:
gross_by_hole.setdefault(r["hole_number"], {})[r["match_participant_id"]] = r["gross_strokes"]
hole_points: list[tuple[int, int]] = []
holes_out = []
for h in played:
scores_this_hole = gross_by_hole.get(h, {})
pids_a, pids_b = by_side["a"], by_side["b"]
if any(pid not in scores_this_hole for pid in pids_a + pids_b):
break
points_a_players = tuple(
stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
for pid in pids_a
)
points_b_players = tuple(
stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
for pid in pids_b
)
pa, pb = high_low_high_points_for_hole(points_a_players, points_b_players)
hole_points.append((pa, pb))
holes_out.append(HighLowHighHoleOut(hole_number=h, points_a=pa, points_b=pb))
if not hole_points:
return HighLowHighResultOut(ready=True, points_a=0, points_b=0, holes=[])
total_a, total_b = high_low_high_running_score(hole_points)
return HighLowHighResultOut(ready=True, points_a=total_a, points_b=total_b, holes=holes_out)