Før kommunikasjon med V0

This commit is contained in:
Erol Haagenrud 2026-07-17 21:40:42 +02:00
parent cc66d26cba
commit 0ca9151ed6
41 changed files with 5252 additions and 152 deletions

View file

@ -127,11 +127,40 @@
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_prod.txt https://teecup.teeoff.no/auth/me)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_prod.txt /tmp/health_resp.json *)",
"Bash(git -C /opt/teecup status --short)",
"Bash(git -C /opt/teeoff status --short)"
"Bash(git -C /opt/teeoff status --short)",
"Bash(echo \"exit=$?\")",
"Bash(python3 -m py_compile app/errors.py app/auth.py app/routers/auth.py app/email.py app/routers/tournaments.py app/routers/matches.py app/routers/scoring.py)",
"Bash(chmod 600 .env.scratch)",
"Bash(sed -i -e 's#/teecup_db$#/teecup_scratch#' -e 's#^TEECUP_DEV_LOG_MAGIC_LINKS=.*#TEECUP_DEV_LOG_MAGIC_LINKS=true#' .env.scratch)",
"Bash(grep -c '^TEECUP_DEV_LOG_MAGIC_LINKS=true$' .env.scratch)",
"Bash(rm -f /opt/teecup/.env.scratch /tmp/scratch_cookies.txt)",
"Bash(python3 -m py_compile app/config.py app/db.py)",
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/auth/me)",
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teeoff.no/)",
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/health)",
"Bash(ls -la /opt/teecup/.env.bak.* 2>&1 *)",
"Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/health: %{http_code}\\\\n' https://teecup.teeoff.no/health)",
"Bash(curl -s -o /dev/null -w 'teeoff.no: %{http_code}\\\\n' https://teeoff.no/)",
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
"Bash(dpkg -l)",
"Bash(sudo -n apt-get install -y libavif-bin)",
"Bash(apt-get install *)",
"Bash(ls -la /opt/ 2>&1 *)",
"Read(//opt/**)",
"Bash(find / -maxdepth 3 -iname \"*.zip\" -newer /opt/teecup/TeeOff-logo-Retina-1.avif)",
"Bash(find / -xdev -iname \"*.zip\" -mmin -60)",
"Bash(unzip -l tee-cup-login-screen.zip)",
"Bash(python3 -m zipfile -l tee-cup-login-screen.zip)",
"Bash(python3 -m zipfile -e tee-cup-login-screen.zip .)",
"Bash(python3)",
"Bash(docker --version)",
"Bash(docker run --rm -v /opt/teecup/frontend:/app -w /app node:22-slim bash -c ' *)",
"Bash(git -C /opt/teecup status --short frontend/)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",
"/tmp"
"/tmp",
"/opt/teecup"
]
}
}

View file

@ -0,0 +1,43 @@
-- =====================================================================
-- TeeCup — migrasjon 006
-- Dato/klokkeslett for turnering/økt/match + locale-forberedelse (i18n)
-- =====================================================================
-- Kjøres etter 001-005. Alt additivt (nullable eller default) — trygt mot
-- en database som allerede er live.
-- =====================================================================
\set ON_ERROR_STOP on
-- ---------------------------------------------------------------------
-- 1. Turnering: sluttdato (start_date fantes allerede fra 001)
-- ---------------------------------------------------------------------
ALTER TABLE tournament ADD COLUMN end_date date;
ALTER TABLE tournament ADD CONSTRAINT tournament_date_order
CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date);
-- ---------------------------------------------------------------------
-- 2. Økt: starttid + intervall mellom flights, pluss startshull.
-- match.tee_time utledes av disse (scheduled_at + (sequence-1) *
-- tee_interval_minutes) i app-laget, IKKE lagret per match -- se
-- match.tee_time_override under for unntakstilfeller.
-- ---------------------------------------------------------------------
ALTER TABLE session ADD COLUMN scheduled_at timestamptz;
ALTER TABLE session ADD COLUMN tee_interval_minutes smallint
CHECK (tee_interval_minutes IS NULL OR tee_interval_minutes > 0);
ALTER TABLE session ADD COLUMN start_hole smallint NOT NULL DEFAULT 1
CHECK (start_hole BETWEEN 1 AND 18);
-- ---------------------------------------------------------------------
-- 3. Match: valgfri overstyring av utledet tee_time (forsinkelser e.l.)
-- ---------------------------------------------------------------------
ALTER TABLE match ADD COLUMN tee_time_override timestamptz;
-- ---------------------------------------------------------------------
-- 4. i18n-forberedelse: kun to locales foreløpig -- CHECK gjør det
-- eksplisitt at flere krever en ny migrasjon, ikke en stille utvidelse.
-- ---------------------------------------------------------------------
ALTER TABLE app_user ADD COLUMN preferred_locale text NOT NULL DEFAULT 'nb'
CHECK (preferred_locale IN ('nb', 'en'));
ALTER TABLE magic_link_token ADD COLUMN locale text NOT NULL DEFAULT 'nb'
CHECK (locale IN ('nb', 'en'));

View file

