teecup/CLAUDE.md

22 KiB

CLAUDE.md — arbeidsinstruks for TeeCup

Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.

Autoritative kilder (les før du gjør noe)

  • ARCHITECTURE_DECISIONS.md — hva som er bestemt og hvorfor (ADR-001…016). Fasit.
  • FEATURE_BACKLOG.md — hva som gjenstår, hva som er utsatt, hva som mangler.
  • Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge filene oppdatert når noe avgjøres.

Sikkerhetsregler (ufravikelige)

  • Rør ALDRI teeoff-databasen eller den ekte teecup_db uten at brukeren eksplisitt har bekreftet det i samme økt. Test alltid migrasjoner mot en egen scratch-database først, og rydd opp etterpå.
  • Vis planen (hvilke kommandoer, mot hvilken database) FØR du kjører noe som skriver, migrerer eller sletter. Vent på bekreftelse.
  • Hemmeligheter (passord, secrets) bor i .env (filrettigheter 600), dekkes av .gitignore, committes aldri, og skrives aldri i klartekst i chatten eller i SQL-filer. Generer dem på serveren (openssl rand -base64 32).
  • Kjør appen som databaserollen teecup_app (NOSUPERUSER, NOBYPASSRLS) — aldri som teeoff_admin/superuser i runtime.

Arkitektur-invarianter (ikke bryt uten en ny ADR)

  • Tenant = organisasjon. organization_id på alle domenetabeller, håndhevet av RLS. App-koden setter app.current_org med SET LOCAL per transaksjon.
  • Verifiser at brukeren er medlem av organisasjonen FØR org-konteksten settes. RLS stoler blindt på app.current_org.
  • Egen innlogging (uavhengig av teeoff). Banedata hentes fra teeoff via lesende API, ikke delt database.
  • v1 = nøyaktig to lag (Ryder Cup-format), håndhevet i app-laget. Match-modellen holdes generell (to sider) så knockout/flere lag kan komme senere.
  • Handicap-/matchlogikk skal ligge i handicap_engine.py (rent, testet, uten db/API-avhengigheter). Allowances er konfig, ikke hardkodet.
  • Media (bilder/video) skal i objektlagring (MinIO), ikke i Postgres. Postgres holder bare metadata + nøkkel.

Arbeidsmåte

  • Inkrementelt. Ingenting tas for gitt før det er testet. Bekreft hvert steg før du går videre.
  • Bruk git (remote: brukerens Forgejo). Commit i logiske steg med tydelige meldinger.
  • Er du usikker på omfang eller en beslutning: spør heller enn å gjette.

Status (oppdater denne når ting endres)

