Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.

To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?

Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
This commit is contained in:
Erol Haagenrud 2026-07-18 12:44:05 +02:00
parent 5e4e4af26c
commit b28f604fe9
10 changed files with 595 additions and 10 deletions

View file

@ -240,7 +240,14 @@
"Bash(cd /opt/teeoff/backend && ls -la; echo ---; find . -maxdepth 2 -type f | grep -v node_modules)", "Bash(cd /opt/teeoff/backend && ls -la; echo ---; find . -maxdepth 2 -type f | grep -v node_modules)",
"Read(//opt/teeoff/backend/**)", "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/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": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -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;

View file

@ -5,7 +5,7 @@
> Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares. > Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares.
**Status:** Levende dokument **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å) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

View file

@ -722,10 +722,18 @@ Ferdig og verifisert:
over er basert på å lese koden direkte. Stabil identifikator: `facilities. over er basert på å lese koden direkte. Stabil identifikator: `facilities.
slug` (f.eks. `borregaard-golfklubb`) — selve banen har kun en intern slug` (f.eks. `borregaard-golfklubb`) — selve banen har kun en intern
serial-id, ingen egen slug, så `course.external_course_ref` må bære serial-id, ingen egen slug, så `course.external_course_ref` må bære
facility-slug + bane-id sammen. **Ikke scopet eller bygget ennå** — egen, facility-slug + bane-id sammen.
senere avklaring med bruker om omfang (import-flyt vs. live-oppslag hver **Oppdatering samme dag: brukeren ba meg ta fatt på dette NÅ** (bevisst
gang, hvor mye av bane-/tee-/hull-detaljene som skal kopieres inn i sidesprang fra "bygg i rekkefølgen ting brukes"-planen — blind draw-
`teecup_db` vs. slås opp on-demand). 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: Neste steg:
1. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster: 1. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster:

View file

@ -25,7 +25,7 @@
| Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. | | 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: 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. | | 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. | | 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. | | 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. | | RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. |

View file

@ -83,5 +83,10 @@ class Settings:
MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media") MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media")
MINIO_INTERNAL_ENDPOINT: str = os.getenv("TEECUP_MINIO_INTERNAL_ENDPOINT", "teecup-minio:9000") 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() settings = Settings()

View file

@ -7,3 +7,4 @@ minio>=7.2
pillow>=10.0 pillow>=10.0
pillow-avif-plugin>=1.4 pillow-avif-plugin>=1.4
python-multipart>=0.0.9 python-multipart>=0.0.9
httpx>=0.27

View file

@ -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). 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 ..auth import get_authorized_org
from ..db import org_connection from ..db import org_connection
from ..errors import translate_db_errors from ..errors import app_error, translate_db_errors
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
router = APIRouter() router = APIRouter()
@ -59,3 +60,182 @@ async def create_course(
body.name, body.name,
) )
return Course(**dict(row)) 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))

58
app/teeoff_client.py Normal file
View file

@ -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, 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 navn her, teeoff har ingen egen
søkeparameter 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()

View file

