diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ec47b89..456686b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -240,7 +240,14 @@ "Bash(cd /opt/teeoff/backend && ls -la; echo ---; find . -maxdepth 2 -type f | grep -v node_modules)", "Read(//opt/teeoff/backend/**)", "Bash(cd /opt/teeoff/backend && grep -n \"app\\\\.\\\\\\(get\\\\|post\\\\|put\\\\|delete\\\\|patch\\\\\\)\\\\|@app\\\\.\\\\|APIRouter\\\\|router\\\\.\\\\\\(get\\\\|post\\\\\\)\" main.py | grep -i \"facilit\\\\|course\\\\|club\" )", - "Bash(cd /opt/teeoff && grep -n \"CREATE TABLE.*\\\\\\(courses\\\\|tees\\\\|holes\\\\|hole_lengths\\\\|facilities\\\\\\)\\\\b\" schema.sql init.sql 2>/dev/null)" + "Bash(cd /opt/teeoff && grep -n \"CREATE TABLE.*\\\\\\(courses\\\\|tees\\\\|holes\\\\|hole_lengths\\\\|facilities\\\\\\)\\\\b\" schema.sql init.sql 2>/dev/null)", + "Bash(awk 'NR>=4645 && NR<=4682' /opt/teeoff/backend/main.py)", + "Bash(python3 -m py_compile app/routers/courses.py app/teeoff_client.py app/config.py)", + "Bash(grep -n \"setSelectedCourseId\\(null\\)$\" /opt/teecup/frontend/components/tournament-program.tsx)", + "Bash(awk -F: '$1>=782 && $1<=980')", + "Bash(python3 -)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'facilities'\\); print\\(d[:2]\\)\")", + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/scratch_cookies2.txt)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/010_official_course_unique_ref.sql b/010_official_course_unique_ref.sql new file mode 100644 index 0000000..4ff60d4 --- /dev/null +++ b/010_official_course_unique_ref.sql @@ -0,0 +1,19 @@ +-- ===================================================================== +-- TeeCup — migrasjon 010 +-- Offisiell banedata fra teeoff: hindre dupliserte importer (ADR-019) +-- ===================================================================== +-- Kjøres etter 001-009. Additiv (partiell unik indeks, rører ingen +-- eksisterende rader -- alle course-rader har source='custom' i dag, +-- external_course_ref er alltid NULL, og NULL matcher aldri seg selv i en +-- UNIQUE-indeks). +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- Importerer en organisator samme teeoff-bane to ganger, skal den andre +-- importen avvises av den eksisterende DUPLICATE-feilkoden (409) -- +-- ikke stille sette inn en ny rad med identisk banedata (ADR-019 +-- Beslutning E). +CREATE UNIQUE INDEX course_org_external_ref_unique + ON course (organization_id, external_course_ref) + WHERE external_course_ref IS NOT NULL; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index eb11179..994f646 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -5,7 +5,7 @@ > Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares. **Status:** Levende dokument -**Sist oppdatert:** 2026-07-16 +**Sist oppdatert:** 2026-07-18 --- @@ -566,6 +566,83 @@ satt opp i praksis). --- +## ADR-019 — Offisiell banedata: import (kopi), ikke live oppslag + +**Kontekst:** ADR-004 vedtok prinsippet (lesende API, ikke delt database) helt +i starten av prosjektet, men ble aldri bygget — kun `source='custom'`-baner +fantes (organisator taster inn banen selv). Brukeren påpekte dette rett etter +program-skjerm-runden (2026-07-18): det er en reell mangel at en organisator +må taste inn en bane manuelt når banen faktisk allerede finnes i teeoff. + +**Kartlagt før noe ble besluttet** (lest `/opt/teeoff/backend/main.py`, kun +lesing): `GET /api/facilities/{slug}` er offentlig, uten auth/API-nøkkel, og +returnerer allerede `courses[]` med `holes[]` (`hole_number`, `par`, +`hcp_index`) og `tees[]` (`name`, `cr_men`/`slope_men`, `cr_women`/ +`slope_women`) — nøyaktig det `hole`/`tee`/`tee_rating`-tabellene i +`teecup_db` (migrasjon 001) allerede modellerer. `GET /api/facilities? +view=search` gir en lettvekts liste (`id`, `slug`, `name`, `city`, `county`, +…) egnet for søk. Par/hcp_index kan være NULL i teeoffs skjema (ufullstendig +baneregistrering) — teecups `hole`-tabell krever begge NOT NULL. + +**Beslutning A — Import ved organisators eksplisitte valg, ikke live +oppslag ved hver bruk.** Organisator søker blant teeoff sine baner, velger +én, og teecup kopierer da bane+hull+tee+tee_rating INN i `teecup_db` som en +vanlig `course`-rad med `source='official'` og +`external_course_ref = '{facility_slug}:{teeoff_course_id}'`. Etter import +er raden en helt vanlig lokal `course`-rad — økt-/match-/handicap-koden +(som allerede kun kjenner `course_id` som fremmednøkkel) trenger INGEN +endring. + +**Begrunnelse:** Handicap-motoren joiner `tee_rating`/`hole` direkte i SQL +midt i en database-transaksjon (`app/handicap.py`) — et live HTTP-kall til +teeoff derfra ville krevd at hver matchberegning avhenger av teeoffs +oppetid, og ville ikke vært transaksjonssikkert. Import fryser dataene på +importtidspunktet — samme reproduserbarhets-prinsipp som handicap- +snapshotten i ADR-007 (`team_roster.handicap_index_snapshot`): endrer +teeoff en rating i etterkant, skal ikke en allerede opprettet turnering +plutselig regne annerledes. Dette er også det som gjør ADR-004s opprinnelige +"et brudd ett sted skal ikke ta ned begge produktene"-begrunnelse reell: en +teeoff-nedetid blokkerer kun NYE importer, ikke bruk av allerede importerte +baner. + +**Beslutning B — Server-til-server, internt Docker-nettverk, ingen ny +hemmelighet.** `teecup_api` kaller `http://teeoff_api:8000` direkte (samme +`teeoff_default`-nettverk begge allerede deler) — ikke via Caddy/det +offentlige domenet. Teeoff sitt API har ingen auth-mekanisme på disse +endepunktene i det hele tatt, så ingen ny credential trengs. CORS-listen på +teeoff-siden (ekskluderer teecups origin) er irrelevant — CORS gjelder kun +nettleser-fetch, ikke et backend-til-backend-kall. Ny avhengighet: `httpx` +(async HTTP-klient), lagt til i `app/requirements.txt`. + +**Beslutning C — Ufullstendige teeoff-data feiler importen tydelig, importen +skjer aldri delvis.** Mangler et hull `par`/`hcp_index`, eller mangler en +tee både `cr_men` OG `cr_women`, avvises HELE importen med en klar feil +(`code: EXTERNAL_DATA_INCOMPLETE`) FØR noe skrives — ikke en delvis +importert bane med hull som senere feiler i handicap-beregning. Hele +importen kjører i én DB-transaksjon (`org_connection` sin eksisterende +`conn.transaction()`), så en feil midtveis ruller automatisk tilbake. + +**Beslutning D — Kun `full_18`-rating importeres.** Teeoff har ingen egen +front9/back9-rating i sitt skjema (kun én CR/slope per tee, kjønnsdelt). +Samme valg som ADR-008 allerede gjorde bevisst for egendefinerte baner: den +formelle WHS 9-hulls-metoden er ikke i bruk, 18-hulls-tildelingen med uttak +av spilte hull dekker front/back-økter. + +**Beslutning E — Reimport av samme teeoff-bane er en feil, ikke en +duplikat-rad.** Ny partiell unik indeks `(organization_id, +external_course_ref) WHERE external_course_ref IS NOT NULL` (migrasjon +`010`) — importerer en organisator samme teeoff-bane to ganger, avvises det +med den eksisterende `DUPLICATE`-koden (409), ikke en ny rad med samme +banedata. + +**Konsekvens:** `app/teeoff_client.py` (ny, ren HTTP-klient, ingen +db/RLS-avhengighet — samme isolasjonsprinsipp som `handicap_engine.py`). +`app/routers/courses.py` utvides med `GET .../courses/official-search` og +`POST .../courses/official-import`. Migrasjon `010_official_course_unique_ +ref.sql`. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CLAUDE.md b/CLAUDE.md index bb99967..cddac6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -722,10 +722,18 @@ Ferdig og verifisert: over er basert på å lese koden direkte. Stabil identifikator: `facilities. slug` (f.eks. `borregaard-golfklubb`) — selve banen har kun en intern serial-id, ingen egen slug, så `course.external_course_ref` må bære - facility-slug + bane-id sammen. **Ikke scopet eller bygget ennå** — egen, - senere avklaring med bruker om omfang (import-flyt vs. live-oppslag hver - gang, hvor mye av bane-/tee-/hull-detaljene som skal kopieres inn i - `teecup_db` vs. slås opp on-demand). + facility-slug + bane-id sammen. + **Oppdatering samme dag: brukeren ba meg ta fatt på dette NÅ** (bevisst + sidesprang fra "bygg i rekkefølgen ting brukes"-planen — blind draw- + skjermen er fortsatt neste steg i den planen ETTERPÅ, ikke droppet). + Design besluttet og skrevet som **ADR-019** (se ARCHITECTURE_DECISIONS.md): + import (kopi) ved organisators eksplisitte valg, ikke live oppslag ved + hver bruk — fryser data på importtidspunktet, samme reproduserbarhets- + prinsipp som handicap-snapshotten (ADR-007). Server-til-server-kall + (`teecup_api` → `http://teeoff_api:8000`, internt Docker-nettverk, ingen + auth trengs). Ny migrasjon `010` (unik `external_course_ref` per org, + hindrer dupliserte importer). Bygges nå — se ADR-019 for alle fem + delbeslutningene. Neste steg: 1. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster: diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index dee8719..54ec09d 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -25,7 +25,7 @@ | Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. | | API: oppsett (spillere/lag/roster/økter/matcher/blind draw) | ✅ | Verifisert for ekte mot scratch-db + engangscontainer. | | API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. | -| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». **Kartlagt 2026-07-18** (ikke bygget ennå): `GET /api/facilities/{slug}` i `/opt/teeoff/backend/main.py` er offentlig, uten auth, og har allerede alt vi trenger — navn, hull med par+hcp_index, tee-navn, course/slope rating per tee (kjønnsdelt). Ingen CORS/API-nøkkel-hinder siden dette blir et server-til-server-kall, ikke nettleser. Se program-skjerm-runden i CLAUDE.md-status for full kartlegging. | +| Banedata fra teeoff via API | 🔨 | ADR-004 → **ADR-019** (design besluttet 2026-07-18, bygges nå — sidesprang fra "bygg i rekkefølgen ting brukes", blind draw-skjermen er fortsatt neste steg etterpå). Import (kopi) ved eksplisitt organisator-valg, ikke live oppslag — se ADR-019 i ARCHITECTURE_DECISIONS.md for alle fem delbeslutningene og CLAUDE.md-status for kartleggingen av teeoff sitt `/api/facilities`-API. | | Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. | | Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. | | RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. | diff --git a/app/config.py b/app/config.py index 73666f0..defb23a 100644 --- a/app/config.py +++ b/app/config.py @@ -83,5 +83,10 @@ class Settings: MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media") MINIO_INTERNAL_ENDPOINT: str = os.getenv("TEECUP_MINIO_INTERNAL_ENDPOINT", "teecup-minio:9000") + # Offisiell banedata (ADR-019). Internt Docker-nettverk (teeoff_default, + # delt med teecup_api) -- ikke via Caddy/det offentlige domenet. Teeoffs + # facilities-API er offentlig uten auth, så ingen hemmelighet trengs her. + TEEOFF_API_BASE_URL: str = os.getenv("TEECUP_TEEOFF_API_BASE_URL", "http://teeoff_api:8000") + settings = Settings() diff --git a/app/requirements.txt b/app/requirements.txt index 0ff94b8..323d73e 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -7,3 +7,4 @@ minio>=7.2 pillow>=10.0 pillow-avif-plugin>=1.4 python-multipart>=0.0.9 +httpx>=0.27 diff --git a/app/routers/courses.py b/app/routers/courses.py index 4417f60..f011cb1 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -11,11 +11,12 @@ ennå. Denne runden dekker KUN det organisatoren trenger for å opprette en bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold). """ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query +from .. import teeoff_client from ..auth import get_authorized_org from ..db import org_connection -from ..errors import translate_db_errors +from ..errors import app_error, translate_db_errors from pydantic import BaseModel, Field router = APIRouter() @@ -59,3 +60,182 @@ async def create_course( body.name, ) return Course(**dict(row)) + + +# --- Offisiell banedata fra teeoff (ADR-019) --------------------------------- + + +class OfficialFacility(BaseModel): + slug: str + name: str + city: str | None = None + county: str | None = None + + +class OfficialCourseOption(BaseModel): + teeoff_course_id: int + name: str + is_main_course: bool + + +class OfficialFacilityDetail(BaseModel): + slug: str + name: str + courses: list[OfficialCourseOption] + + +class OfficialCourseImport(BaseModel): + facility_slug: str + teeoff_course_id: int + + +@router.get("/orgs/{organization_id}/courses/official-search", response_model=list[OfficialFacility]) +async def search_official_courses( + q: str = Query(default=""), + organization_id: str = Depends(get_authorized_org), +) -> list[OfficialFacility]: + try: + facilities = await teeoff_client.search_facilities(q) + except teeoff_client.TeeoffUnavailableError: + raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.") + return [ + OfficialFacility(slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county")) + for f in facilities + ] + + +@router.get( + "/orgs/{organization_id}/courses/official-search/{slug}", + response_model=OfficialFacilityDetail, +) +async def get_official_facility( + slug: str, + organization_id: str = Depends(get_authorized_org), +) -> OfficialFacilityDetail: + try: + facility = await teeoff_client.get_facility(slug) + except teeoff_client.TeeoffNotFoundError: + raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.") + except teeoff_client.TeeoffUnavailableError: + raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.") + + courses = [ + OfficialCourseOption( + teeoff_course_id=c["id"], + name=c["name"], + is_main_course=bool(c.get("is_main_course")), + ) + # Kun 18-hulls baner kan importeres foreløpig (ADR-019) -- filtrert + # her slik at organisator aldri ser et valg som senere feiler. + for c in facility.get("courses", []) + if len(c.get("holes") or []) == 18 + ] + return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses) + + +@router.post("/orgs/{organization_id}/courses/official-import", response_model=Course, status_code=201) +async def import_official_course( + body: OfficialCourseImport, + organization_id: str = Depends(get_authorized_org), +) -> Course: + try: + facility = await teeoff_client.get_facility(body.facility_slug) + except teeoff_client.TeeoffNotFoundError: + raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.") + except teeoff_client.TeeoffUnavailableError: + raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.") + + course_data = next( + (c for c in facility.get("courses", []) if c.get("id") == body.teeoff_course_id), None + ) + if course_data is None: + raise app_error(404, "NOT_FOUND", "Banen finnes ikke på dette anlegget i teeoff.") + + holes = course_data.get("holes") or [] + tees = course_data.get("tees") or [] + + # ADR-019 Beslutning C: hele importen feiler tydelig FØR noe skrives, + # aldri en delvis importert bane som senere feiler i handicap-beregning. + if len(holes) != 18: + raise app_error( + 400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull i teeoff ennå." + ) + for h in holes: + if h.get("par") is None or h.get("hcp_index") is None: + raise app_error( + 400, + "EXTERNAL_DATA_INCOMPLETE", + "Banen mangler par eller HCP-index på ett eller flere hull i teeoff.", + ) + + # (navn, kjønn, course_rating, slope_rating) -- ADR-019 Beslutning D: kun + # full_18-rating importeres, teeoff har ingen egen front9/back9-rating. + tee_inputs: list[tuple[str, str, float, int]] = [] + for t in tees: + name = t.get("name") or "Tee" + if t.get("cr_men") is not None and t.get("slope_men") is not None: + tee_inputs.append((name, "m", float(t["cr_men"]), int(t["slope_men"]))) + if t.get("cr_women") is not None and t.get("slope_women") is not None: + tee_inputs.append((name, "f", float(t["cr_women"]), int(t["slope_women"]))) + if not tee_inputs: + raise app_error( + 400, + "EXTERNAL_DATA_INCOMPLETE", + "Banen har ingen tee med registrert rating/slope i teeoff ennå.", + ) + + external_ref = f"{body.facility_slug}:{body.teeoff_course_id}" + par_total = sum(h["par"] for h in holes) + + async with org_connection(organization_id) as conn, translate_db_errors(): + course_row = await conn.fetchrow( + f""" + INSERT INTO course (organization_id, name, source, external_course_ref) + VALUES ($1, $2, 'official', $3) + RETURNING {_COURSE_COLUMNS} + """, + organization_id, + course_data.get("name") or facility.get("name"), + external_ref, + ) + course_id = course_row["id"] + + for h in holes: + await conn.execute( + """ + INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index) + VALUES ($1, $2, $3, $4, $5) + """, + organization_id, + course_id, + h["hole_number"], + h["par"], + h["hcp_index"], + ) + + for name, gender, course_rating, slope_rating in tee_inputs: + tee_row = await conn.fetchrow( + """ + INSERT INTO tee (organization_id, course_id, name, gender) + VALUES ($1, $2, $3, $4) + RETURNING id + """, + organization_id, + course_id, + name, + gender, + ) + await conn.execute( + """ + INSERT INTO tee_rating + (organization_id, tee_id, scope, course_rating, slope_rating, par) + VALUES ($1, $2, 'full_18', $3, $4, $5) + """, + organization_id, + tee_row["id"], + course_rating, + slope_rating, + par_total, + ) + + return Course(**dict(course_row)) diff --git a/app/teeoff_client.py b/app/teeoff_client.py new file mode 100644 index 0000000..8ad163c --- /dev/null +++ b/app/teeoff_client.py @@ -0,0 +1,58 @@ +""" +Utgående, lesende HTTP-klient mot teeoff sitt offentlige facilities-API +(ADR-004/ADR-019). Ren HTTP-modul -- ingen db/RLS-avhengighet, samme +isolasjonsprinsipp som handicap_engine.py. + +Server-til-server: teecup_api snakker med teeoff_api direkte over det delte +Docker-nettverket (teeoff_default), ikke via Caddy/det offentlige domenet. +Endepunktene (`/api/facilities`, `/api/facilities/{slug}`) er offentlige i +teeoff -- ingen auth-mekanisme finnes der, så ingen hemmelighet trengs her. +""" + +import httpx + +from .config import settings + +_TIMEOUT = httpx.Timeout(10.0, connect=5.0) + + +class TeeoffUnavailableError(Exception): + """Teeoff svarte ikke (nede, timeout, uventet statuskode).""" + + +class TeeoffNotFoundError(Exception): + """Anlegget finnes ikke i teeoff (404).""" + + +async def search_facilities(query: str) -> list[dict]: + """`?view=search` gir en lettvekts liste (id/slug/name/city/county/...) + egnet for søk -- filtrerer på navn her, teeoff har ingen egen + søkeparameter på dette endepunktet.""" + url = f"{settings.TEEOFF_API_BASE_URL}/api/facilities" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + res = await client.get(url, params={"view": "search"}) + except httpx.HTTPError as e: + raise TeeoffUnavailableError(str(e)) from e + if res.status_code != 200: + raise TeeoffUnavailableError(f"uventet status {res.status_code}") + + facilities: list[dict] = res.json() + q = query.strip().lower() + if not q: + return facilities + return [f for f in facilities if q in (f.get("name") or "").lower()] + + +async def get_facility(slug: str) -> dict: + url = f"{settings.TEEOFF_API_BASE_URL}/api/facilities/{slug}" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + res = await client.get(url) + except httpx.HTTPError as e: + raise TeeoffUnavailableError(str(e)) from e + if res.status_code == 404: + raise TeeoffNotFoundError(slug) + if res.status_code != 200: + raise TeeoffUnavailableError(f"uventet status {res.status_code}") + return res.json() diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx index cbe5358..016a05e 100644 --- a/frontend/components/tournament-program.tsx +++ b/frontend/components/tournament-program.tsx @@ -277,7 +277,13 @@ export function TournamentProgram({ )}
{error}
} + + {!selectedFacility ? ( + <> + + {facilities && ( +Laster baner…
+ ) : ( +