Ferdig og verifisert:

  • Handicap-motor + tester (24/24, R&A-verifisert).

  • Skjema 001 + roller 002 + scoring/blind draw 003. Isolasjon bevist med test_isolation.sql (RLS-oppførsel, ikke bare at skjemaet kjører).

  • 002 hadde en reell bug (psql interpolerer ikke :'var' inne i DO $$...$$) — permanent fikset, verifisert mot scratch to ganger.

  • API-et kjørt for ekte (ikke bare syntaks-sjekket) i en engangs Docker- container mot en scratch-database, RLS bevist gjennom hele asyncpg-pool-stacken (ikke bare i rå SQL).

  • Oppsett-endepunktene er bygget og verifisert: app/routers/players.py (spillerpool), tournaments.py (turnering/lag/roster/økter, ADR-011 to-lags-grense håndhevet med FOR UPDATE-lås), matches.py (matcher/ deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt feiloversettelse i app/errors.py, delte synlighetsspørringer i app/blind_draw.py. main.py er nå bare app-factory + include_router.

  • Scoring-runden er bygget og verifisert for ekte mot scratch-db (18-hulls bane med tee_rating, 4 spillere for fourball-testing): app/handicap.py (ADR-014 fire brytere via parse_allowance_config, handicap beregnes i compute_and_store_side_handicaps rett etter deltaker-innsetting — singles/fourball per spiller umiddelbart, foursome/greensome/scramble kun når siden er komplett), app/routers/scoring.py (hole-scores/ hole-results-upsert, scorecard-GET, matchstatus-recompute med FOR UPDATE-lås mot race og SAMMENHENGENDE-prefiks-regel for uferdige hull). app/team_authz.py skilt ut fra matches.py (delt med scoring.py). Alle 10 planlagte tester bestått, inkl. fourball better-ball-aggregering (MIN av to nettoer, venter til begge partnere har registrert), poeng-caching ved tidlig avgjort match, og ADR-014-bryteren use_handicap=false. Fant og fikset underveis: tournaments.py sin SessionCreate manglet scoring_mode helt (økter kunne aldri opprettes i hole_result-modus via API-et) — lagt til. Bevisst utelatt/kjente begrensninger: en side som aldri når forventet deltakerantall (no-show) får aldri beregnet handicap og matchen kan da aldri avgjøres — ingen manuell overstyring bygget. Score-skriving er upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser. Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ); bar er «rostret på laget».

  • Match-lås ved avgjørelse (2026-07-16): submit_hole_score/ submit_hole_result avviser nå 409 hvis match.points_side_a IS NOT NULL (matchen er avgjort) — FØR upserten kjøres, både for nye hull og korrigering av allerede talte hull. Tetter en reell bug: uten dette kunne «spøkelses-hull» lagt inn etter avgjørelse endre en allerede cachet margin ved neste omregning. Automatisk, ingen ny autorisasjon involvert.

  • Ekte autentisering bygget og verifisert (2026-07-16): X-Debug-User-Id- stubben er HELT fjernet (ingen fallback). Magic-link + JWT-sesjon i app/routers/auth.py + app/auth.py (request-link/verify-link/ logout/me), ny migrasjon 004_auth.sql (magic_link_token-tabell + unik e-post-indeks på app_user). Token = secrets.token_urlsafe(32), kun SHA-256-hash lagres, atomisk forbruk (UPDATE ... RETURNING, ikke les-sjekk-skriv), generisk respons uansett om e-posten finnes (unngår enumerering), gamle uforbrukte lenker ugyldiggjøres når en ny utstedes, app_user opprettes FØRST ved vellykket verifisering (ikke ved forespørsel). Sesjons-JWT (PyJWT, algorithms=["HS256"] eksplisitt) i HttpOnly/SameSite=Lax/dynamisk-Secure-cookie, 30 dager, med et ekte eksistens-oppslag mot app_user på hver forespørsel (faktisk tilbakekalling — en slettet bruker kan ikke ri ut sesjonen). Alle 12 planlagte tester bestått. Fant og fikset underveis: ON CONFLICT (email) matchet ikke den nye PARTIELLE unike indeksen uten eksplisitt WHERE email IS NOT NULL (samme klasse feil som hole_scores partielle indekser i scoring-runden). Fant, IKKE fikset i denne runden (egen runde rett etterpå — se under): organization-tabellens RLS-policy kastet en 500 i stedet for skjemaets lovede "trygg standard: se ingenting" ved tomstreng-GUC.

  • RLS-tomstreng-bug FIKSET (2026-07-16): ny migrasjon 005_rls_null_guard.sql — delt STABLE SQL-funksjon app_current_org() gjør NULLIF(current_setting('app.current_org', true), '')::uuid i stedet for det rå uttrykket, brukt av alle 15 RLS-policyer (ALTER POLICY, 14 org_isolation + org_self). Verifisert med 3 nye regresjonstester i test_isolation.sql (Test 10-12) OG ved faktisk å gjenskape original- buggen mot en ekte container (pool-størrelse 1, varm opp med org_connection(), deretter /auth/me på samme gjenbrukte tilkobling — gikk fra 500 til 200). Viktig presisering fra denne runden: fiksen gjør IKKE at /auth/me kan joine organization direkte via plain_connection() — det var en feilaktig antakelse i forrige runde. org_self krever fortsatt en MATCHENDE app.current_org for å vise en rad (riktig RLS-design, ikke noe fiksen skulle endre), og en bruker kan tilhøre flere organisasjoner samtidig, så det finnes ingen ÉN kontekst å sette for en tverr-org-spørring. /auth/me slår derfor opp hvert org-navn ett om gangen via org_connection() (N+1, N = antall org-er brukeren tilhører) — dette er riktig løsning, ikke en omvei.

  • Organisasjon-bootstrap bygget og verifisert (2026-07-16): nytt POST /orgs (app/routers/organizations.py) — det ENESTE stedet i API-et som setter inn en organization-rad. Fant under statusgjennomgang at dette manglet helt (alle tidligere org-er var seedet med superbruker-SQL). Ingen ny migrasjon. Selvrefererende RLS-bootstrap bekreftet å fungere: generer org-ens uuid i Python, sett app.current_org til nøyaktig den via eksisterende org_connection(), sett inn organization-raden med samme id — org_selfs implisitte WITH CHECK blir da trivielt sann, ingen privilegert tilkobling nødvendig (i motsetning til hva 002s kommentar antydet). Verifisert med 5 tester inkl. en negativ kontroll (mismatchende id avvist med insufficient_privilege) og full kryss-org-isolasjon mellom to uavhengig opprettede organisasjoner.

  • Ekte SMTP-utsending bygget og verifisert (2026-07-16): ny app/email.py (send_magic_link_email, smtplib via asyncio.to_thread, håndterer både implisitt TLS/port 465 og STARTTLS dynamisk). Brukeren la egne SMTP-credentials i .env (TEECUP_SMTP_*, TEECUP_FROM_EMAIL — ADR-009, ikke delt med teeoff); jeg leste kun nøkkelnavnene for å bekrefte de fantes, aldri verdiene. app/config.py sin SMTP_CONFIGURED er valgfri (ikke _required) — dev-only logging (TEECUP_DEV_LOG_MAGIC_LINKS) fortsatt fungerer uendret når SMTP ikke er satt opp. Driftsfeil i utsendingen lekker aldri til klientresponsen (bevarer anti-enumerering). Verifisert med faktisk levering: sendte én ekte test-e-post til en adresse brukeren oppga — brukeren bekreftet mottak. Første gang noe i prosjektet er bevist ved ekte levering, ikke bare curl/scratch.

  • Containerisert og LIVE på teecup.teeoff.no (2026-07-16): ekte teecup_db opprettet (migrasjoner 001→005 kjørt permanent, test_isolation.sql består), Dockerfile + docker-compose.yml (tjeneste teecup_api, joiner det eksisterende teeoff_default-nettverket), Caddy-blokk lagt til i /opt/teeoff/deploy/Caddyfile. Ekte innlogging (magic-link → e-post → JWT-sesjon med Secure-cookie) verifisert ende-til-ende mot den live stacken. teeoff.no upåvirket gjennom hele prosessen. To reelle hendelser underveis, begge løst:

    1. Caddy plukket ikke opp filendringenteeoff_caddy sin Caddyfile-mount er en ENKELTFIL-bind-mount, låst til inoden som fantes da containeren sist startet. Min fil-redigering (atomisk rename) laget en ny inode på samme sti, så containeren fortsatte å lese den GAMLE filen uansett hvor mange ganger caddy validate/caddy reload/admin-API /load ble kjørt (alle validerte/lastet den uendrede gamle filen, derav ingen feilmelding). Løst med en full docker restart teeoff_caddy (brukeren bekreftet — avvek fra planens "kun graceful reload, ingen omstart"-løfte, noen sekunders nedetid for teeoff.no).
    2. Alvorlig nettverksalias-kollisjon (funnet RETT ETTER omstarten, da ekte teeoff-trafikk som /api/facilities?... med ekte klubb-slugs dukket opp i teecup_api sin logg): docker-compose.yml sin service-nøkkel var api: — SAMME nøkkel som teeoffs eget api-servicenavn (docker-compose.prod.yml). Docker Compose registrerer nettverksalias basert på service-NAVNET (ikke bare container_name) på delte nettverk, så BEGGE containerne fikk alias apiteeoff_default — Caddys reverse_proxy api:8000 i teeoff sin egen config kunne da tilfeldig treffe enten ekte teeoff_api eller teecup_api. Rettet umiddelbart (stoppet teecup_api først for å hindre videre feilruting av ekte teeoff-trafikk, ga service-nøkkelen navnet teecup_api i stedet, gjenopprettet — bekreftet med docker network inspect at alias api nå KUN peker på ekte teeoff_api). Mindre driftslærdom: (a) jeg eksponerte ved et uhell TEECUP_SMTP_PASS/TEECUP_FROM_EMAIL i eget debug-output mens jeg feilsøkte en .env-korrupsjon (manglende linjeskift fra min egen >>-tilføyelse) — brukeren roterte passordet som forsiktighetsregel; (b) Docker leser IKKE .env på nytt for en allerede kjørende container — docker compose up -d --force-recreate kreves etter enhver .env-endring som skal tas i bruk; (c) et #-tegn i et upassordet .env-passord kuttes som en kommentar av Compose sin parser — anførselstegn (fortrinnsvis enkle) løser dette.
  • Dato/klokkeslett + feilkode-kontrakt + i18n-forberedelse (2026-07-17, ADR-015): reist av brukeren rett før frontend-arbeidet. Ny migrasjon 006_scheduling_and_locale.sql (alt additivt): tournament.end_date, session.scheduled_at/tee_interval_minutes/start_hole, match.tee_time_override, app_user.preferred_locale, magic_link_token.locale. match.tee_time er UTLEDET i Python (scheduled_at + (sequence-1)*tee_interval_minutes, override vinner hvis satt) — aldri lagret per match. Full retrofit av ALLE 39 daværende HTTPException(..., detail="norsk streng")-steder på tvers av 6 filer til en delt app_error(status_code, code, message)-factory (app/errors.py) — responsformen er nå konsekvent {"detail":{"code":...,"message":...}} i hele API-et, verifisert med et siste grep -rn 'detail="' app/ som ga NULL treff. i18n: locale (nb/en) sendes av klienten ved request-link, styrer e-postmalen (ekte engelsk mal lagt inn i app/email.py, ikke bare rørlegging) OG settes som en HELT NY brukers preferred_locale — en eksisterende bruker som logger inn på et annet språk får IKKE sin lagrede preferanse overskrevet. Fant og fikset underveis: tournament.start_date har ligget i skjemaet siden migrasjon 001, men var ALDRI koblet til TournamentCreate/Tournament-modellene — funnet som en naturlig bivirkning av å legge til end_date. Verifisert grundig mot fersk teecup_scratch (001→006, test_isolation.sql fortsatt 12/12): feilkode-form bekreftet på tvers av 5 filer (NOT_FOUND/LIMIT_REACHED tournaments.py, DUPLICATE roster, NOT_AUTHENTICATED uten cookie, NOT_ROSTERED_ON_TEAM matches.py), tee_time-beregning bekreftet (10 min intervall → 08:00/08:10/08:20, override vinner, scheduled_at=null gir tee_time:null ikke feil), full i18n-runde bekreftet (ny bruker locale:"en"preferred_locale:"en"; påfølgende request-link for samme bruker med locale:"nb" skiftet e-postmalen men IKKE den lagrede preferansen, bekreftet via /auth/me). Kjørt mot ekte teecup_db 2026-07-17 (bruker bekreftet eksplisitt i samme økt): migrasjonen kjørte rent, test_isolation.sql fortsatt 12/12 (ruller alltid tilbake, ingen domenerader berørt). Mindre driftslærdom, funnet OG rettet samme runde: et .env-filter (grep -v -i 'pass|secret|key') jeg brukte for å lese ikke-sensitive nøkler fanget ikke opp TEECUP_DATABASE_URL, som bar teecup_app- passordet innebygd i selve URL-en (i tillegg til at det SAMME passordet allerede lå rent i TEECUP_APP_PASSWORD — duplisert, ikke bare skjult) — passordet ble dermed synlig i et verktøyresultat. Flagget til brukeren umiddelbart (samme mønster som SMTP-passord-hendelsen over).

  • .env/tilkobling ryddet opp (2026-07-17), samme runde: roten til hendelsen over var at TEECUP_DATABASE_URL var én sammensatt DSN-streng med passordet URL-kodet inni — usynlig for navnebaserte secret-filtre. Erstattet med fem separate, rent navngitte felt: TEECUP_DB_HOST/TEECUP_DB_PORT/TEECUP_DB_NAME/TEECUP_DB_USER/ TEECUP_DB_PASS (sistnevnte omdøpt fra TEECUP_APP_PASSWORD, kun nøkkelnavnet — verdien aldri lest eller skrevet av meg). app/config.py og app/db.py bygger nå asyncpg-poolen fra disse fem separate feltene (host=/port=/user=/password=/database=) i stedet for én DSN — fjerner også URL-prosentkoding-problemet fra SMTP-passord-hendelsen sin klasse av feil. docker-compose.yml sin environment:-liste oppdatert tilsvarende. Krevde et fullt image-rebuild, ikke bare --force-recreate: Dockerfile sin COPY app/ app/ bakes inn i imaget ved build-tid (ingen bind-mount i prod, i motsetning til scratch-verifiseringens engangscontainere) — en ren --force-recreate gjenbrukte det GAMLE imaget og krasjet umiddelbart på den nå fjernede TEECUP_DATABASE_URL. Rettet med docker compose up -d --build --force-recreate. Verifisert ende-til-ende mot den live stacken: containeren boot-et rent (Application startup complete i loggen, som krever en vellykket init_pool() — asyncpg ville kastet og forhindret akkurat den logglinjen ved feil tilkoblingsparametre), /auth/me over ekte https ga et rent 401 NOT_AUTHENTICATED (ikke 500/502), teeoff.no upåvirket (200 gjennom hele omstarten, kun teecup_api restartet — ikke delt Caddy-instans, ikke samme risikoklasse som Caddy-hendelsen fra containeriseringsrunden).

  • Frontend startet, innlogging LIVE (2026-07-17, ADR-016): første frontend-skjerm i prosjektet. Designet i V0 (Next.js + Tailwind + shadcn/ui), hentet inn som frontend/ — merkevare-form/farge fra Teeoff-logoen, IKKE navn/logo (egne, separate produkter, se ADR-009). Kvalitetsrunde før bruk: V0s fargetokens var OKLCH-TILNÆRMINGER, ikke eksakte — regnet ut presise verdier fra #8bc24a/#ff5722 og rettet alle 6 forekomster i globals.css. Fjernet @vercel/analytics (unødvendig på egen-hostet infra), fjernet typescript: { ignoreBuildErrors: true } (ekte typesjekk kjører nå), fjernet dødt pnpm.overrides-felt. frontend/.gitignore manglet .pnpm-store/ — årsaken til at brukerens VSCode viste 10 000 "ukjente" filer ved første commit-forsøk; rettet. Kablet mot ekte API: next.config.mjs sin rewrites() proxyer /auth/*//orgs/*//health server-side til teecup_api — same-origin, ingen CORS, cookie uendret (full begrunnelse i ADR-016). Login-skjermet sender ekte POST /auth/request-link; ny /verify-side mottar ?token=... fra e-postlenken (auto-verifiserer) eller viser et manuelt "lim inn koden"-felt. app/email.py fikk en ny PUBLIC_BASE_URL- innstilling og sender nå en EKTE klikkbar lenke (koden beholdes som fallback). Reell fallgruve funnet og fikset ved containerisering: Next.js sin rewrites() løses ved BUILD-tid for output: "standalone", ikke ved container-oppstart — en runtime -e TEECUP_API_ORIGIN=... ble stille ignorert (proxy-kall feilet med ECONNREFUSED mot localhost:8000). Løst med en Docker build-time ARG TEECUP_API_ORIGIN i frontend/Dockerfile, satt via docker-compose.yml sin build.args. Rullet ut live: ny teecup_frontend-tjeneste i docker-compose.yml. Caddy (teecup.teeoff.no, i det SEPARATE teeoff-repoet, /opt/teeoff/deploy/Caddyfile) endret fra å peke direkte på teecup_api til å peke på teecup_frontend — samme stale-inode-oppførsel som containeriseringsrunden (graceful reload plukket IKKE opp endringen, / fortsatte å gi teecup_api sin egen 404 i stedet for innloggingssiden til reload faktisk skjedde). Løst likt: full docker restart teeoff_caddy, brukeren bekreftet eksplisitt på forhånd. teeoff.no upåvirket gjennom hele omstarten. Verifisert med FAKTISK e-postlevering: ekte magic-link sendt til brukerens egen adresse over https://teecup.teeoff.no, ekte e-post mottatt med en ekte klikkbar lenke, åpnet i nettleser, landet på en fungerende /verify-side, sesjon opprettet — brukeren bekreftet innlogget status. Første gang en hel bruker-vendt flyt er bevist ende-til-ende i produksjon, ikke bare API-et isolert. Merk for neste økt: deploy/Caddyfile-endringen ligger uncommitted i det SEPARATE /opt/teeoff-repoet, ikke i teecup-repoet — lett å glemme siden denne økten ellers kun har jobbet i /opt/teecup.

  • Dashboard-skjerm LIVE (2026-07-18): andre V0-skjerm — organisasjon- bytter/-opprettelse + turneringsliste (/dashboard), samme mønster som login-runden. Reell integrasjonsfelle unngått: V0s eksport denne gangen var en FULL re-eksport av hele prosjektet (inkl. login-form.tsx, next.config.mjs, package.json), ikke bare de nye filene — en naiv utpakking ville stille reversert rewrites()-proxyen, output: "standalone", den ekte fetch-kablingen i login-skjemaet, og alle V0-uavhengige opprydninger fra forrige runde. Løst ved å pakke ut til et scratch-område FØRST, diffe mot live-treet fil for fil, og kun ta inn det som faktisk var nytt (dashboard.tsx, tournament-card.tsx, tournament-status-badge.tsx, wordmark.tsx, badge.tsx, dropdown-menu.tsx, app/dashboard/page.tsx) — next.config.mjs, package.json, globals.css, Docker-filene ble bevisst IKKE overskrevet. login-form.tsx fikk en kirurgisk patch (kun Wordmark flyttet til egen fil, som V0 selv hadde gjort — all egen fetch-/feilhåndteringslogikk urørt). dashboard.tsx sitt datalag skrevet om fra bunnen (V0 leverte kun mock useState): henter /auth/me for organisasjonsmedlemskap, /orgs/{id}/tournaments per valgt org, POST /orgs/POST /orgs/{id}/tournaments for opprettelse, POST /auth/logout for utlogging — presentasjonskomponentene (kort, bytter, tomme tilstander) beholdt uendret fra V0. /verify-siden oppdatert til å sende brukeren videre til /dashboard etter vellykket innlogging (fantes ingen dit å gå før nå). Verifisert: ekte typesjekket build, redeploy av kun teecup_frontend (ingen Caddy-endring nødvendig denne gangen — ADR-016s mønster holder), teecup.teeoff.no/dashboard → 200, teeoff.no upåvirket. Skrive-flyten bekreftet med EKTE data samme dag (brukeren testet selv, ikke meg): organisasjon "Tjøme Gents" og turnering "De Gamle er Eldst" opprettet via UI-et mot den ekte teecup_db — statusmerket viste riktig "Utkast", "Ingen datoer satt" håndtert korrekt (ingen krasj på manglende dato), ett-org-visningen viste riktig uten unødvendig bytter-UI. Første gang en HEL skrive-flyt (ikke bare lesing) er bevist ende-til-ende fra frontend mot ekte produksjonsdata.

Neste steg:

  1. Flere V0-skjermer (lag/roster, økt/program, blind draw, scorekort, leaderboard) — samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport neste gang også — diff mot live-treet i et scratch-område før noe pakkes ut over eksisterende filer (se dashboard-runden over for hvorfor dette er nødvendig hver gang).
  2. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
  3. Kommunikasjon (chat/feed) — ikke startet.