diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d83eaff..76ff1ec 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -276,7 +276,8 @@ "Bash(grep -v '^$')", "Bash(grep -rl \"POSTGRES_PASSWORD\" /opt/teeoff --include='*.yml' --include='*.env*' 2>/dev/null)", "Bash(grep -n \"^ [a-zA-Z0-9_-]*:$\" /opt/teeoff/docker-compose.prod.yml)", - "Bash(set -e)" + "Bash(set -e)", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/dashboard)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index d92daee..ae2168c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1068,15 +1068,139 @@ Ferdig og verifisert: ekte typesjekket build. Rullet ut live, ren frontend-endring, `teeoff.no` upåvirket. +- **Invitasjonskode + ledende side + projisert stilling, BACKEND LIVE + (2026-07-19, ADR-020):** brukeren reiste tre relaterte hull rett etter at + "bygg i rekkefølgen ting brukes"-serien var ferdig: (1) ingen vei inn til + en turnering for en spiller som bare har fått muntlig beskjed, (2) + leaderboardet viser kun faktisk opptjente poeng, ikke hva stillingen ville + blitt om pågående matcher holder seg, (3) ingen fargekoding i matchlister + for hvem som leder. Full ADR-020 skrevet (4 delbeslutninger, se + ARCHITECTURE_DECISIONS.md) — nøkkelbeslutning bekreftet eksplisitt av + bruker FØR bygging: en invitasjonskode OVERSTYRER `tournament.visibility` + helt (koden ER selve invitasjonen, ikke en snarvei som fortsatt krever + eksisterende tilgang). + Ny migrasjon `011_join_code_and_leading_side.sql`: `tournament.join_code` + (6 tegn, alfabet uten 0/O/1/I, globalt unikt, backfylt for eksisterende + rader), `match.leading_side` (cachet fortegn av `MatchState.lead`, samme + mønster som `status_text`/`points_side_a/b`), fjerde + `SECURITY DEFINER`-bro `public_tournament_by_code()` (etter + `public_tournament_org` 007, `link_player_by_email` 008, + `public_org_by_slug` 009). + **Backend bygget:** `create_tournament` genererer koden (retry-løkke ved + kollisjon, astronomisk usannsynlig med 33^6 kombinasjoner). Ny + `GET /public/tournaments/by-code/{code}` (MÅ registreres FØR + `/{tournament_id}` i routeren, ellers tolkes "by-code" som en ugyldig + UUID). `GET /public/tournaments/{id}`, `GET .../sessions` og + `POST .../register` godtar alle en valgfri `code`-parameter som — når den + matcher — hopper `_check_visibility()` helt over. + `recompute_and_cache_match_state` (scoring.py) cacher nå `leading_side` + ved HVER hull-innsending, ikke bare ved avgjørelse. Leaderboard- + endepunktet fikk `projected_points`/`projected_points_by_team`: + avgjorte matcher bidrar likt til faktisk og projisert, ikke-avgjorte gir + hele `points_per_match` til `leading_side` (delt 0,5/0,5 ved "AS"/ikke + startet) — speiler hvordan Ryder Cup-TV-dekning viser "hvis det sluttet + nå". + **Reell hendelse underveis, håndtert transparent (ikke skjult):** en + feilformulert `docker exec teeoff_db env | grep -i POSTGRES`-kommando + (ment å liste variabelNAVN) fanget opp `POSTGRES_PASSWORD` sin VERDI også, + siden selve nøkkelnavnet matchet søkemønsteret — eksponerte `teeoff_db` + sitt superbruker-passord (`teeoff_admin`) i verktøyresultatet. Alvorligere + enn de to tidligere passord-hendelsene i prosjektet siden dette er + superbrukeren for HELE den delte Postgres-klyngen (teeoff OG teecup), ikke + en enkelt tjeneste-credential. Flagget til bruker umiddelbart, som valgte + å rotere. Rotert trygt UTEN å noensinne re-eksponere gammel ELLER ny verdi + i noe synlig kommandoresultat: `ALTER ROLE` kjørt via lokal + Unix-socket-`trust`-auth (bekreftet ved å lese `pg_hba.conf`, ingen + hemmelighet involvert i den sjekken) — krevde altså IKKE det gamle + passordet i det hele tatt. Nytt passord generert med `openssl rand -hex + 32` (hex, ikke base64 — unngår SAMME klasse URL-enkodings-felle som + `TEECUP_DATABASE_URL`-hendelsen tidligere, siden verdien også ligger i en + `postgres://`-DSN). `/opt/teeoff/.env` sine TO forekomster + (`POSTGRES_PASSWORD` og `DATABASE_URL`) oppdatert med `sed`-mønstre som + ALDRI leser/skriver ut den gamle verdien. `teeoff_api`/`teeoff_worker` + service-nøklene i `docker-compose.prod.yml` viste seg å hete `api`/ + `worker` (ikke `teeoff_api`/`teeoff_worker` — det er kun + `container_name`), samme "service-nøkkel ≠ container-navn"-fallgruve som + nettverksalias-hendelsen fra containeriseringsrunden. `docker compose up + -d --force-recreate api worker` gjenskapte OGSÅ `teeoff_db` selv (ikke + eksplisitt navngitt) — Compose oppdager konfigurasjonsendring + (`${POSTGRES_PASSWORD}` i `db`-tjenestens egen `environment:`) og + gjenskaper uansett hvilke tjenester som ble navngitt. Verifisert grundig + ETTERPÅ: `teeoff_db`-loggen viste "Skipping initialization" (datavolum + urørt, ikke reinitialisert) + ren oppstart, `teeoff_api`/`teeoff_worker` + ren oppstart uten en eneste feil-/auth-/passord-linje i hele loggen, + `teeoff.no` OG `teecup.teeoff.no/health` begge `200` etterpå. + **Scratch-verifisert grundig** (fersk `teecup_scratch` 001→011, + isolert `teecup_app_scratch`-rolle, isolert scratch-MinIO, engangs + API-container): `test_isolation.sql` 12/12 (måtte først rettes — tre + RÅ `INSERT INTO tournament`-steder i selve testfilen predaterte + `join_code` og traff den nye NOT NULL-constrainten, rettet med + dummy-koder). Full ende-til-ende-runde: to turneringer opprettet, ulike + koder bekreftet; `by-code`-oppslag bekreftet for kjent OG ukjent kode + (404); anonym lesing av en `org`-synlig turnering BLOKKERT uten kode + (403 NOT_VISIBLE), TILLATT med riktig kode (inkl. case-insensitivt), + FORTSATT blokkert med feil kode; samme mønster bekreftet for + `POST .../register`. Full hull-for-hull-simulering av en singel-match + (hole_result-modus): `leading_side`/`status_text` fulgte hverandre + eksakt gjennom "1 UP (A)" → "AS" (leading_side=null) → avgjort "9&7 + (A)", leaderboardets `projected_points` traff nøyaktig 1.0/0.0 mens A + ledet, 0.5/0.5 ved "AS", og ble likt `points`/`projected_points` (begge + 1.0/0.0) etter avgjørelse. + **Rullet ut mot ekte `teecup_db` 2026-07-19**, bruker bekreftet + eksplisitt: migrasjon 011 kjørt (eneste eksisterende turnering fikk + automatisk generert kode), `test_isolation.sql` fortsatt 12/12, kun + `teecup_api` redeployet (ingen frontend-endring i denne del-runden), + `teeoff.no` upåvirket. + **Frontend fullført samme dag, egen del-runde:** `login-form.tsx` fikk et + eget kode-modus (`JoinByCode`) — «Har du en invitasjonskode?»-lenke bytter + ut e-post-skjemaet, slår opp `/public/tournaments/by-code/{code}` og + navigerer til `/t/{id}?code=...` med Next sin `useRouter`. `code` + query-param tres gjennom hele veien: `app/t/[id]/page.tsx` leser + `searchParams`, `public-tournament.tsx` sender den med på BÅDE + info-/sessions-lesingen og selve `POST .../register` (ikke bare det + første oppslaget som tok deg dit). + `tournament-detail.tsx` viser koden i en egen kopier-chip i headeren — + ingen enkelt-turnering-`GET` fantes, så komponenten henter i stedet hele + org-ens turneringsliste (som allerede bærer `join_code`) og finner egen + rad, i stedet for å legge til et nytt endepunkt kun for dette. + `tournament-leaderboard.tsx` fikk en ny `SegmentedBar`-komponent — ETT + fargesegmentert rektangel per bar (ikke tall side om side), proporsjonalt + med hvert lags poeng, 50/50 nøytralt ved 0-0. To slike bares rett under + headeren: "Stilling nå" (faktisk) og "Projisert (hvis pågående matcher + holder seg)" — den EKSISTERENDE store tall-scoreboarden beholdt uendret + lenger ned som detaljvisning. + `session-blind-draw.tsx` sin `RevealedView`: matchkortet får nå en farget + toppkant (leaderens `team.color`) og en `status_text`-chip (fylt farge + ved avgjort match m/ konfetti-ikon, lys tone ved pågående) i stedet for + kun klokkeslettet; `RevealSide` for ledende side får en svak fargetonet + bakgrunn. `leading_side`/`status_text`/`points_side_a/b` lagt til + `ApiMatch`-typen (var der allerede i API-et, bare ikke konsumert + frontend-siden før nå). + **Verifisert:** ekte typesjekket produksjonsbuild (samme `Dockerfile` + som deployes, ikke dev-server) kompilerte rent, alle 10 ruter listet. + Rullet ut (kun `teecup_frontend`, ingen backend-endring i denne delen), + `teecup.teeoff.no/dashboard` og `/` → 200, `teeoff.no` upåvirket. Ekte + smoke-test i produksjon: `by-code`-oppslag for "De Gamle er Eldst" sin + faktiske kode ga riktig turnering-id, `/t/{id}?code=...` ga 200. + **ADR-020 er dermed helt ferdig** (backend + frontend, alle fire + del-ønsker: kode-basert oppdagelse, kode-felt på login, projisert + stilling, fargekoding av matcher) — bortsett fra kode-regenerering, som + er bevisst utsatt (se FEATURE_BACKLOG.md). + **Reell driftshendelse underveis** (mellom backend- og frontend-delen, + under scratch-oppsett): en feilformulert `grep -i POSTGRES`-kommando + eksponerte `teeoff_db` sitt superbruker-passord ved et uhell. Flagget + umiddelbart, brukeren valgte å rotere — se detaljene under + backend-avsnittet over for hele hendelsen og hvordan roteringen ble + gjennomført uten å noensinne re-eksponere gammel eller ny verdi. + Neste steg: 1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. 2. Kommunikasjon (chat/feed) — ikke startet. 3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md (walkover/konsesjon, - sanntid/WebSockets vs. polling, tilskuer-rolle) — bør avklares i egne, - dedikerte runder når det er naturlig. - 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 - ikke støtter ennå FØR integrering. -3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. -4. Kommunikasjon (chat/feed) — ikke startet. + sanntid/WebSockets vs. polling, tilskuer-rolle, kode-regenerering for + ADR-020) — bør avklares i egne, dedikerte runder når det er naturlig. +4. Ved fremtidige nye V0-skjermer/-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 ikke støtter ennå FØR + integrering. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 06064fd..efefa23 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -646,6 +646,28 @@ sammenslåing eller rekkefølge-endring i en senere runde. --- +## Invitasjonskode, ledende side og projisert stilling — ADR-020 + +Reist av brukeren 2026-07-19, rett etter at kamp-play-flyten var komplett. +Full design i ARCHITECTURE_DECISIONS.md ADR-020. + +| Del | Status | Notat | +|---|---|---| +| Kort invitasjonskode per turnering, overstyrer visibility | ✅ backend | `tournament.join_code` (migrasjon 011), genereres automatisk ved opprettelse. `GET /public/tournaments/by-code/{code}` + kode-bypass i `registration.py`. Løser "muntlig invitert spiller finner ikke turneringen"-hullet. | +| `match.leading_side` (strukturert, ikke tekst-parsing) | ✅ backend | Cachet i `recompute_and_cache_match_state` ved hver hull-innsending, eksponert på `MatchOut`. | +| Projisert stilling (hvis pågående matcher holder seg) | ✅ backend | `TeamStanding.projected_points` + `SessionStanding.projected_points_by_team` i leaderboard-endepunktet. Ikke-avgjorte matcher gir full poengsum til `leading_side`, delt 0,5/0,5 ved "AS". | +| Login-skjerm: kode-felt, tar deg direkte til turneringen | ✅ LIVE 2026-07-19 | `login-form.tsx` sin `JoinByCode`. `code` tres gjennom `/t/[id]` sin lesing OG registrering. | +| Turnering-detalj: vis invitasjonskode (kopier-knapp) | ✅ LIVE 2026-07-19 | `tournament-detail.tsx` sin `JoinCodeChip` — henter fra org-ens turneringsliste, ikke et nytt endepunkt. | +| Leaderboard: to stillingsbarer (faktisk + projisert) | ✅ LIVE 2026-07-19 | `tournament-leaderboard.tsx` sin `SegmentedBar` — fargesegmentert rektangel, ikke tall side om side. Eksisterende tall-scoreboard beholdt som detaljvisning. | +| Fargekoding av matchlister etter ledende lag | ✅ LIVE 2026-07-19 | `session-blind-draw.tsx` sin `RevealedView`/`RevealSide` — farget toppkant + status-chip, bruker `leading_side` + eksisterende `team.color`, ingen ny fargemodell. | +| Kode-regenerering (ved lekket kode) | 💤 bevisst utsatt | Ikke bygget denne runden — ingen organisator-vei til å bytte ut en kode ennå. Egen sak hvis etterspurt. | + +**ADR-020 er dermed helt ferdig** — backend + frontend, alle fire +del-ønsker levert samme dag. Se CLAUDE.md-status for full runde inkl. en +reell (og transparent håndtert) passord-eksponeringshendelse underveis. + +--- + ## UX / frontend (senere fase) - 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp diff --git a/frontend/app/t/[id]/page.tsx b/frontend/app/t/[id]/page.tsx index b592178..3975ad3 100644 --- a/frontend/app/t/[id]/page.tsx +++ b/frontend/app/t/[id]/page.tsx @@ -44,9 +44,12 @@ export async function generateMetadata({ export default async function PublicTournamentPage({ params, + searchParams, }: { params: Promise<{ id: string }> + searchParams: Promise<{ code?: string }> }) { const { id } = await params - return + const { code } = await searchParams + return } diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx index eb1ff94..41cc9bc 100644 --- a/frontend/components/login-form.tsx +++ b/frontend/components/login-form.tsx @@ -2,7 +2,8 @@ import type React from "react" import { useEffect, useRef, useState } from "react" -import { Mail, ArrowLeft, CheckCircle2 } from "lucide-react" +import { useRouter } from "next/navigation" +import { Mail, ArrowLeft, CheckCircle2, KeyRound } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -14,6 +15,7 @@ function isValidEmail(value: string) { } export function LoginForm() { + const [mode, setMode] = useState<"email" | "code">("email") const [email, setEmail] = useState("") const [touched, setTouched] = useState(false) const [sent, setSent] = useState(false) @@ -83,6 +85,8 @@ export function LoginForm() { onResend={handleResend} onReset={handleReset} /> + ) : mode === "code" ? ( + setMode("email")} /> ) : (
@@ -133,12 +137,109 @@ export function LoginForm() {

Ingen passord. Vi sender deg en sikker lenke på e-post.

+ + )}
) } +// --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort, +// menneske-skrivbar kode -- FØR innlogging, og uansett turneringens +// synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). --------- + +function JoinByCode({ onBack }: { onBack: () => void }) { + const router = useRouter() + const [code, setCode] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const trimmed = code.trim() + if (!trimmed || loading) return + setLoading(true) + setError(null) + try { + const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`) + if (!res.ok) { + const body = await res.json().catch(() => null) + setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.") + return + } + const data: { tournament_id: string } = await res.json() + router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`) + } catch { + setError("Klarte ikke å slå opp koden. Sjekk tilkoblingen og prøv igjen.") + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+
+
+ + {error && ( +

+ {error} +

+ )} + + + +