@ -293,6 +293,76 @@ Playing Handicap i stedet for å kalle `match_play_strokes`.
---
## ADR-015 — Utledet tee-time og maskinlesbar feilkode-kontrakt
**Beslutning A — klokkeslett er utledet, ikke lagret per match:** `session`
får `scheduled_at` (starttid for FØRSTE match) + `tee_interval_minutes`
(minutter mellom hver "flight"). En matchs `tee_time` er
`scheduled_at + (sequence-1) * tee_interval_minutes`, beregnet i Python ved
lesing (`app/routers/matches.py`), ALDRI skrevet til `match`-raden. Unntak:
`match.tee_time_override` (nullable `timestamptz`) for enkeltmatcher som må
justeres uavhengig (forsinkelser) — vinner alltid over den utledede verdien
når satt. `session.start_hole` er eksplisitt lagret (ikke utledet av
`hole_config`).
**Begrunnelse:** Brukeren beskrev selv mønsteret rett fra domenet: "man sier
at førstematchen starter på hull X klokka Y, og det er Z minutter mellom hver
flight" — det er slik tee-tider faktisk fungerer i golf, og å lagre ett
tidspunkt per match ville vært duplisert, avledet data som kunne komme ut av
synk med intervallet. Et lite unntak (override) dekker det ene tilfellet der
den avledede regelen ikke holder.
**Konsekvens:** Alle tre nye tidsfelter er nullable/additive (migrasjon
`006_scheduling_and_locale.sql`) — en økt uten planlagt klokkeslett gir
`tee_time: null` i API-et, ikke en feil. Det finnes ennå intet
PATCH-endepunkt for `tee_time_override` (satt direkte i databasen ved
verifisering); bygges når frontend faktisk trenger å justere enkeltmatcher.
**Beslutning B — én uniform feilrespons på tvers av HELE API-et:**
`{"detail": {"code": "...", "message": "..."}}` på ALLE stedene som kaster
`HTTPException` (39 steder ved innføring), via en liten factory
`app_error(status_code, code, message)` i `app/errors.py` — ingen egen
exception-klasse, ingen global exception handler. Kodene er en liten,
gjenbrukt taksonomi (~15 koder, IKKE én unik kode per kastested) —
f.eks. `NOT_FOUND`, `DUPLICATE`, `LIMIT_REACHED`, `NOT_ROSTERED_ON_TEAM`,
`NOT_AUTHENTICATED`. Pydantic sine egne 422-valideringsfeil er bevisst
UTENFOR denne kontrakten og beholder FastAPI sin standard `{"detail": [...]}`.
**Begrunnelse:** Hardkodet norsk prosa i `detail` (slik det var før denne
runden) kan ikke brukes til noe annet enn "vis strengen" av en frontend —
den kan ikke skille en 409 pga. dupliserte rader fra en 409 pga. et
forretningstak, og kan ikke oversettes (se i18n under). En liten, gjenbrukt
kode-taksonomi lar frontend bygge stabil logikk (f.eks. "vis en spesifikk
inline-feil for `LIMIT_REACHED`, en generisk toast for alt annet") uten å
måtte parse norsk tekst. Skulle vært gjort FØR frontend startet — dyrt å
ettermontere når klientkode allerede har begynt å parse tekststrenger.
**Konsekvens:** Enhver ny `HTTPException` i fremtidig API-kode SKAL bruke
`app_error()`, aldri en rå `HTTPException(status_code, detail="...")` — se
`app/errors.py` sin docstring. Nye forretningsbetydninger som ikke passer
noen eksisterende kode får en ny kode i taksonomien, ikke gjenbruk av en
semantisk feil kode.
**Beslutning C — locale er klient-oppgitt, ikke server-gjettet:**
`app_user.preferred_locale` og `magic_link_token.locale` (kun `nb`/`en`
foreløpig, håndhevet med `CHECK`) settes fra en `locale`-verdi klienten
sender eksplisitt ved `POST /auth/request-link` — ALDRI gjettet server-side
(f.eks. fra `Accept-Language`). En NY bruker får `preferred_locale` satt fra
forespørselens locale ved førstegangsopprettelse; en EKSISTERENDE bruker som
ber om en ny lenke på et annet språk får IKKE sin lagrede preferanse
overskrevet — kun selve e-posten sendes på forespørselens språk. Ingen
endepunkt for å ENDRE `preferred_locale` på en eksisterende bruker ennå
(egen, senere sak — profilinnstillinger).
**Begrunnelse:** Frontend vet sitt eget gjeldende visningsspråk (brukerens
valg i UI-et) — det er en bedre kilde enn å gjette fra headere eller IP.
At en påfølgende innlogging på et annet språk (f.eks. en gjest som låner en
enhet) IKKE skal endre den lagrede preferansen er bevisst: en
innloggingshandling bør ikke ha den overraskende bivirkningen å endre
brukerens varige profilinnstilling.
---
## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:

View file

@ -3,7 +3,7 @@
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…014). Fasit.
- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…015). 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.
@ -186,10 +186,75 @@ Ferdig og verifisert:
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).
Neste steg:
1. Frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, siden
004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE
1. Frontend (PWA, offline-first) og kommunikasjon. Frontend er fortsatt IKKE
startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — helt frem til
nå har API-et vært nåbart, men uten noe grensesnitt en sluttbruker kan
bruke.

View file