@ -277,7 +277,13 @@ export function TournamentProgram({
)} )}
<div className="mt-6"> <div className="mt-6">
<CreateSessionCard nextSequence={nextSequence} courses={courses} onCreate={addSession} /> <CreateSessionCard
nextSequence={nextSequence}
courses={courses}
organizationId={organizationId}
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
onCreate={addSession}
/>
</div> </div>
</> </>
)} )}
@ -381,10 +387,14 @@ function EmptyState() {
function CreateSessionCard({ function CreateSessionCard({
nextSequence, nextSequence,
courses, courses,
organizationId,
onCourseImported,
onCreate, onCreate,
}: { }: {
nextSequence: number nextSequence: number
courses: ApiCourse[] courses: ApiCourse[]
organizationId: string
onCourseImported: (course: ApiCourse) => void
onCreate: (input: { onCreate: (input: {
name?: string name?: string
format: SessionFormat format: SessionFormat
@ -411,6 +421,7 @@ function CreateSessionCard({
const [courseQuery, setCourseQuery] = useState("") const [courseQuery, setCourseQuery] = useState("")
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null) const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false)
const [useHandicap, setUseHandicap] = useState(true) const [useHandicap, setUseHandicap] = useState(true)
@ -491,6 +502,7 @@ function CreateSessionCard({
setStartHole("1") setStartHole("1")
setCourseQuery("") setCourseQuery("")
setSelectedCourseId(null) setSelectedCourseId(null)
setOfficialSearchOpen(false)
setAdvancedOpen(false) setAdvancedOpen(false)
setUseHandicap(true) setUseHandicap(true)
setUseCourseHandicap(true) setUseCourseHandicap(true)
@ -603,6 +615,24 @@ function CreateSessionCard({
</ul> </ul>
</div> </div>
)} )}
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
{officialSearchOpen && (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(course) => {
onCourseImported(course)
setSelectedCourseId(course.id)
setOfficialSearchOpen(false)
}}
/>
)}
</div> </div>
)} )}
</div> </div>
@ -749,6 +779,206 @@ function CreateSessionCard({
) )
} }
// --- Offisiell bane fra teeoff (ADR-019) ------------------------------------
type ApiOfficialFacility = {
slug: string
name: string
city: string | null
county: string | null
}
type ApiOfficialCourseOption = {
teeoff_course_id: number
name: string
is_main_course: boolean
}
function OfficialCourseSearch({
organizationId,
onClose,
onImported,
}: {
organizationId: string
onClose: () => void
onImported: (course: ApiCourse) => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
const [searching, setSearching] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function runSearch(e?: React.FormEvent) {
e?.preventDefault()
setSearching(true)
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`,
{ credentials: "include" },
)
if (!res.ok) throw new Error(`search: ${res.status}`)
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
setFacilities([])
} finally {
setSearching(false)
}
}
async function pickFacility(facility: ApiOfficialFacility) {
setSelectedFacility(facility)
setError(null)
setCourseOptions(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setCourseOptions(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
setCourseOptions([])
}
}
async function importCourse(course: ApiOfficialCourseOption) {
if (!selectedFacility) return
setImporting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
facility_slug: selectedFacility.slug,
teeoff_course_id: course.teeoff_course_id,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const code = body?.detail?.code
if (code === "EXTERNAL_DATA_INCOMPLETE") {
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
} else if (code === "DUPLICATE") {
setError("Denne banen er allerede importert til organisasjonen.")
} else {
setError("Klarte ikke å importere banen. Prøv igjen.")
}
return
}
onImported(await res.json())
} catch {
setError("Klarte ikke å importere banen. Prøv igjen.")
} finally {
setImporting(false)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="size-8 rounded-lg text-muted-foreground"
aria-label="Lukk"
>
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{!selectedFacility ? (
<>
<form onSubmit={runSearch} className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk anleggsnavn…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-11 flex-1 rounded-xl text-base"
/>
<Button type="submit" disabled={searching} className="h-11 shrink-0 rounded-xl font-bold">
{searching ? "Søker…" : "Søk"}
</Button>
</form>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickFacility(f)}
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && (
<span className="text-xs text-muted-foreground">
{[f.city, f.county].filter(Boolean).join(", ")}
</span>
)}
</button>
</li>
))}
{facilities.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
)}
</>
) : (
<>
<button
type="button"
onClick={() => {
setSelectedFacility(null)
setCourseOptions(null)
}}
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
{selectedFacility.name}
</button>
{courseOptions === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courseOptions.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={importing}
onClick={() => importCourse(c)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
>
{c.name}
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer</span>}
</button>
</li>
))}
{courseOptions.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
Ingen 18-hulls baner å importere hos dette anlegget ennå.
</li>
)}
</ul>
)}
</>
)}
</div>
)
}
// --- Small UI helpers ------------------------------------------------------ // --- Small UI helpers ------------------------------------------------------
function SegmentButton({ function SegmentButton({