+ Fått en kode muntlig eller på en lapp? Skriv den inn her — du trenger ikke logge inn for + å se turneringen eller melde deg på. +

+ + +
+ ) +} + function ConfirmationState({ email, cooldown, diff --git a/frontend/components/public-tournament.tsx b/frontend/components/public-tournament.tsx index d7b7e43..4dfb0ea 100644 --- a/frontend/components/public-tournament.tsx +++ b/frontend/components/public-tournament.tsx @@ -107,17 +107,22 @@ function formatDateRange(start?: Date, end?: Date) { // --- Root ------------------------------------------------------------------ -export function PublicTournament({ tournamentId }: { tournamentId: string }) { +export function PublicTournament({ tournamentId, code }: { tournamentId: string; code?: string }) { const [info, setInfo] = useState(null) const [sessions, setSessions] = useState([]) const [loading, setLoading] = useState(true) const [accessError, setAccessError] = useState(null) + // ADR-020: en gyldig invitasjonskode overstyrer turneringens synlighet -- + // føres med på BÅDE lesingen og registreringen (RegistrationCard under), + // ikke bare det første oppslaget som tok deg hit fra login-skjermet. + const codeParam = code ? `?code=${encodeURIComponent(code)}` : "" + useEffect(() => { let cancelled = false async function load() { try { - const res = await fetch(`/public/tournaments/${tournamentId}`, { credentials: "include" }) + const res = await fetch(`/public/tournaments/${tournamentId}${codeParam}`, { credentials: "include" }) if (res.status === 403 || res.status === 404) { const body = await res.json().catch(() => null) if (!cancelled) { @@ -133,7 +138,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) { if (cancelled) return setInfo(data) - const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions`, { + const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, { credentials: "include", }) if (sessionsRes.ok) { @@ -159,7 +164,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) { return () => { cancelled = true } - }, [tournamentId]) + }, [tournamentId, codeParam]) if (loading) { return ( @@ -232,7 +237,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) { {info.registration_open ? ( - + ) : ( )} @@ -435,9 +440,11 @@ const GENDER_TO_API: Record = { kvinne: "f", mann: "m", annet: " function RegistrationCard({ tournamentId, tournamentName, + code, }: { tournamentId: string tournamentName: string + code?: string }) { const [name, setName] = useState("") const [email, setEmail] = useState("") @@ -491,6 +498,7 @@ function RegistrationCard({ gender: GENDER_TO_API[optional.gender], handicap_index: hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined, consent, + code: code || undefined, }), }) if (!res.ok) { diff --git a/frontend/components/session-blind-draw.tsx b/frontend/components/session-blind-draw.tsx index d222d82..671af95 100644 --- a/frontend/components/session-blind-draw.tsx +++ b/frontend/components/session-blind-draw.tsx @@ -57,6 +57,10 @@ type ApiMatch = { sequence: number team_a_id: string team_b_id: string + status_text: string | null + points_side_a: number | null + points_side_b: number | null + leading_side: "a" | "b" | null tee_time: string | null participants: ApiParticipant[] } @@ -699,33 +703,66 @@ function RevealedView({ }) { return (
- {matches.map((match) => ( - -
- - {match.sequence} - - - -
- -
- p.team_side === "a")} align="left" /> -
- - vs + {matches.map((match) => { + // Fargekoding etter ledende lag (ADR-020) -- umiddelbar visuell + // status i stedet for kun tekst, samme mønster som TV-dekning av + // Ryder Cup. Ingen farge når leading_side er null (ikke startet/AS). + const leadingTeam = + match.leading_side === "a" ? teams[0] : match.leading_side === "b" ? teams[1] : null + const leadColor = leadingTeam?.color ?? null + const decided = match.points_side_a !== null + return ( + +
+ + {match.sequence} + {match.status_text ? ( + + {decided && + ) : ( + + + )}
- p.team_side === "b")} align="right" /> -
- - ))} + +
+ p.team_side === "a")} + align="left" + leading={match.leading_side === "a"} + /> +
+ + vs + +
+ p.team_side === "b")} + align="right" + leading={match.leading_side === "b"} + /> +
+ + ) + })}
) } @@ -734,17 +771,22 @@ function RevealSide({ team, participants, align, + leading, }: { team: ApiTeam participants: ApiParticipant[] align: "left" | "right" + leading: boolean }) { const right = align === "right" const color = team.color ?? "#64748b" return (