@ -7,7 +7,7 @@
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
>
> Sist oppdatert: 2026-07-16
> Sist oppdatert: 2026-07-17
---
@ -32,6 +32,71 @@
| Organisasjon-bootstrap (opprette ny org via API) | ✅ | `POST /orgs`, `app/routers/organizations.py`. Se detaljer under. |
| Ekte SMTP-utsending av magic-link | ✅ | `app/email.py`. Se detaljer under. |
| Containerisert, LIVE på `teecup.teeoff.no` | ✅ | `Dockerfile` + `docker-compose.yml`. Se egen seksjon under — to reelle driftshendelser funnet og rettet. |
| Dato/klokkeslett på turnering/økt/match | ✅ | ADR-015. `session.scheduled_at` + `tee_interval_minutes` → utledet `match.tee_time`. Se egen seksjon under. |
| Maskinlesbar feilkode-kontrakt (`{"code",...}`) | ✅ | ADR-015. Full retrofit, alle 39 tidligere steder. Se egen seksjon under. |
| i18n-forberedelse (nb/en) | ✅ | ADR-015. `preferred_locale` + ekte engelsk e-postmal. Se egen seksjon under. |
---
### Dato/klokkeslett + feilkode-kontrakt + i18n-forberedelse — ✅ BYGGET OG VERIFISERT 2026-07-17
- Reist av brukeren rett før frontend-arbeidet: kan turnering/økt/match ha
dato/klokkeslett, og må appen forberedes for flerspråklighet? Begge er
API-kontraktspørsmål som er langt billigere å løse FØR frontend bygges enn
å ettermontere — se ADR-015 for alle tre beslutningene i sin helhet.
- **Migrasjon `006_scheduling_and_locale.sql`:** alt additivt (nullable/
default) — `tournament.end_date` (+ `CHECK` mot `start_date`),
`session.scheduled_at`/`tee_interval_minutes`/`start_hole`,
`match.tee_time_override`, `app_user.preferred_locale`,
`magic_link_token.locale` (begge sistnevnte `CHECK IN ('nb','en')`). Kjørt
permanent mot den ekte `teecup_db` (se under).
- **Feilkoder:** ny `app_error(status_code, code, message)`-factory i
`app/errors.py`, alle 39 tidligere norsk-hardkodede `HTTPException(...,
detail="...")`-steder på tvers av 6 filer (`errors.py`, `auth.py`,
`routers/auth.py`, `routers/tournaments.py`, `routers/matches.py`,
`routers/scoring.py`) retrofittet til en delt ~15-kode-taksonomi. Endelig
`grep -rn 'detail="' app/` ga NULL treff.
- **Dato/tid i API-et:** `tournament.start_date` (fantes i skjemaet siden
001, men var ALDRI koblet til API-modellene før nå — funnet og fikset som
en naturlig bivirkning av å legge til `end_date`) + `end_date` eksponert;
`session` sine tre nye felt eksponert i `SessionCreate`/`SessionOut`;
`match.tee_time` utledet i Python i både `create_match` og `list_matches`
(ingen ekstra spørring — økten hentes allerede for andre formål der).
- **i18n:** `MagicLinkRequest.locale` (`Literal["nb","en"]`, default `nb`)
sendes av klienten, følger med i token-raden, brukes til å velge
e-postmal OG (kun ved førstegangsopprettelse) `app_user.preferred_locale`.
Ekte engelsk e-postmal lagt inn i `app/email.py` (ikke bare rørlegging) —
konkret bevis på at flerspråklighet fungerer ende-til-ende, ikke bare at
et felt eksisterer.
- **Verifisert grundig mot en fersk `teecup_scratch`** (migrasjoner
001→006, `test_isolation.sql` 12/12 uendret, engangs-API-container):
feilkode-form bekreftet på tvers av 5 filer (`NOT_FOUND`/`LIMIT_REACHED`
fra tournaments.py, `DUPLICATE` fra roster, `NOT_AUTHENTICATED` uten
cookie, `NOT_ROSTERED_ON_TEAM` fra matches.py sin `add_participant`);
`tee_time` bekreftet å øke riktig per `sequence` (10 min intervall →
08:00/08:10/08:20), `tee_time_override` bekreftet å vinne over utledet
verdi, økt uten `scheduled_at` bekreftet å gi `tee_time: null` (ikke
feil); i18n bekreftet full runde — ny bruker med `locale:"en"` fikk
`preferred_locale:"en"`, en ETTERFØLGENDE `request-link` for samme bruker
med `locale:"nb"` skiftet e-postmalen men IKKE den lagrede preferansen
(bekreftet uendret via `/auth/me`).
- **Migrasjon 006 kjørt mot ekte `teecup_db` 2026-07-17**, bruker bekreftet
eksplisitt i samme økt — kjørte rent, `test_isolation.sql` fortsatt 12/12.
- **Mindre driftslærdom, funnet OG rettet samme runde:** et innledende
`grep -v -i 'pass|secret|key'`-filter jeg brukte for å lese ikke-sensitive
`.env`-nøkler fanget IKKE opp `TEECUP_DATABASE_URL`, som bar
`teecup_app`-passordet innebygd i selve URL-en (i tillegg duplisert av det
samme passordet som allerede lå rent i `TEECUP_APP_PASSWORD`) — passordet
endte dermed synlig i et verktøyresultat. Flagget til brukeren umiddelbart
(samme mønster som SMTP-passord-hendelsen under containeriseringsrunden).
**Rettet:** `.env` bygget om til fem separate, rent navngitte felt
(`TEECUP_DB_HOST/PORT/NAME/USER/PASS`), `app/config.py`+`app/db.py` bygger
`asyncpg`-poolen fra disse i stedet for én DSN-streng,
`docker-compose.yml` oppdatert tilsvarende. Se CLAUDE.md-status for full
driftsdetalj, inkl. at dette krevde et fullt
`docker compose up -d --build --force-recreate` (ikke bare
`--force-recreate` — imaget bygger inn `app/` ved build-tid). Verifisert
ende-til-ende mot den live stacken (`Application startup complete`,
`/auth/me` ga rent `401` over ekte https, `teeoff.no` upåvirket).
---

BIN
TeeOff-logo-Retina-1.avif Normal file

Binary file not shown.

View file

