diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 89308cf..389fa25 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -250,7 +250,14 @@ "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)", "Bash(curl -s \"https://teecup.teeoff.no/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a?org=test-org-id&name=Test\")", "Bash(python3 -m py_compile app/team_authz.py app/routers/matches.py app/routers/scoring.py)", - "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/owner_cookies.txt /tmp/member_cookies.txt /tmp/captain_cookies.txt)" + "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/owner_cookies.txt /tmp/member_cookies.txt /tmp/captain_cookies.txt)", + "Bash(python3 -m py_compile /opt/teecup/app/routers/courses.py)", + "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/tee_cookies.txt)", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-6)", + "Bash(python3 -m py_compile /opt/teecup/app/routers/matches.py)", + "Bash(python3 -m py_compile /opt/teecup/app/blind_draw.py /opt/teecup/app/routers/matches.py)", + "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/del_cookies.txt /tmp/captvis_cookies.txt)", + "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/hcp_cookies.txt)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index 4d4e829..95df62d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -799,10 +799,92 @@ Ferdig og verifisert: uavhengig av org-rolle. `test_isolation.sql` 12/12. Ingen migrasjon (ren Python-endring) — kun `teecup_api` redeployet, `teeoff.no` upåvirket. +- **Tee-endepunkter bygget og LIVE (2026-07-18), rett før blind draw-skjermen:** + fant et hull som blokkerte selve blind draw-flyten: `match_participant. + tee_id` er påkrevd, men det fantes INGEN `GET`-vei for å liste en banes + tee-er (selv offisielt importerte baner har tee-rader, men ingenting + eksponerte dem) OG egendefinerte («custom») baner har ALDRI hatt noen + vei til å FÅ tee-er i det hele tatt — dette er ikke noe ADR-019 innførte, + det var et hull som fantes fra før custom-baner ble lagt til i første + omgang, bare usynlig til nå. Konsekvens før fiksen: en turnering satt opp + på en manuelt navngitt bane kunne ALDRI få en ekte deltaker lagt til på + noen match. **Presisering (brukeren spurte eksplisitt):** dette er IKKE + noe som må løses i teeoff.no sin kode — egendefinerte baner er per + definisjon baner teeoff ikke kjenner til, så dette er en ren + teecup-intern funksjon, uavhengig av ADR-019-integrasjonen. + Ny `GET/POST /orgs/{id}/courses/{id}/tees` i `app/routers/courses.py`. + `POST` avviser eksplisitt forsøk på offisielle baner (400 + `VALIDATION_FAILED` — de får tee-ene sine fra teeoff-importen, ikke + manuelt). Kun full_18-rating dekket (samme begrunnelse som ADR-019 + Beslutning D). **Bevisst UTENFOR omfang denne runden, egen senere sak:** + hull-/stroke-index-data for egendefinerte baner (`hole`-tabellen forblir + tom for custom-baner) — trengs for korrekt slagfordeling i SCORING-fasen + (ADR-008), ikke i blind draw, så det løses naturlig når scorekort- + skjermen bygges (samme "bygg i rekkefølgen ting brukes"-logikk). + **Verifisert grundig i scratch:** tom tee-liste på fersk custom-bane, + opprett+list tee på custom-bane, offisiell bane viser alle 8 importerte + tee-er, manuell tee-opprettelse avvist på offisiell bane, og — den + faktiske payoff-en — en deltaker lagt til en match på en custom-bane-økt + for FØRSTE gang noensinne (`POST .../matches/{id}/participants` lykkes + nå med en tee_id fra en nyopprettet custom-tee). `test_isolation.sql` + 12/12. Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no` + upåvirket. + +- **Blind draw-skjermen LIVE (2026-07-18):** syvende V0-skjerm, + `components/session-blind-draw.tsx`, ny rute + `/tournaments/[id]/sessions/[sessionId]`. To lag-kolonner, hver med sine + matcher ("flights"), legg til/fjern spiller+tee per plass (antall plasser + avhenger av format), lås-knapp med bekreftelse, avslørings-visning når + begge lag har låst. Program-skjermens økt-kort er nå klikkbare inn hit. + **Datamodell tilpasset fra V0s mock til API-ets faktiske sett-modell:** + V0 designet faste, indekserte "Slot"-arrays (kontrollert skjema-state); + API-et har verken PATCH på `match_participant` eller noen slot-indeks — + kun opprett/slett av en løs deltaker-mengde per side. Løst med en + "legg til spiller"-inline-form (samme mønster som spiller-/bane-type-ahead + ellers i appen) i stedet for faste dropdown-rader; funksjonelt likeverdig, + strukturelt riktigere for API-ets faktiske form. + **To reelle hull funnet og fikset FØR/UNDER integrering:** + 1. Ingen `DELETE` fantes for `match_participant` — en kaptein kunne aldri + angre et valg før låsing uten å etterlate en foreldreløs rad. Ny + `DELETE /orgs/{id}/matches/{match_id}/participants/{participant_id}` + (samme ALREADY_LOCKED/NOT_ROSTERED_ON_TEAM-sjekker som opprett). + 2. **"Skriv blindt"-hull, funnet under selve scratch-testingen (ikke bare + tenkt ut på forhånd):** forrige rundes org-admin-overstyring i + `team_authz.py` lot en organisator LEGGE TIL deltakere på et lag de + ikke selv er rostret på, men `app/blind_draw.py` sin `own_team_ids()` + sjekket KUN rostret spiller for SYNLIGHET — så organisatoren så aldri + sine egne tilføyelser igjen før begge lag hadde låst. Fikset ved å gi + `own_team_ids()` samme owner/admin-utvidelse som `user_may_act_for_ + team()` — en org-admin ser nå BEGGE lag umiddelbart (konsistent med at + de uansett allerede har full tilgang, se forrige rundes resonnement), + mens en faktisk rostret kaptein fortsatt kun ser sitt eget lag før + reveal. Kun ett reelt kallsted (`matches.py` sin `list_matches`). + **Tredje, urelatert bug fanget under samme scratch-økt og fikset på + brukerens eksplisitte forespørsel:** `POST .../matches/{id}/participants` + krasjet med en rå 500 (`TypeError` i `handicap_engine.py` sin + `course_handicap_raw`) hvis spilleren manglet `handicap_index` OG + `use_handicap` var på (standard) — preeksisterende, ikke noe denne + runden introduserte. Fikset i `app/routers/matches.py` sin + `add_participant`: sjekker nå `handicap_index_snapshot IS NULL` EKSPLISITT + FØR innsetting når `use_handicap` er sann, avviser med en klar + `VALIDATION_FAILED` (400) i stedet for å krasje. Bekreftet at + `use_handicap=false` fortsatt tillater en spiller uten handicap + (scratch-spill), og at en spiller MED handicap fortsatt fungerer uendret. + **Verifisert grundig i scratch, flere runder:** DELETE-endepunktet + (fjern+idempotent 404 ved gjentak+409 etter lås), synlighetsfikset (org- + admin ser begge sider, rostret kaptein ser fortsatt kun eget lag), + null-handicap-fikset (avvist rent, scratch-modus upåvirket, normal + spiller upåvirket), full opprett-match→legg-til-deltaker→lås→avslør- + syklus ende-til-ende. `test_isolation.sql` 12/12 etter hver runde. Ekte + typesjekket produksjonsbuild. + **Rullet ut live**, bruker bekreftet eksplisitt: begge containere + bygget+redeployet (ingen migrasjon), `teeoff.no` upåvirket. + Neste steg: -1. Blind draw-skjermen (neste i "bygg i rekkefølgen ting brukes", gjenopptatt - etter ADR-019-sidespranget) — deretter scorekort, leaderboard. Samme - mønster: +1. Scorekort-skjermen (neste i "bygg i rekkefølgen ting brukes") — her må + også hull-/stroke-index-data for egendefinerte baner løses (bevisst + utsatt fra tee-rundens tidligere status-notat). Deretter leaderboard. + Samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver gang — diff mot live-treet i et scratch-område før noe pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 2cf551b..e92971f 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -368,10 +368,33 @@ ### Blind draw (skjult lagoppstilling) - **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert (`app/routers/matches.py`: synlighetsfilter i SQL, ikke Python-filter — se ADR-013). + **Frontend LIVE 2026-07-18:** `/tournaments/[id]/sessions/[sessionId]`, + `components/session-blind-draw.tsx`. Ny `DELETE .../matches/{id}/ + participants/{id}` (kunne ikke angre et valg før låsing uten den). Fant + og fikset et "skriv blindt"-hull: org-admin kunne legge til deltakere på + et lag de ikke er rostret på (forrige rundes utvidelse), men + `own_team_ids()` i `app/blind_draw.py` viste dem aldri tilbake før + reveal — utvidet til samme owner/admin-regel som skrive-siden. Se + CLAUDE.md-status for full runde, inkl. en tredje, urelatert 500-bug + (manglende handicap-indeks) funnet og fikset samtidig. - Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er ferdige. -- **Gjenstår:** hvem som FÅR låse et lag er i dag bare «rostret på laget», ikke - kaptein-spesifikt — se «Brukerroller» over. +- **Gjenstår:** hvem som FÅR låse et lag er i dag «rostret på laget» ELLER + org-eier/admin (utvidet 2026-07-18, se «Brukerroller» over), ikke + kaptein-spesifikt. +- **Nytt 2026-07-18, ✅ BYGGET (fant og fikset rett før frontend-skjermen):** + `match_participant.tee_id` er påkrevd, men det fantes INGEN vei til å + liste EN banes tee-er (selv offisielt importerte), og egendefinerte + baner har ALDRI hatt noen vei til å FÅ tee-er — et hull som fantes fra + før ADR-019, ikke noe den innførte. Uten dette kunne en turnering på en + manuelt navngitt bane aldri få en ekte deltaker på noen match. Ny + `GET/POST /orgs/{id}/courses/{id}/tees` (kun full_18-rating, offisielle + baner avviser manuell tee-opprettelse). **Presisert eksplisitt: dette er + IKKE en teeoff.no-kodeendring** — egendefinerte baner er per definisjon + utenfor teeoffs katalog, så dette er en ren teecup-intern funksjon. + **Bevisst utenfor omfang:** hull-/stroke-index-data for egendefinerte + baner (trengs i SCORING-fasen, ikke blind draw) — egen, senere sak når + scorekort-skjermen bygges. ### Forenklet scoreføring (uten slagtall) - **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert diff --git a/app/blind_draw.py b/app/blind_draw.py index 8f4db06..1d9ba88 100644 --- a/app/blind_draw.py +++ b/app/blind_draw.py @@ -20,12 +20,16 @@ async def locked_team_ids(conn: Connection, session_id: str) -> set[str]: return {r["team_id"] for r in rows} -async def own_team_ids(conn: Connection, tournament_id: str, user_id: str) -> set[str]: - """Hvilke(t) lag brukeren selv er rostret på i denne turneringen. +async def own_team_ids(conn: Connection, organization_id: str, tournament_id: str, user_id: str) -> set[str]: + """Hvilke lag brukeren FÅR SE deltakerne til, før begge lag har låst. - Går via player.user_id (nullable) -> team_roster -> team. En org-admin uten - egen player-rad har ingen "eget lag" og får dermed tomt sett her — bevisst, - ikke en feil (se matches.py). + To uavhengige veier inn, samme par som team_authz.user_may_act_for_team + (2026-07-18 -- se den for full begrunnelse): rostret spiller (via + player.user_id -> team_roster -> team) ELLER org-eier/admin, som da ser + BEGGE lag. Uten org-admin-veien kunne en organisator LEGGE TIL deltakere + (team_authz gir dem det) men aldri SE dem igjen før reveal -- en reell + "skriv blindt" felle funnet under blind draw-skjermens integrering, ikke + bare et tenkt tilfelle. """ rows = await conn.fetch( """ @@ -38,4 +42,20 @@ async def own_team_ids(conn: Connection, tournament_id: str, user_id: str) -> se tournament_id, user_id, ) - return {r["team_id"] for r in rows} + own = {r["team_id"] for r in rows} + + is_org_admin = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM organization_membership + WHERE organization_id = $1 AND user_id = $2 AND role IN ('owner', 'admin') + ) + """, + organization_id, + user_id, + ) + if is_org_admin: + all_teams = await conn.fetch("SELECT id::text FROM team WHERE tournament_id = $1", tournament_id) + own |= {r["id"] for r in all_teams} + + return own diff --git a/app/routers/courses.py b/app/routers/courses.py index f011cb1..4ac8bd0 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -62,6 +62,111 @@ async def create_course( return Course(**dict(row)) +# --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) -- +# +# Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte +# baner har tee-rader, men ingenting eksponerte dem) eller en opprett-vei for +# egendefinerte baner (de har ALDRI hatt noen vei til tee-data, uavhengig av +# ADR-019). Reist av brukeren rett før blind draw-skjermen: uten dette kan en +# match aldri få en deltaker (tee_id er påkrevd, ingen gyldig verdi finnes). +# Kun full_18-rating dekket her, samme begrunnelse som ADR-019 Beslutning D. +# Hull-/stroke-index-data for egendefinerte baner (trengs for korrekt +# slagfordeling i SCORING-fasen, ikke i blind draw) er bevisst UTENFOR +# omfang denne runden -- egen, senere sak når scorekort-skjermen bygges. + + +class TeeCreate(BaseModel): + name: str = Field(min_length=1, max_length=50) + gender: str | None = Field(default=None, pattern="^[mfx]$") + course_rating: float = Field(gt=0) + slope_rating: int = Field(ge=55, le=155) + par: int = Field(ge=27, le=90) + + +class Tee(BaseModel): + id: str + name: str + gender: str | None + course_rating: float + slope_rating: int + par: int + + +_TEE_COLUMNS = """ + t.id::text, t.name, t.gender, tr.course_rating::float AS course_rating, + tr.slope_rating, tr.par +""" + + +@router.get("/orgs/{organization_id}/courses/{course_id}/tees", response_model=list[Tee]) +async def list_tees( + course_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[Tee]: + async with org_connection(organization_id) as conn: + rows = await conn.fetch( + f""" + SELECT {_TEE_COLUMNS} + FROM tee t + JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18' + WHERE t.course_id = $1 + ORDER BY t.name + """, + course_id, + ) + return [Tee(**dict(r)) for r in rows] + + +@router.post("/orgs/{organization_id}/courses/{course_id}/tees", response_model=Tee, status_code=201) +async def create_tee( + course_id: str, + body: TeeCreate, + organization_id: str = Depends(get_authorized_org), +) -> Tee: + async with org_connection(organization_id) as conn, translate_db_errors(): + source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id) + if source is None: + raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") + if source == "official": + raise app_error( + 400, + "VALIDATION_FAILED", + "Offisielle baner får tee-ene sine fra teeoff -- kan ikke legges til manuelt.", + ) + + tee_row = await conn.fetchrow( + """ + INSERT INTO tee (organization_id, course_id, name, gender) + VALUES ($1, $2, $3, $4) + RETURNING id::text + """, + organization_id, + course_id, + body.name, + body.gender, + ) + rating_row = await conn.fetchrow( + """ + INSERT INTO tee_rating (organization_id, tee_id, scope, course_rating, slope_rating, par) + VALUES ($1, $2, 'full_18', $3, $4, $5) + RETURNING course_rating::float AS course_rating, slope_rating, par + """, + organization_id, + tee_row["id"], + body.course_rating, + body.slope_rating, + body.par, + ) + return Tee( + id=tee_row["id"], + name=body.name, + gender=body.gender, + course_rating=rating_row["course_rating"], + slope_rating=rating_row["slope_rating"], + par=rating_row["par"], + ) + + # --- Offisiell banedata fra teeoff (ADR-019) --------------------------------- diff --git a/app/routers/matches.py b/app/routers/matches.py index e7e6aeb..e2a1e57 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -162,7 +162,11 @@ async def list_matches( locked = await locked_team_ids(conn, session_id) revealed = len(locked) >= 2 # Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant). - own = set() if revealed else await own_team_ids(conn, session["tournament_id"], user.user_id) + own = ( + set() + if revealed + else await own_team_ids(conn, organization_id, session["tournament_id"], user.user_id) + ) participant_rows = await conn.fetch( """ @@ -276,6 +280,28 @@ async def add_participant( 409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten." ) + # Beregnet HELT FØR innsetting -- se app/handicap.py sin + # compute_and_store_side_handicaps, som ellers krasjer rått + # (TypeError i handicap_engine.py sin course_handicap_raw) på en + # spiller uten handicap_index når use_handicap er på (standard). + # Fant under scratch-testing av blind draw-skjermen -- reell, + # preeksisterende feil, ikke noe denne runden introduserte. + allowance_override = ( + json.loads(session["allowance_override"]) if session["allowance_override"] else None + ) + config = parse_allowance_config(session["format"], allowance_override) + if config.use_handicap: + handicap_index_snapshot = await conn.fetchval( + "SELECT handicap_index_snapshot FROM team_roster WHERE id = $1", body.team_roster_id + ) + if handicap_index_snapshot is None: + raise app_error( + 400, + "VALIDATION_FAILED", + "Denne spilleren mangler handicap-indeks -- sett en verdi på spilleren " + "før hen kan legges til i en handicap-basert økt.", + ) + row = await conn.fetchrow( """ WITH inserted AS ( @@ -302,17 +328,58 @@ async def add_participant( # Beregn/lagre course_handicap/playing_handicap nå som deltakeren er # satt inn. Funksjonen avgjør selv om siden er "klar" (singles/ # fourball: alltid; foursome/greensome/scramble: kun når siden er - # komplett) -- se app/handicap.py. - allowance_override = ( - json.loads(session["allowance_override"]) if session["allowance_override"] else None - ) - config = parse_allowance_config(session["format"], allowance_override) + # komplett) -- se app/handicap.py. `config` allerede parset over. await compute_and_store_side_handicaps( conn, match_id, body.team_side, session["format"], session["hole_config"], config ) return MatchParticipantOut(**dict(row)) +@router.delete( + "/orgs/{organization_id}/matches/{match_id}/participants/{participant_id}", + status_code=204, +) +async def remove_participant( + match_id: str, + participant_id: str, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> None: + """Fjern et deltaker-valg FØR laget har låst -- lar en kaptein endre seg + (velge en annen spiller/tee for samme plass) uten å etterlate en + foreldreløs rad, siden det ikke finnes noen PATCH på match_participant. + Reist av brukeren rett før blind draw-skjermen (som lar spiller/tee + endres fritt i UI-et helt frem til låsing).""" + async with org_connection(organization_id) as conn, translate_db_errors(): + row = await conn.fetchrow( + """ + SELECT mp.team_side::text AS team_side, + m.session_id::text AS session_id, + m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id + FROM match_participant mp + JOIN match m ON m.id = mp.match_id + WHERE mp.id = $1 AND mp.match_id = $2 + """, + participant_id, + match_id, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.") + + team_id = row["team_a_id"] if row["team_side"] == "a" else row["team_b_id"] + + if not await user_may_act_for_team(conn, organization_id, team_id, user.user_id): + raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") + + locked = await locked_team_ids(conn, row["session_id"]) + if team_id in locked: + raise app_error( + 409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten." + ) + + await conn.execute("DELETE FROM match_participant WHERE id = $1", participant_id) + + class LockCreate(BaseModel): team_id: str diff --git a/frontend/app/tournaments/[id]/sessions/[sessionId]/page.tsx b/frontend/app/tournaments/[id]/sessions/[sessionId]/page.tsx new file mode 100644 index 0000000..bc9ff84 --- /dev/null +++ b/frontend/app/tournaments/[id]/sessions/[sessionId]/page.tsx @@ -0,0 +1,34 @@ +import { SessionBlindDraw } from "@/components/session-blind-draw" + +export default async function SessionBlindDrawPage({ + params, + searchParams, +}: { + params: Promise<{ id: string; sessionId: string }> + searchParams: Promise<{ org?: string; name?: string }> +}) { + const { id, sessionId } = await params + const { org, name } = await searchParams + const tournamentName = name?.trim() || "Turnering" + + // Samme grunn som de andre turnering-undersidene: API-et krever + // organization_id på alle økt-/match-endepunkter (RLS, se ADR-001/003). + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/components/session-blind-draw.tsx b/frontend/components/session-blind-draw.tsx new file mode 100644 index 0000000..1df53cd --- /dev/null +++ b/frontend/components/session-blind-draw.tsx @@ -0,0 +1,777 @@ +"use client" + +import type React from "react" +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Clock, EyeOff, Lock, PartyPopper, Plus, Users, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/courses.py) + +type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles" +type HoleConfig = "full_18" | "front_9" | "back_9" +type ScoringMode = "stroke" | "hole_result" + +type ApiTeam = { id: string; name: string; color: string | null } + +type ApiRosterEntry = { + id: string + player_id: string + display_name: string + handicap_index_snapshot: number | null + is_captain: boolean +} + +type ApiTee = { + id: string + name: string + gender: "m" | "f" | "x" | null + course_rating: number + slope_rating: number + par: number +} + +type ApiSession = { + id: string + name: string | null + format: Format + hole_config: HoleConfig + course_id: string + points_per_match: number + scoring_mode: ScoringMode + locked_team_ids: string[] +} + +type ApiParticipant = { + id: string + team_side: "a" | "b" + team_roster_id: string + player_name: string + tee_id: string + tee_name: string +} + +type ApiMatch = { + id: string + sequence: number + team_a_id: string + team_b_id: string + tee_time: string | null + participants: ApiParticipant[] +} + +// --- Labels ------------------------------------------------------------------ + +const FORMAT_LABELS: Record = { + foursome: "Foursome", + greensome: "Greensome", + scramble_2: "Scramble (2)", + scramble_4: "Scramble (4)", + fourball: "Fourball", + singles: "Singel", +} +const HOLE_LABELS: Record = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9" } +const SCORING_LABELS: Record = { stroke: "Slag for slag", hole_result: "Kun hullresultat" } + +function slotsPerSide(format: Format): number { + if (format === "singles") return 1 + if (format === "scramble_4") return 4 + return 2 +} + +// --- Component --------------------------------------------------------------- + +export function SessionBlindDraw({ + organizationId, + tournamentId, + sessionId, + tournamentName, +}: { + organizationId: string + tournamentId: string + sessionId: string + tournamentName: string +}) { + const [session, setSession] = useState(null) + const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null) + const [rosters, setRosters] = useState>({}) + const [tees, setTees] = useState([]) + const [matches, setMatches] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [confirmingLock, setConfirmingLock] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + try { + const [sessionsRes, teamsRes] = await Promise.all([ + fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }), + fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }), + ]) + if (!sessionsRes.ok || !teamsRes.ok) throw new Error("load failed") + const sessionsData: ApiSession[] = await sessionsRes.json() + const foundSession = sessionsData.find((s) => s.id === sessionId) + if (!foundSession) throw new Error("session not found") + const teamsData: ApiTeam[] = await teamsRes.json() + if (teamsData.length !== 2) throw new Error("expected 2 teams") + + const [rosterARes, rosterBRes, teesRes, matchesRes] = await Promise.all([ + fetch(`/orgs/${organizationId}/teams/${teamsData[0].id}/roster`, { credentials: "include" }), + fetch(`/orgs/${organizationId}/teams/${teamsData[1].id}/roster`, { credentials: "include" }), + fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/tees`, { credentials: "include" }), + fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }), + ]) + if (!rosterARes.ok || !rosterBRes.ok || !teesRes.ok || !matchesRes.ok) throw new Error("load failed") + + if (cancelled) return + setSession(foundSession) + setTeams([teamsData[0], teamsData[1]]) + setRosters({ + [teamsData[0].id]: await rosterARes.json(), + [teamsData[1].id]: await rosterBRes.json(), + }) + setTees(await teesRes.json()) + setMatches(await matchesRes.json()) + } catch { + if (!cancelled) setError("Klarte ikke å laste blind draw-siden. Prøv å laste siden på nytt.") + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { + cancelled = true + } + }, [organizationId, tournamentId, sessionId]) + + const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches]) + const nextSequence = useMemo( + () => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1), + [matches], + ) + + const bothLocked = (session?.locked_team_ids.length ?? 0) >= 2 + + async function refetchMatches() { + const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }) + if (res.ok) setMatches(await res.json()) + } + + async function addMatch() { + if (!teams) return + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ sequence: nextSequence, team_a_id: teams[0].id, team_b_id: teams[1].id }), + }) + if (!res.ok) throw new Error(`create match: ${res.status}`) + const created: ApiMatch = await res.json() + setMatches((prev) => [...prev, created]) + } catch { + setError("Klarte ikke å legge til match. Prøv igjen.") + } + } + + async function addParticipant(matchId: string, teamSide: "a" | "b", teamRosterId: string, teeId: string) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ team_side: teamSide, team_roster_id: teamRosterId, tee_id: teeId }), + }) + if (!res.ok) throw new Error(`add participant: ${res.status}`) + const created: ApiParticipant = await res.json() + setMatches((prev) => + prev.map((m) => (m.id === matchId ? { ...m, participants: [...m.participants, created] } : m)), + ) + } catch { + setError("Klarte ikke å legge til spilleren. Kanskje laget allerede har låst, eller du mangler tilgang.") + } + } + + async function removeParticipant(matchId: string, participantId: string) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants/${participantId}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok && res.status !== 204) throw new Error(`remove participant: ${res.status}`) + setMatches((prev) => + prev.map((m) => + m.id === matchId + ? { ...m, participants: m.participants.filter((p) => p.id !== participantId) } + : m, + ), + ) + } catch { + setError("Klarte ikke å fjerne spilleren. Prøv igjen.") + } + } + + async function lockTeam(teamId: string) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/lock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ team_id: teamId }), + }) + if (!res.ok) throw new Error(`lock: ${res.status}`) + setSession((prev) => + prev ? { ...prev, locked_team_ids: [...prev.locked_team_ids, teamId] } : prev, + ) + // Motstanderens deltakere er skjult server-side inntil begge har låst + // (ADR-013) -- hent matchene på nytt for å få den avslørte visningen + // hvis dette var det andre laget som nettopp låste. + await refetchMatches() + } catch { + setError("Klarte ikke å låse oppstillingen. Prøv igjen.") + } finally { + setConfirmingLock(null) + } + } + + const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` + const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` + + if (loading || !session || !teams) { + return ( +
+
+
+ +
+
+
+ {error ? ( +

+ {error} +

+ ) : ( +
+
+ ) + } + + const perSide = slotsPerSide(session.format) + + function usedPlayerIds(teamId: string, exceptMatchId: string): Set { + const side: "a" | "b" = teamId === teams![0].id ? "a" : "b" + const used = new Set() + for (const m of matches) { + if (m.id === exceptMatchId) continue + for (const p of m.participants) { + if (p.team_side === side) used.add(p.team_roster_id) + } + } + return used + } + + function teamIsComplete(teamId: string): boolean { + const side: "a" | "b" = teamId === teams![0].id ? "a" : "b" + if (matches.length === 0) return false + return matches.every((m) => m.participants.filter((p) => p.team_side === side).length >= perSide) + } + + return ( +
+
+
+ +
+
+ +
+
+ {FORMAT_LABELS[session.format]} + {HOLE_LABELS[session.hole_config]} + {SCORING_LABELS[session.scoring_mode]} + {formatPoints(session.points_per_match)} poeng +
+ + {error && ( +

+ {error} +

+ )} + + + + {bothLocked ? ( + + ) : ( +
+ {teams.map((team, teamIndex) => ( + setConfirmingLock(team.id)} + onCancelLock={() => setConfirmingLock(null)} + onConfirmLock={() => lockTeam(team.id)} + /> + ))} +
+ )} +
+
+ ) +} + +// --- Status banner ------------------------------------------------------------- + +function StatusBanner({ + teams, + lockedTeamIds, + bothLocked, +}: { + teams: [ApiTeam, ApiTeam] + lockedTeamIds: string[] + bothLocked: boolean +}) { + if (bothLocked) { + return ( +
+
+
+
+ Oppstilling avslørt! + + Begge lag har låst. Her er paringene for økten. + +
+
+ ) + } + + const lockedCount = lockedTeamIds.length + if (lockedCount === 1) { + const lockedTeam = lockedTeamIds.includes(teams[0].id) ? teams[0] : teams[1] + const otherTeam = lockedTeam.id === teams[0].id ? teams[1] : teams[0] + return ( +
+
+
+
+ + {lockedTeam.name} har låst + + + Venter på at {otherTeam.name} låser sin oppstilling. + +
+
+ ) + } + + return ( +
+
+
+
+ Begge lag setter opp i skjul + + Ingen ser motstanderens valg før begge har låst oppstillingen. + +
+
+ ) +} + +// --- Team column --------------------------------------------------------------- + +function TeamColumn({ + team, + side, + roster, + tees, + matches, + perSide, + locked, + usedPlayerIds, + onAddParticipant, + onRemoveParticipant, + onAddMatch, + complete, + confirming, + onRequestLock, + onCancelLock, + onConfirmLock, +}: { + team: ApiTeam + side: "a" | "b" + roster: ApiRosterEntry[] + tees: ApiTee[] + matches: ApiMatch[] + perSide: number + locked: boolean + usedPlayerIds: (teamId: string, exceptMatchId: string) => Set + onAddParticipant: (matchId: string, side: "a" | "b", teamRosterId: string, teeId: string) => void + onRemoveParticipant: (matchId: string, participantId: string) => void + onAddMatch: () => void + complete: boolean + confirming: boolean + onRequestLock: () => void + onCancelLock: () => void + onConfirmLock: () => void +}) { + const color = team.color ?? "#64748b" + + return ( +
+
+
+
+ + +
+ + {locked && ( +
+
+ )} + +
+ {matches.map((match) => { + const sideParticipants = match.participants.filter((p) => p.team_side === side) + const disabled = usedPlayerIds(team.id, match.id) + return ( +
+
+ + {match.sequence} + + + +
+ +
+ {sideParticipants.map((p) => ( +
+ {p.player_name} +
+ + {p.tee_name} + + {!locked && ( + + )} +
+
+ ))} + + {!locked && sideParticipants.length < perSide && ( + onAddParticipant(match.id, side, teamRosterId, teeId)} + /> + )} + + {perSide > 1 && ( +

{perSide} spillere per side i dette formatet

+ )} +
+
+ ) + })} +
+ + {!locked && ( + + )} + + {!locked && ( +
+ {confirming ? ( +
+

+ Er du sikker? Du kan ikke endre oppstillingen etter at du har låst. +

+
+ + +
+
+ ) : ( + + )} + {!complete && !confirming && ( +

+ Velg spiller og tee for alle plasser før du kan låse. +

+ )} +
+ )} +
+ ) +} + +// --- Add-slot inline form -------------------------------------------------- + +function AddSlotForm({ + roster, + tees, + disabledPlayerIds, + onAdd, +}: { + roster: ApiRosterEntry[] + tees: ApiTee[] + disabledPlayerIds: Set + onAdd: (teamRosterId: string, teeId: string) => void +}) { + const [rosterId, setRosterId] = useState("") + const [teeId, setTeeId] = useState("") + + function handleAdd() { + if (!rosterId || !teeId) return + onAdd(rosterId, teeId) + setRosterId("") + setTeeId("") + } + + return ( +
+ + + +
+ ) +} + +// --- Revealed view --------------------------------------------------------- + +function RevealedView({ teams, matches }: { teams: [ApiTeam, ApiTeam]; matches: ApiMatch[] }) { + return ( +
+ {matches.map((match) => ( +
+
+ + {match.sequence} + + + +
+ +
+ p.team_side === "a")} align="left" /> +
+ + vs + +
+ p.team_side === "b")} align="right" /> +
+
+ ))} +
+ ) +} + +function RevealSide({ + team, + participants, + align, +}: { + team: ApiTeam + participants: ApiParticipant[] + align: "left" | "right" +}) { + const right = align === "right" + const color = team.color ?? "#64748b" + return ( +
+
+
+ {participants.map((p) => ( +
+ {p.player_name} + {p.tee_name} +
+ ))} +
+ ) +} + +// --- Small helpers --------------------------------------------------------- + +function MetaBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function teeLabel(tee: ApiTee) { + const genderMap: Record<"m" | "f" | "x", string> = { m: "H", f: "D", x: "N" } + const g = tee.gender ? ` ${genderMap[tee.gender]}` : "" + return `${tee.name}${g}` +} + +function formatHcp(value: number | null) { + if (value === null) return "–" + return value.toFixed(1).replace(".", ",") +} + +function formatTime(iso: string) { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) +} + +function formatPoints(points: number) { + return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",") +} diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx index 8e5da2d..0652699 100644 --- a/frontend/components/tournament-program.tsx +++ b/frontend/components/tournament-program.tsx @@ -271,6 +271,7 @@ export function TournamentProgram({ session={session} course={coursesById.get(session.course_id) ?? null} isLast={index === sorted.length - 1} + href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`} /> ))} @@ -298,10 +299,12 @@ function SessionCard({ session, course, isLast, + href, }: { session: ApiSession course: ApiCourse | null isLast: boolean + href: string }) { const title = session.name?.trim() || `Økt ${session.sequence}` @@ -314,7 +317,10 @@ function SessionCard({ {!isLast && -
+

{title}

@@ -350,7 +356,7 @@ function SessionCard({
)}
-
+ ) } diff --git a/frontend/tee-cup-login-screen (4).zip b/frontend/tee-cup-login-screen (4).zip deleted file mode 100644 index 01bebd3..0000000 Binary files a/frontend/tee-cup-login-screen (4).zip and /dev/null differ diff --git a/frontend/tee-cup-login-screen (5).zip b/frontend/tee-cup-login-screen (5).zip deleted file mode 100644 index c0059ae..0000000 Binary files a/frontend/tee-cup-login-screen (5).zip and /dev/null differ