@ -20,10 +20,11 @@ import time
from dataclasses import dataclass
import jwt
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, Request
from .config import settings
from .db import plain_connection
from .errors import app_error
SESSION_COOKIE_NAME = "teecup_session"
_JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer
@ -62,20 +63,20 @@ async def get_current_user(request: Request) -> CurrentUser:
"""
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ikke innlogget.")
raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.")
try:
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
except jwt.PyJWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig eller utløpt sesjon.")
raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig eller utløpt sesjon.")
user_id = claims.get("sub")
if not user_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig sesjon.")
raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig sesjon.")
async with plain_connection() as conn:
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
if not exists:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Brukeren finnes ikke lenger.")
raise app_error(401, "NOT_AUTHENTICATED", "Brukeren finnes ikke lenger.")
return CurrentUser(user_id=user_id)
@ -101,8 +102,5 @@ async def get_authorized_org(
organization_id,
)
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Brukeren er ikke medlem av denne organisasjonen.",
)
raise app_error(403, "NOT_ORG_MEMBER", "Brukeren er ikke medlem av denne organisasjonen.")
return organization_id

View file

@ -21,8 +21,15 @@ def _required(name: str) -> str:
class Settings:
# Peker på teecup_db, og bruker RUNTIME-rollen teecup_app (ikke teeoff_admin).
# Format: postgresql://teecup_app:<passord>@<host>:<port>/teecup_db
DATABASE_URL: str = _required("TEECUP_DATABASE_URL")
# Fem separate felt (ikke én sammensatt DSN-streng) -- en URL som bærer
# passordet innebygd unngår ikke hemmeligheten, den gjemmer den bare et
# sted secret-filtre som leter etter "pass|secret|key" i variabelNAVNET
# ikke ser etter (se CLAUDE.md-status for hendelsen dette rettet).
DB_HOST: str = _required("TEECUP_DB_HOST")
DB_PORT: int = int(_required("TEECUP_DB_PORT"))
DB_NAME: str = _required("TEECUP_DB_NAME")
DB_USER: str = _required("TEECUP_DB_USER")
DB_PASS: str = _required("TEECUP_DB_PASS")
# Egen sesjons-secret, uavhengig av teeoff.
SESSION_SECRET: str = _required("TEECUP_SESSION_SECRET")

View file

@ -28,7 +28,11 @@ _pool: asyncpg.Pool | None = None
async def init_pool() -> None:
global _pool
_pool = await asyncpg.create_pool(
dsn=settings.DATABASE_URL,
host=settings.DB_HOST,
port=settings.DB_PORT,
user=settings.DB_USER,
password=settings.DB_PASS,
database=settings.DB_NAME,
min_size=settings.DB_POOL_MIN,
max_size=settings.DB_POOL_MAX,
)

View file

@ -37,18 +37,38 @@ def _send_sync(to_email: str, subject: str, body: str) -> None:
smtp.send_message(msg)
async def send_magic_link_email(to_email: str, raw_token: str) -> None:
"""Sender selve magic-link-tokenet som klartekst.
_TEMPLATES = {
"nb": {
"subject": "Logg inn på TeeCup",
"body": (
"Hei,\n\n"
"Bruk denne koden for å logge inn på TeeCup innen {minutes} minutter:\n\n"
"{token}\n\n"
"Ba du ikke om dette, kan du se bort fra e-posten.\n"
),
},
"en": {
"subject": "Log in to TeeCup",
"body": (
"Hi,\n\n"
"Use this code to log in to TeeCup within {minutes} minutes:\n\n"
"{token}\n\n"
"If you didn't request this, you can ignore this email.\n"
),
},
}
async def send_magic_link_email(to_email: str, raw_token: str, locale: str = "nb") -> None:
"""Sender selve magic-link-tokenet som klartekst, på angitt språk.
MERK: ingen klikkbar lenke ennå -- TeeCup har ikke noe frontend å peke
(se plan). Når frontend bygges, oppdater malen til en ekte URL.
`locale` valideres allerede av Pydantic (Literal["nb","en"]) vei inn i
routeren, men faller trygt tilbake til "nb" her også hvis den skulle
komme fra et annet kall uten den garantien.
"""
subject = "Logg inn på TeeCup"
body = (
"Hei,\n\n"
f"Bruk denne koden for å logge inn på TeeCup innen "
f"{settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter:\n\n"
f"{raw_token}\n\n"
"Ba du ikke om dette, kan du se bort fra e-posten.\n"
)
template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
subject = template["subject"]
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token)
await to_thread(_send_sync, to_email, subject, body)

View file

@ -1,15 +1,32 @@
"""
Felles feiloversettelse: Postgres-constraint-brudd -> rene HTTP-feil.
Felles feilhåndtering: en maskinlesbar `code` ALLE HTTP-feil, pluss
oversettelse av Postgres-constraint-brudd -> rene HTTP-feil.
Mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg tvers av routerne
(lag-navn, roster, sesjons-rekkefølge, lås). Meldingene er generiske og lekker
aldri Postgres-tekst (constraint-/tabellnavn) til klienten.
Responsform tvers av HELE API-et: {"detail": {"code": "...", "message": "..."}}.
Ingen egen exception-klasse, ingen global handler -- FastAPI støtter allerede
at `detail` er et JSON-serialiserbart objekt, ikke bare en streng,
`app_error()` er bare en liten factory brukt konsekvent alle ~39 steder
som kaster HTTPException i dette API-et.
Kodene er gjenbrukt der samme forretningsbetydning gjentar seg (f.eks.
NOT_FOUND tvers av mange ressurstyper) -- IKKE én unik kode per kastested,
det ville vært for finkornet til at en frontend kan bygge stabil logikk
det. Se plan-loggen for hele taksonomien.
Postgres-constraint-mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg
tvers av routerne (lag-navn, roster, sesjons-rekkefølge, lås). Meldingene
er generiske og lekker aldri Postgres-tekst (constraint-/tabellnavn) til
klienten.
"""
from contextlib import asynccontextmanager
import asyncpg
from fastapi import HTTPException, status
from fastapi import HTTPException
def app_error(status_code: int, code: str, message: str) -> HTTPException:
return HTTPException(status_code=status_code, detail={"code": code, "message": message})
# async (ikke bare @contextmanager) slik at den kan stå i samme
@ -22,17 +39,10 @@ async def translate_db_errors():
try:
yield
except asyncpg.UniqueViolationError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Raden finnes allerede (dupliserer noe som skal være unikt).",
raise app_error(
409, "DUPLICATE", "Raden finnes allerede (dupliserer noe som skal være unikt)."
) from e
except asyncpg.ForeignKeyViolationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Refererer til noe som ikke finnes.",
) from e
raise app_error(400, "INVALID_REFERENCE", "Refererer til noe som ikke finnes.") from e
except asyncpg.CheckViolationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verdien bryter en regel for feltet.",
) from e
raise app_error(400, "VALIDATION_FAILED", "Verdien bryter en regel for feltet.") from e

View file

@ -17,20 +17,28 @@ Sikkerhetsmønster (se plan/ADR-009):
eller ingen av delene konfigurert) logges server-side og lekker ALDRI til
klienten som en annen respons -- det ville brutt enumereringsvernet over
(klienten skal ikke kunne skille "finnes ikke" fra "utsending feilet").
i18n (se plan): `locale` sendes av klienten ved forespørsel (frontend vet sitt
eget visningsspråk), lagres token-raden, og brukes BÅDE til å velge språk
selve e-posten OG til å sette en HELT NY brukers `preferred_locale`. En
EKSISTERENDE bruker som ber om en ny lenke får IKKE sin lagrede preferanse
overskrevet -- kun e-posten sendes forespørselens språk.
"""
import hashlib
import secrets
import traceback
from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel, EmailStr
from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies
from ..config import settings
from ..db import org_connection, plain_connection
from ..email import send_magic_link_email
from ..errors import app_error
router = APIRouter(prefix="/auth", tags=["auth"])
@ -46,6 +54,7 @@ def _hash_token(token: str) -> str:
class MagicLinkRequest(BaseModel):
email: EmailStr
locale: Literal["nb", "en"] = "nb"
@router.post("/request-link")
@ -75,16 +84,17 @@ async def request_magic_link(body: MagicLinkRequest) -> dict:
raw_token = secrets.token_urlsafe(32)
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at) VALUES ($1, $2, $3)",
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, $4)",
email,
_hash_token(raw_token),
expires_at,
body.locale,
)
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Magic link for {email}: {raw_token}", flush=True)
print(f"[DEV] Magic link for {email} ({body.locale}): {raw_token}", flush=True)
elif settings.SMTP_CONFIGURED:
try:
await send_magic_link_email(email, raw_token)
await send_magic_link_email(email, raw_token, body.locale)
except Exception:
# Se modul-docstring: driftsfeil skal ALDRI endre klientens
# respons. Token-raden består og utløper naturlig hvis den
@ -109,6 +119,7 @@ class SessionUser(BaseModel):
id: str
email: str
display_name: str
preferred_locale: str
@router.post("/verify-link", response_model=SessionUser)
@ -119,37 +130,42 @@ async def verify_magic_link(
async with plain_connection() as conn:
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
# samtidige forsøk med samme lenke kan da ikke begge lykkes.
email = await conn.fetchval(
token_row = await conn.fetchrow(
"""
UPDATE magic_link_token
SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
RETURNING email
RETURNING email, locale
""",
token_hash,
)
if email is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail="Lenken er ugyldig, brukt eller utløpt."
)
if token_row is None:
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
email = token_row["email"]
placeholder_name = email.split("@")[0]
user_id = await conn.fetchval(
"""
INSERT INTO app_user (email, display_name)
VALUES ($1, $2)
INSERT INTO app_user (email, display_name, preferred_locale)
VALUES ($1, $2, $3)
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
RETURNING id
""",
email,
body.display_name or placeholder_name,
token_row["locale"],
)
if user_id is None:
# En annen samtidig verifisering for samme e-post vant innsettingen.
# En annen samtidig verifisering for samme e-post vant innsettingen
# (eller brukeren fantes allerede) -- preferred_locale røres IKKE
# her, kun ved førstegangsopprettelse (se modul-docstring).
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
user_row = await conn.fetchrow(
"SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1",
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale
FROM app_user WHERE id = $1
""",
user_id,
)
@ -182,6 +198,7 @@ class Me(BaseModel):
id: str
email: str
display_name: str
preferred_locale: str
organizations: list[MyOrg]
@ -199,7 +216,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
# er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1",
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale
FROM app_user WHERE id = $1
""",
user.user_id,
)
membership_rows = await conn.fetch(
@ -219,5 +239,6 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
preferred_locale=user_row["preferred_locale"],
organizations=organizations,
)

View file

@ -11,23 +11,42 @@ Synlighet (ADR-013): motstanderens deltakere er skjult i app-laget (ikke RLS —
begge lag er i samme organisasjon) til BEGGE lag har låst for økten. Filteret
er lagt i SQL (WHERE ... AND ($revealed OR team_id = ANY($egne_lag))), ikke et
etterfølgende Python-filter, slik at lekkasje er strukturelt umulig.
Klokkeslett (se plan): match.tee_time er UTLEDET, ikke lagret -- session sin
scheduled_at + (sequence-1) * tee_interval_minutes, med en valgfri
tee_time_override som vinner hvis satt. Beregnes i Python i både create_match
og list_matches, som allerede henter økten for andre formål.
"""
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_team_ids, own_team_ids
from ..db import org_connection
from ..errors import translate_db_errors
from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from ..team_authz import user_may_act_for_team
router = APIRouter()
def _compute_tee_time(
scheduled_at: datetime | None,
tee_interval_minutes: int | None,
sequence: int,
override: datetime | None,
) -> datetime | None:
if override is not None:
return override
if scheduled_at is None or tee_interval_minutes is None:
return None
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
class MatchParticipantOut(BaseModel):
id: str
team_side: str
@ -51,6 +70,7 @@ class MatchOut(BaseModel):
status_text: str | None
points_side_a: float | None
points_side_b: float | None
tee_time: datetime | None
participants: list[MatchParticipantOut]
@ -66,10 +86,14 @@ async def create_match(
) -> MatchOut:
async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id FROM session WHERE id = $1", session_id
"""
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
FROM session WHERE id = $1
""",
session_id,
)
if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
# Matchens FK garanterer bare at lagene er gyldige lag i ORG-en, ikke i
# DENNE turneringen — sjekk det eksplisitt her. Stoler på DB CHECK
@ -80,10 +104,7 @@ async def create_match(
[body.team_a_id, body.team_b_id],
)
if team_count != 2:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Begge lagene må tilhøre øktens turnering.",
)
raise app_error(400, "VALIDATION_FAILED", "Begge lagene må tilhøre øktens turnering.")
row = await conn.fetchrow(
"""
@ -91,7 +112,7 @@ async def create_match(
VALUES ($1, $2, $3, $4, $5)
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b
points_side_b::float AS points_side_b, tee_time_override
""",
organization_id,
session_id,
@ -99,7 +120,10 @@ async def create_match(
body.team_a_id,
body.team_b_id,
)
return MatchOut(**dict(row), participants=[])
tee_time = _compute_tee_time(
session["scheduled_at"], session["tee_interval_minutes"], row["sequence"], row["tee_time_override"]
)
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
@router.get(
@ -113,16 +137,20 @@ async def list_matches(
) -> list[MatchOut]:
async with org_connection(organization_id) as conn:
session = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id FROM session WHERE id = $1", session_id
"""
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
FROM session WHERE id = $1
""",
session_id,
)
if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
matches = await conn.fetch(
"""
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b
points_side_b::float AS points_side_b, tee_time_override
FROM match
WHERE session_id = $1
ORDER BY sequence
@ -175,6 +203,9 @@ async def list_matches(
status_text=m["status_text"],
points_side_a=m["points_side_a"],
points_side_b=m["points_side_b"],
tee_time=_compute_tee_time(
session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"]
),
participants=by_match[m["id"]],
)
for m in matches
@ -208,7 +239,7 @@ async def add_participant(
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"]
@ -216,11 +247,10 @@ async def add_participant(
"SELECT team_id::text FROM team_roster WHERE id = $1", body.team_roster_id
)
if roster_team_id is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Roster-oppføringen finnes ikke.")
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
if roster_team_id != expected_team_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="team_roster_id tilhører ikke laget på angitt side i denne matchen.",
raise app_error(
400, "MISMATCHED_SIDE", "team_roster_id tilhører ikke laget på angitt side i denne matchen."
)
session = await conn.fetchrow(
@ -235,20 +265,15 @@ async def add_participant(
"SELECT course_id::text FROM tee WHERE id = $1", body.tee_id
)
if tee_course_id is None or tee_course_id != session["course_id"]:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane."
)
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.")
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
)
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
locked = await locked_team_ids(conn, match["session_id"])
if expected_team_id in locked:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Laget har allerede låst oppstillingen for denne økten.",
raise app_error(
409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten."
)
row = await conn.fetchrow(
@ -311,12 +336,10 @@ async def lock_lineup(
async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id)
if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
if not await user_may_act_for_team(conn, body.team_id, user.user_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
)
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
# UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved
# dobbel-lås — ingen manuell sjekk nødvendig.

View file

@ -31,14 +31,14 @@ forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.
import json
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection
from ..errors import translate_db_errors
from ..errors import app_error, translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..team_authz import user_may_act_for_team
@ -226,34 +226,32 @@ async def submit_hole_score(
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Matchen er avgjort og kan ikke lenger endres.",
)
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if match["scoring_mode"] != "stroke":
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
raise app_error(
400,
"WRONG_SCORING_MODE",
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
)
played = _played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
)
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
is_individual = match["format"] in _INDIVIDUAL_FORMATS
if is_individual and body.match_participant_id is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Dette formatet krever match_participant_id (individuell ball).",
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"Dette formatet krever match_participant_id (individuell ball).",
)
if not is_individual and body.match_participant_id is not None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
)
expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"]
@ -268,16 +266,14 @@ async def submit_hole_score(
match_id,
)
if participant is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail="Deltakeren finnes ikke i denne matchen."
)
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.")
if participant["team_side"] != body.team_side:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="match_participant_id tilhører ikke angitt side."
raise app_error(
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
)
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget.")
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
if body.match_participant_id is not None:
row = await conn.fetchrow(
@ -352,23 +348,19 @@ async def submit_hole_result(
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Matchen er avgjort og kan ikke lenger endres.",
)
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if match["scoring_mode"] != "hole_result":
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
raise app_error(
400,
"WRONG_SCORING_MODE",
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
)
played = _played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
)
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
# Begge lag kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene.
@ -376,8 +368,8 @@ async def submit_hole_result(
await user_may_act_for_team(conn, match["team_a_id"], user.user_id)
or await user_may_act_for_team(conn, match["team_b_id"], user.user_id)
):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på noen av lagene i denne matchen."
raise app_error(
403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på noen av lagene i denne matchen."
)
row = await conn.fetchrow(
@ -435,7 +427,7 @@ async def get_scorecard(
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
results = await _compute_hole_results(conn, match_id, match)
played = _played_hole_numbers(match["hole_config"])

View file

@ -8,30 +8,35 @@ rostret på et lag som ikke finnes ennå).
"""
import json
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from ..auth import get_authorized_org
from ..blind_draw import locked_team_ids
from ..db import org_connection
from ..errors import translate_db_errors
from ..errors import app_error, translate_db_errors
router = APIRouter()
# ---------------------------------------------------------------------------
# Turneringer (flyttet uendret fra tidligere main.py)
# Turneringer
# ---------------------------------------------------------------------------
class TournamentCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
start_date: date | None = None
end_date: date | None = None
class Tournament(BaseModel):
id: str
name: str
status: str
start_date: date | None
end_date: date | None
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
@ -40,9 +45,12 @@ async def list_tournaments(
) -> list[Tournament]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"SELECT id::text, name, status::text FROM tournament ORDER BY created_at DESC"
"""
SELECT id::text, name, status::text, start_date, end_date
FROM tournament ORDER BY created_at DESC
"""
)
return [Tournament(id=r["id"], name=r["name"], status=r["status"]) for r in rows]
return [Tournament(**dict(r)) for r in rows]
@router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
@ -50,17 +58,19 @@ async def create_tournament(
body: TournamentCreate,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
async with org_connection(organization_id) as conn:
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
"""
INSERT INTO tournament (organization_id, name)
VALUES ($1, $2)
RETURNING id::text, name, status::text
INSERT INTO tournament (organization_id, name, start_date, end_date)
VALUES ($1, $2, $3, $4)
RETURNING id::text, name, status::text, start_date, end_date
""",
organization_id,
body.name,
body.start_date,
body.end_date,
)
return Tournament(id=row["id"], name=row["name"], status=row["status"])
return Tournament(**dict(row))
# ---------------------------------------------------------------------------
@ -111,15 +121,14 @@ async def create_team(
organization_id,
)
if locked is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Turneringen finnes ikke.")
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
existing = await conn.fetchval(
"SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id
)
if existing >= 2:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Turneringen har allerede to lag (ADR-011: nøyaktig to lag).",
raise app_error(
409, "LIMIT_REACHED", "Turneringen har allerede to lag (ADR-011: nøyaktig to lag)."
)
row = await conn.fetchrow(
@ -189,7 +198,7 @@ async def add_roster_entry(
async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id)
if team is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Laget finnes ikke.")
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
# Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)).
# Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme
@ -209,9 +218,10 @@ async def add_roster_entry(
team_id,
)
if already_elsewhere:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Spilleren er allerede rostret på det andre laget i denne turneringen.",
raise app_error(
409,
"DUPLICATE",
"Spilleren er allerede rostret på det andre laget i denne turneringen.",
)
snapshot = body.handicap_index_snapshot
@ -257,6 +267,11 @@ class SessionCreate(BaseModel):
# ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
# (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
scoring_mode: str = "stroke"
# Klokkeslett-modell (se plan): én starttid + ett intervall per økt.
# match.tee_time utledes av disse i matches.py, IKKE lagret per match.
scheduled_at: datetime | None = None
tee_interval_minutes: int | None = None
start_hole: int = 1
class SessionOut(BaseModel):
@ -269,6 +284,9 @@ class SessionOut(BaseModel):
points_per_match: float
allowance_override: dict | None
scoring_mode: str
scheduled_at: datetime | None
tee_interval_minutes: int | None
start_hole: int
locked_team_ids: list[str]
revealed: bool
@ -284,6 +302,9 @@ def _session_out(row, locked: set[str]) -> SessionOut:
points_per_match=row["points_per_match"],
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
scoring_mode=row["scoring_mode"],
scheduled_at=row["scheduled_at"],
tee_interval_minutes=row["tee_interval_minutes"],
start_hole=row["start_hole"],
locked_team_ids=sorted(locked),
revealed=len(locked) >= 2,
)
@ -302,7 +323,8 @@ async def list_sessions(
"""
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole
FROM session
WHERE tournament_id = $1
ORDER BY sequence
@ -332,11 +354,13 @@ async def create_session(
"""
INSERT INTO session
(organization_id, tournament_id, sequence, name, format,
hole_config, course_id, points_per_match, allowance_override, scoring_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13)
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole
""",
organization_id,
tournament_id,
@ -348,5 +372,8 @@ async def create_session(
body.points_per_match,
allowance_json,
body.scoring_mode,
body.scheduled_at,
body.tee_interval_minutes,
body.start_hole,
)
return _session_out(row, set())

View file

@ -3,7 +3,11 @@ services:
build: .
container_name: teecup_api
environment:
TEECUP_DATABASE_URL: ${TEECUP_DATABASE_URL}
TEECUP_DB_HOST: ${TEECUP_DB_HOST}
TEECUP_DB_PORT: ${TEECUP_DB_PORT}
TEECUP_DB_NAME: ${TEECUP_DB_NAME}
TEECUP_DB_USER: ${TEECUP_DB_USER}
TEECUP_DB_PASS: ${TEECUP_DB_PASS}
TEECUP_SESSION_SECRET: ${TEECUP_SESSION_SECRET}
TEECUP_SMTP_SERVER: ${TEECUP_SMTP_SERVER}
TEECUP_SMTP_PORT: ${TEECUP_SMTP_PORT}

15
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
# v0 sandbox internal files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
.snowflake/
.v0-trash/
.vercel/
# Environment variables
.env*.local
# Common ignores
node_modules
.next/
.DS_Store

172
frontend/app/globals.css Normal file
View file

@ -0,0 +1,172 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: var(--font-nunito), ui-sans-serif, system-ui, sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-brand-orange: var(--brand-orange);
--color-brand-orange-foreground: var(--brand-orange-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
color-scheme: light;
--background: oklch(0.985 0.005 130);
--foreground: oklch(0.24 0.02 145);
--card: oklch(1 0 0);
--card-foreground: oklch(0.24 0.02 145);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.24 0.02 145);
--primary: oklch(0.7512 0.1613 130.33);
--primary-foreground: oklch(0.22 0.04 140);
--secondary: oklch(0.96 0.02 130);
--secondary-foreground: oklch(0.3 0.03 140);
--muted: oklch(0.96 0.01 130);
--muted-foreground: oklch(0.52 0.02 140);
--accent: oklch(0.94 0.05 130);
--accent-foreground: oklch(0.3 0.04 140);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.9 0.015 130);
--input: oklch(0.9 0.015 130);
--ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53);
--brand-orange-foreground: oklch(0.99 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
color-scheme: dark;
--background: oklch(0.19 0.02 150);
--foreground: oklch(0.96 0.01 130);
--card: oklch(0.24 0.02 150);
--card-foreground: oklch(0.96 0.01 130);
--popover: oklch(0.24 0.02 150);
--popover-foreground: oklch(0.96 0.01 130);
--primary: oklch(0.7512 0.1613 130.33);
--primary-foreground: oklch(0.2 0.04 140);
--secondary: oklch(0.28 0.02 150);
--secondary-foreground: oklch(0.96 0.01 130);
--muted: oklch(0.28 0.02 150);
--muted-foreground: oklch(0.7 0.02 135);
--accent: oklch(0.32 0.04 140);
--accent-foreground: oklch(0.96 0.01 130);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53);
--brand-orange-foreground: oklch(0.16 0.02 40);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@media (prefers-color-scheme: dark) {
:root:not(.light) {
color-scheme: dark;
--background: oklch(0.19 0.02 150);
--foreground: oklch(0.96 0.01 130);
--card: oklch(0.24 0.02 150);
--card-foreground: oklch(0.96 0.01 130);
--popover: oklch(0.24 0.02 150);
--popover-foreground: oklch(0.96 0.01 130);
--primary: oklch(0.7512 0.1613 130.33);
--primary-foreground: oklch(0.2 0.04 140);
--secondary: oklch(0.28 0.02 150);
--secondary-foreground: oklch(0.96 0.01 130);
--muted: oklch(0.28 0.02 150);
--muted-foreground: oklch(0.7 0.02 135);
--accent: oklch(0.32 0.04 140);
--accent-foreground: oklch(0.96 0.01 130);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53);
--brand-orange-foreground: oklch(0.16 0.02 40);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

56
frontend/app/layout.tsx Normal file
View file

@ -0,0 +1,56 @@
import { Analytics } from '@vercel/analytics/next'
import type { Metadata, Viewport } from 'next'
import { Nunito } from 'next/font/google'
import './globals.css'
const nunito = Nunito({
subsets: ['latin'],
variable: '--font-nunito',
display: 'swap',
})
export const metadata: Metadata = {
title: 'TeeCup — Logg inn',
description: 'Logg inn på TeeCup, din golfturnerings-app. Passordløs innlogging med magisk lenke.',
generator: 'v0.app',
icons: {
icon: [
{
url: '/icon-light-32x32.png',
media: '(prefers-color-scheme: light)',
},
{
url: '/icon-dark-32x32.png',
media: '(prefers-color-scheme: dark)',
},
{
url: '/icon.svg',
type: 'image/svg+xml',
},
],
apple: '/apple-icon.png',
},
}
export const viewport: Viewport = {
colorScheme: 'light dark',
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#8BC24A' },
{ media: '(prefers-color-scheme: dark)', color: '#1c261d' },
],
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="no" className={`${nunito.variable} bg-background`}>
<body className="font-sans antialiased">
{children}
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>
)
}

22
frontend/app/page.tsx Normal file
View file

@ -0,0 +1,22 @@
import { LoginForm, Wordmark } from "@/components/login-form"
export default function Page() {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
<div className="flex w-full max-w-sm flex-col gap-8">
<header className="flex flex-col items-center gap-3 text-center">
<Wordmark />
<p className="text-balance text-base leading-relaxed text-muted-foreground">
Logg inn for å følge turneringen din live.
</p>
</header>
<LoginForm />
<p className="text-center text-xs leading-relaxed text-muted-foreground text-pretty">
Ved å logge inn godtar du våre vilkår og personvernregler.
</p>
</div>
</main>
)
}

21
frontend/components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -0,0 +1,202 @@
"use client"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { Flag, Mail, ArrowLeft, CheckCircle2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const RESEND_COOLDOWN = 30 // seconds
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
export function LoginForm() {
const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false)
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const [cooldown, setCooldown] = useState(0)
const emailValid = isValidEmail(email)
const showError = touched && email.length > 0 && !emailValid
// Countdown timer for the resend cooldown.
useEffect(() => {
if (cooldown <= 0) return
const id = setInterval(() => {
setCooldown((c) => (c <= 1 ? 0 : c - 1))
}, 1000)
return () => clearInterval(id)
}, [cooldown])
async function sendLink() {
// Mock only — pretend to dispatch a magic link.
setSending(true)
await new Promise((r) => setTimeout(r, 700))
setSending(false)
setSent(true)
setCooldown(RESEND_COOLDOWN)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setTouched(true)
if (!emailValid || sending) return
void sendLink()
}
function handleResend() {
if (cooldown > 0 || sending) return
void sendLink()
}
function handleReset() {
setSent(false)
setTouched(false)
}
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
{sent ? (
<ConfirmationState
email={email}
cooldown={cooldown}
sending={sending}
onResend={handleResend}
onReset={handleReset}
/>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-sm font-semibold">
E-post
</Label>
<div className="relative">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="email"
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@epost.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={showError}
aria-describedby={showError ? "email-error" : undefined}
className="h-14 rounded-2xl pl-11 text-base"
/>
</div>
{showError && (
<p id="email-error" className="text-sm font-medium text-destructive">
Skriv inn en gyldig e-postadresse.
</p>
)}
</div>
<Button
type="submit"
disabled={sending}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{sending ? "Sender …" : "Send innloggingslenke"}
</Button>
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen passord. Vi sender deg en sikker lenke e-post.
</p>
</form>
)}
</div>
)
}
function ConfirmationState({
email,
cooldown,
sending,
onResend,
onReset,
}: {
email: string
cooldown: number
sending: boolean
onResend: () => void
onReset: () => void
}) {
const liveRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
liveRef.current?.focus()
}, [])
return (
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
</div>
<div className="flex flex-col gap-2">
<h2
ref={liveRef}
tabIndex={-1}
className="text-2xl font-extrabold tracking-tight outline-none text-balance"
>
Sjekk innboksen din
</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Vi har sendt en innloggingslenke til{" "}
<span className="font-semibold text-foreground">{email}</span>. Åpne den denne enheten for å logge inn.
</p>
</div>
<div className="mt-1 w-full">
{cooldown > 0 ? (
<p className="text-sm text-muted-foreground" aria-live="polite">
Fikk du ingen e-post? Send nytt om{" "}
<span className="font-semibold text-foreground tabular-nums">{cooldown}s</span>
</p>
) : (
<Button
type="button"
variant="ghost"
onClick={onResend}
disabled={sending}
className="h-11 rounded-xl font-semibold text-primary hover:bg-primary/10 hover:text-primary"
>
{sending ? "Sender …" : "Send lenken på nytt"}
</Button>
)}
</div>
<button
type="button"
onClick={onReset}
className="mt-1 inline-flex items-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Bruk en annen e-post
</button>
</div>
)
}
export function Wordmark() {
return (
<div className="flex flex-col items-center gap-3">
<div className="relative flex size-16 items-center justify-center rounded-2xl bg-primary shadow-md shadow-primary/30">
<Flag aria-hidden="true" className="size-8 text-brand-orange" fill="currentColor" />
</div>
<h1 className="text-3xl font-extrabold tracking-tight text-foreground">
Tee<span className="text-primary">Cup</span>
</h1>
</div>
)
}

View file

@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

6
frontend/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

11
frontend/next.config.mjs Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig

38
frontend/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "my-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint ."
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@vercel/analytics": "1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"react": "^19",
"react-dom": "^19",
"shadcn": "^4.8.0",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.3.3",
"typescript": "5.7.3"
},
"pnpm": {
"overrides": {
"hono": "4.12.25"
}
}
}

3901
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

26
frontend/public/icon.svg Normal file
View file

@ -0,0 +1,26 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
@media (prefers-color-scheme: light) {
.background { fill: black; }
.foreground { fill: white; }
}
@media (prefers-color-scheme: dark) {
.background { fill: white; }
.foreground { fill: black; }
}
</style>
<g clip-path="url(#clip0_7960_43945)">
<rect class="background" width="180" height="180" rx="37" />
<g style="transform: scale(95%); transform-origin: center">
<path class="foreground"
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
<path class="foreground"
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
</g>
</g>
<defs>
<clipPath id="clip0_7960_43945">
<rect width="180" height="180" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

41
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}