Før kommunikasjon med V0
|
|
@ -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(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(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/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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
"/tmp"
|
"/tmp",
|
||||||
|
"/opt/teecup"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
006_scheduling_and_locale.sql
Normal 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'));
|
||||||
|
|
@ -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å)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
71
CLAUDE.md
|
|
@ -3,7 +3,7 @@
|
||||||
Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
|
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)
|
## 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.
|
- `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
|
- Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge
|
||||||
filene oppdatert når noe avgjøres.
|
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 skal tas i bruk; (c) et `#`-tegn i et upassordet `.env`-passord kuttes
|
||||||
som en kommentar av Compose sin parser — anførselstegn (fortrinnsvis enkle)
|
som en kommentar av Compose sin parser — anførselstegn (fortrinnsvis enkle)
|
||||||
løser dette.
|
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:
|
Neste steg:
|
||||||
1. Frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, siden
|
1. Frontend (PWA, offline-first) og kommunikasjon. Frontend er fortsatt IKKE
|
||||||
004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE
|
|
||||||
startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — helt frem til
|
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
|
nå har API-et vært nåbart, men uten noe grensesnitt en sluttbruker kan
|
||||||
bruke.
|
bruke.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
|
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
|
||||||
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
|
> · 🔀 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. |
|
| 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. |
|
| 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. |
|
| 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
|
||||||
|
nå `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
16
app/auth.py
|
|
@ -20,10 +20,11 @@ import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, Request
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import plain_connection
|
from .db import plain_connection
|
||||||
|
from .errors import app_error
|
||||||
|
|
||||||
SESSION_COOKIE_NAME = "teecup_session"
|
SESSION_COOKIE_NAME = "teecup_session"
|
||||||
_JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer
|
_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)
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ikke innlogget.")
|
raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.")
|
||||||
try:
|
try:
|
||||||
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
|
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
|
||||||
except jwt.PyJWTError:
|
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")
|
user_id = claims.get("sub")
|
||||||
if not user_id:
|
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:
|
async with plain_connection() as conn:
|
||||||
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
|
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
|
||||||
if not exists:
|
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)
|
return CurrentUser(user_id=user_id)
|
||||||
|
|
||||||
|
|
@ -101,8 +102,5 @@ async def get_authorized_org(
|
||||||
organization_id,
|
organization_id,
|
||||||
)
|
)
|
||||||
if not is_member:
|
if not is_member:
|
||||||
raise HTTPException(
|
raise app_error(403, "NOT_ORG_MEMBER", "Brukeren er ikke medlem av denne organisasjonen.")
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Brukeren er ikke medlem av denne organisasjonen.",
|
|
||||||
)
|
|
||||||
return organization_id
|
return organization_id
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,15 @@ def _required(name: str) -> str:
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
# Peker på teecup_db, og bruker RUNTIME-rollen teecup_app (ikke teeoff_admin).
|
# Peker på teecup_db, og bruker RUNTIME-rollen teecup_app (ikke teeoff_admin).
|
||||||
# Format: postgresql://teecup_app:<passord>@<host>:<port>/teecup_db
|
# Fem separate felt (ikke én sammensatt DSN-streng) -- en URL som bærer
|
||||||
DATABASE_URL: str = _required("TEECUP_DATABASE_URL")
|
# 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.
|
# Egen sesjons-secret, uavhengig av teeoff.
|
||||||
SESSION_SECRET: str = _required("TEECUP_SESSION_SECRET")
|
SESSION_SECRET: str = _required("TEECUP_SESSION_SECRET")
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ _pool: asyncpg.Pool | None = None
|
||||||
async def init_pool() -> None:
|
async def init_pool() -> None:
|
||||||
global _pool
|
global _pool
|
||||||
_pool = await asyncpg.create_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,
|
min_size=settings.DB_POOL_MIN,
|
||||||
max_size=settings.DB_POOL_MAX,
|
max_size=settings.DB_POOL_MAX,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
40
app/email.py
|
|
@ -37,18 +37,38 @@ def _send_sync(to_email: str, subject: str, body: str) -> None:
|
||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
async def send_magic_link_email(to_email: str, raw_token: str) -> None:
|
_TEMPLATES = {
|
||||||
"""Sender selve magic-link-tokenet som klartekst.
|
"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
|
MERK: ingen klikkbar lenke ennå -- TeeCup har ikke noe frontend å peke
|
||||||
på (se plan). Når frontend bygges, oppdater malen til en ekte URL.
|
på (se plan). Når frontend bygges, oppdater malen til en ekte URL.
|
||||||
|
`locale` valideres allerede av Pydantic (Literal["nb","en"]) på 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"
|
template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
|
||||||
body = (
|
subject = template["subject"]
|
||||||
"Hei,\n\n"
|
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token)
|
||||||
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"
|
|
||||||
)
|
|
||||||
await to_thread(_send_sync, to_email, subject, body)
|
await to_thread(_send_sync, to_email, subject, body)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,32 @@
|
||||||
"""
|
"""
|
||||||
Felles feiloversettelse: Postgres-constraint-brudd -> rene HTTP-feil.
|
Felles feilhåndtering: en maskinlesbar `code` på ALLE HTTP-feil, pluss
|
||||||
|
oversettelse av Postgres-constraint-brudd -> rene HTTP-feil.
|
||||||
|
|
||||||
Mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg på tvers av routerne
|
Responsform på tvers av HELE API-et: {"detail": {"code": "...", "message": "..."}}.
|
||||||
(lag-navn, roster, sesjons-rekkefølge, lås). Meldingene er generiske og lekker
|
Ingen egen exception-klasse, ingen global handler -- FastAPI støtter allerede
|
||||||
aldri rå Postgres-tekst (constraint-/tabellnavn) til klienten.
|
at `detail` er et JSON-serialiserbart objekt, ikke bare en streng, så
|
||||||
|
`app_error()` er bare en liten factory brukt konsekvent på alle ~39 steder
|
||||||
|
som kaster HTTPException i dette API-et.
|
||||||
|
|
||||||
|
Kodene er gjenbrukt der samme forretningsbetydning gjentar seg (f.eks.
|
||||||
|
NOT_FOUND på tvers av mange ressurstyper) -- IKKE én unik kode per kastested,
|
||||||
|
det ville vært for finkornet til at en frontend kan bygge stabil logikk på
|
||||||
|
det. Se plan-loggen for hele taksonomien.
|
||||||
|
|
||||||
|
Postgres-constraint-mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg
|
||||||
|
på tvers av routerne (lag-navn, roster, sesjons-rekkefølge, lås). Meldingene
|
||||||
|
er generiske og lekker aldri rå Postgres-tekst (constraint-/tabellnavn) til
|
||||||
|
klienten.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import asyncpg
|
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
|
# async (ikke bare @contextmanager) slik at den kan stå i samme
|
||||||
|
|
@ -22,17 +39,10 @@ async def translate_db_errors():
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
except asyncpg.UniqueViolationError as e:
|
except asyncpg.UniqueViolationError as e:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
409, "DUPLICATE", "Raden finnes allerede (dupliserer noe som skal være unikt)."
|
||||||
detail="Raden finnes allerede (dupliserer noe som skal være unikt).",
|
|
||||||
) from e
|
) from e
|
||||||
except asyncpg.ForeignKeyViolationError as e:
|
except asyncpg.ForeignKeyViolationError as e:
|
||||||
raise HTTPException(
|
raise app_error(400, "INVALID_REFERENCE", "Refererer til noe som ikke finnes.") from e
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Refererer til noe som ikke finnes.",
|
|
||||||
) from e
|
|
||||||
except asyncpg.CheckViolationError as e:
|
except asyncpg.CheckViolationError as e:
|
||||||
raise HTTPException(
|
raise app_error(400, "VALIDATION_FAILED", "Verdien bryter en regel for feltet.") from e
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Verdien bryter en regel for feltet.",
|
|
||||||
) from e
|
|
||||||
|
|
|
||||||
|
|
@ -17,20 +17,28 @@ Sikkerhetsmønster (se plan/ADR-009):
|
||||||
eller ingen av delene konfigurert) logges server-side og lekker ALDRI til
|
eller ingen av delene konfigurert) logges server-side og lekker ALDRI til
|
||||||
klienten som en annen respons -- det ville brutt enumereringsvernet over
|
klienten som en annen respons -- det ville brutt enumereringsvernet over
|
||||||
(klienten skal ikke kunne skille "finnes ikke" fra "utsending feilet").
|
(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 på token-raden, og brukes BÅDE til å velge språk
|
||||||
|
på 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 på forespørselens språk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timedelta, timezone
|
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 pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies
|
from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..db import org_connection, plain_connection
|
from ..db import org_connection, plain_connection
|
||||||
from ..email import send_magic_link_email
|
from ..email import send_magic_link_email
|
||||||
|
from ..errors import app_error
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -46,6 +54,7 @@ def _hash_token(token: str) -> str:
|
||||||
|
|
||||||
class MagicLinkRequest(BaseModel):
|
class MagicLinkRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
locale: Literal["nb", "en"] = "nb"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request-link")
|
@router.post("/request-link")
|
||||||
|
|
@ -75,16 +84,17 @@ async def request_magic_link(body: MagicLinkRequest) -> dict:
|
||||||
raw_token = secrets.token_urlsafe(32)
|
raw_token = secrets.token_urlsafe(32)
|
||||||
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
|
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
|
||||||
await conn.execute(
|
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,
|
email,
|
||||||
_hash_token(raw_token),
|
_hash_token(raw_token),
|
||||||
expires_at,
|
expires_at,
|
||||||
|
body.locale,
|
||||||
)
|
)
|
||||||
if settings.DEV_LOG_MAGIC_LINKS:
|
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:
|
elif settings.SMTP_CONFIGURED:
|
||||||
try:
|
try:
|
||||||
await send_magic_link_email(email, raw_token)
|
await send_magic_link_email(email, raw_token, body.locale)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Se modul-docstring: driftsfeil skal ALDRI endre klientens
|
# Se modul-docstring: driftsfeil skal ALDRI endre klientens
|
||||||
# respons. Token-raden består og utløper naturlig hvis den
|
# respons. Token-raden består og utløper naturlig hvis den
|
||||||
|
|
@ -109,6 +119,7 @@ class SessionUser(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
display_name: str
|
display_name: str
|
||||||
|
preferred_locale: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-link", response_model=SessionUser)
|
@router.post("/verify-link", response_model=SessionUser)
|
||||||
|
|
@ -119,37 +130,42 @@ async def verify_magic_link(
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
|
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
|
||||||
# samtidige forsøk med samme lenke kan da ikke begge lykkes.
|
# samtidige forsøk med samme lenke kan da ikke begge lykkes.
|
||||||
email = await conn.fetchval(
|
token_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
UPDATE magic_link_token
|
UPDATE magic_link_token
|
||||||
SET consumed_at = now()
|
SET consumed_at = now()
|
||||||
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
|
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
|
||||||
RETURNING email
|
RETURNING email, locale
|
||||||
""",
|
""",
|
||||||
token_hash,
|
token_hash,
|
||||||
)
|
)
|
||||||
if email is None:
|
if token_row is None:
|
||||||
raise HTTPException(
|
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
||||||
status.HTTP_401_UNAUTHORIZED, detail="Lenken er ugyldig, brukt eller utløpt."
|
email = token_row["email"]
|
||||||
)
|
|
||||||
|
|
||||||
placeholder_name = email.split("@")[0]
|
placeholder_name = email.split("@")[0]
|
||||||
user_id = await conn.fetchval(
|
user_id = await conn.fetchval(
|
||||||
"""
|
"""
|
||||||
INSERT INTO app_user (email, display_name)
|
INSERT INTO app_user (email, display_name, preferred_locale)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
|
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
email,
|
email,
|
||||||
body.display_name or placeholder_name,
|
body.display_name or placeholder_name,
|
||||||
|
token_row["locale"],
|
||||||
)
|
)
|
||||||
if user_id is None:
|
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_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
||||||
|
|
||||||
user_row = await conn.fetchrow(
|
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,
|
user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -182,6 +198,7 @@ class Me(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
display_name: str
|
display_name: str
|
||||||
|
preferred_locale: str
|
||||||
organizations: list[MyOrg]
|
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.
|
# er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
user_row = await conn.fetchrow(
|
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,
|
user.user_id,
|
||||||
)
|
)
|
||||||
membership_rows = await conn.fetch(
|
membership_rows = await conn.fetch(
|
||||||
|
|
@ -219,5 +239,6 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
id=user_row["id"],
|
id=user_row["id"],
|
||||||
email=user_row["email"],
|
email=user_row["email"],
|
||||||
display_name=user_row["display_name"],
|
display_name=user_row["display_name"],
|
||||||
|
preferred_locale=user_row["preferred_locale"],
|
||||||
organizations=organizations,
|
organizations=organizations,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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.
|
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
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..blind_draw import locked_team_ids, own_team_ids
|
from ..blind_draw import locked_team_ids, own_team_ids
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||||
from ..team_authz import user_may_act_for_team
|
from ..team_authz import user_may_act_for_team
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
class MatchParticipantOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
team_side: str
|
team_side: str
|
||||||
|
|
@ -51,6 +70,7 @@ class MatchOut(BaseModel):
|
||||||
status_text: str | None
|
status_text: str | None
|
||||||
points_side_a: float | None
|
points_side_a: float | None
|
||||||
points_side_b: float | None
|
points_side_b: float | None
|
||||||
|
tee_time: datetime | None
|
||||||
participants: list[MatchParticipantOut]
|
participants: list[MatchParticipantOut]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,10 +86,14 @@ async def create_match(
|
||||||
) -> MatchOut:
|
) -> MatchOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
session = await conn.fetchrow(
|
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:
|
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
|
# Matchens FK garanterer bare at lagene er gyldige lag i ORG-en, ikke i
|
||||||
# DENNE turneringen — sjekk det eksplisitt her. Stoler på DB CHECK
|
# 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],
|
[body.team_a_id, body.team_b_id],
|
||||||
)
|
)
|
||||||
if team_count != 2:
|
if team_count != 2:
|
||||||
raise HTTPException(
|
raise app_error(400, "VALIDATION_FAILED", "Begge lagene må tilhøre øktens turnering.")
|
||||||
status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Begge lagene må tilhøre øktens turnering.",
|
|
||||||
)
|
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
|
|
@ -91,7 +112,7 @@ async def create_match(
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
|
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
|
||||||
status_text, points_side_a::float AS points_side_a,
|
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,
|
organization_id,
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -99,7 +120,10 @@ async def create_match(
|
||||||
body.team_a_id,
|
body.team_a_id,
|
||||||
body.team_b_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(
|
@router.get(
|
||||||
|
|
@ -113,16 +137,20 @@ async def list_matches(
|
||||||
) -> list[MatchOut]:
|
) -> list[MatchOut]:
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
session = await conn.fetchrow(
|
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:
|
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(
|
matches = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
||||||
status_text, points_side_a::float AS points_side_a,
|
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
|
FROM match
|
||||||
WHERE session_id = $1
|
WHERE session_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -175,6 +203,9 @@ async def list_matches(
|
||||||
status_text=m["status_text"],
|
status_text=m["status_text"],
|
||||||
points_side_a=m["points_side_a"],
|
points_side_a=m["points_side_a"],
|
||||||
points_side_b=m["points_side_b"],
|
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"]],
|
participants=by_match[m["id"]],
|
||||||
)
|
)
|
||||||
for m in matches
|
for m in matches
|
||||||
|
|
@ -208,7 +239,7 @@ async def add_participant(
|
||||||
match_id,
|
match_id,
|
||||||
)
|
)
|
||||||
if match is None:
|
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"]
|
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
|
"SELECT team_id::text FROM team_roster WHERE id = $1", body.team_roster_id
|
||||||
)
|
)
|
||||||
if roster_team_id is None:
|
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:
|
if roster_team_id != expected_team_id:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
400, "MISMATCHED_SIDE", "team_roster_id tilhører ikke laget på angitt side i denne matchen."
|
||||||
detail="team_roster_id tilhører ikke laget på angitt side i denne matchen.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
|
|
@ -235,20 +265,15 @@ async def add_participant(
|
||||||
"SELECT course_id::text FROM tee WHERE id = $1", body.tee_id
|
"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"]:
|
if tee_course_id is None or tee_course_id != session["course_id"]:
|
||||||
raise HTTPException(
|
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.")
|
||||||
status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
|
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
|
||||||
raise HTTPException(
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||||
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
|
|
||||||
)
|
|
||||||
|
|
||||||
locked = await locked_team_ids(conn, match["session_id"])
|
locked = await locked_team_ids(conn, match["session_id"])
|
||||||
if expected_team_id in locked:
|
if expected_team_id in locked:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_409_CONFLICT,
|
409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten."
|
||||||
detail="Laget har allerede låst oppstillingen for denne økten.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
|
|
@ -311,12 +336,10 @@ async def lock_lineup(
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id)
|
session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id)
|
||||||
if session is None:
|
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):
|
if not await user_may_act_for_team(conn, body.team_id, user.user_id):
|
||||||
raise HTTPException(
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||||
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
|
|
||||||
)
|
|
||||||
|
|
||||||
# UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved
|
# UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved
|
||||||
# dobbel-lås — ingen manuell sjekk nødvendig.
|
# dobbel-lås — ingen manuell sjekk nødvendig.
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,14 @@ forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state
|
from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state
|
||||||
|
|
||||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
||||||
from ..team_authz import user_may_act_for_team
|
from ..team_authz import user_may_act_for_team
|
||||||
|
|
||||||
|
|
@ -226,34 +226,32 @@ async def submit_hole_score(
|
||||||
match_id,
|
match_id,
|
||||||
)
|
)
|
||||||
if match is None:
|
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:
|
if match["points_side_a"] is not None:
|
||||||
raise HTTPException(
|
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
|
||||||
status.HTTP_409_CONFLICT,
|
|
||||||
detail="Matchen er avgjort og kan ikke lenger endres.",
|
|
||||||
)
|
|
||||||
if match["scoring_mode"] != "stroke":
|
if match["scoring_mode"] != "stroke":
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
400,
|
||||||
detail="Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
|
"WRONG_SCORING_MODE",
|
||||||
|
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
|
||||||
)
|
)
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = _played_hole_numbers(match["hole_config"])
|
||||||
if body.hole_number not in played:
|
if body.hole_number not in played:
|
||||||
raise HTTPException(
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
||||||
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
|
|
||||||
)
|
|
||||||
|
|
||||||
is_individual = match["format"] in _INDIVIDUAL_FORMATS
|
is_individual = match["format"] in _INDIVIDUAL_FORMATS
|
||||||
if is_individual and body.match_participant_id is None:
|
if is_individual and body.match_participant_id is None:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
400,
|
||||||
detail="Dette formatet krever match_participant_id (individuell ball).",
|
"WRONG_PARTICIPANT_MODE",
|
||||||
|
"Dette formatet krever match_participant_id (individuell ball).",
|
||||||
)
|
)
|
||||||
if not is_individual and body.match_participant_id is not None:
|
if not is_individual and body.match_participant_id is not None:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
400,
|
||||||
detail="Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
|
"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"]
|
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,
|
match_id,
|
||||||
)
|
)
|
||||||
if participant is None:
|
if participant is None:
|
||||||
raise HTTPException(
|
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.")
|
||||||
status.HTTP_404_NOT_FOUND, detail="Deltakeren finnes ikke i denne matchen."
|
|
||||||
)
|
|
||||||
if participant["team_side"] != body.team_side:
|
if participant["team_side"] != body.team_side:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_400_BAD_REQUEST, detail="match_participant_id tilhører ikke angitt side."
|
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):
|
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:
|
if body.match_participant_id is not None:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
|
|
@ -352,23 +348,19 @@ async def submit_hole_result(
|
||||||
match_id,
|
match_id,
|
||||||
)
|
)
|
||||||
if match is None:
|
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:
|
if match["points_side_a"] is not None:
|
||||||
raise HTTPException(
|
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
|
||||||
status.HTTP_409_CONFLICT,
|
|
||||||
detail="Matchen er avgjort og kan ikke lenger endres.",
|
|
||||||
)
|
|
||||||
if match["scoring_mode"] != "hole_result":
|
if match["scoring_mode"] != "hole_result":
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
400,
|
||||||
detail="Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
|
"WRONG_SCORING_MODE",
|
||||||
|
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
|
||||||
)
|
)
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = _played_hole_numbers(match["hole_config"])
|
||||||
if body.hole_number not in played:
|
if body.hole_number not in played:
|
||||||
raise HTTPException(
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
||||||
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Begge lag kan rapportere et hull-resultat (hvilken som helst side kan
|
# 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.
|
# 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)
|
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)
|
or await user_may_act_for_team(conn, match["team_b_id"], user.user_id)
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på noen av lagene i denne matchen."
|
403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på noen av lagene i denne matchen."
|
||||||
)
|
)
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
|
|
@ -435,7 +427,7 @@ async def get_scorecard(
|
||||||
match_id,
|
match_id,
|
||||||
)
|
)
|
||||||
if match is None:
|
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)
|
results = await _compute_hole_results(conn, match_id, match)
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = _played_hole_numbers(match["hole_config"])
|
||||||
|
|
|
||||||
|
|
@ -8,30 +8,35 @@ rostret på et lag som ikke finnes ennå).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import get_authorized_org
|
from ..auth import get_authorized_org
|
||||||
from ..blind_draw import locked_team_ids
|
from ..blind_draw import locked_team_ids
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Turneringer (flyttet uendret fra tidligere main.py)
|
# Turneringer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TournamentCreate(BaseModel):
|
class TournamentCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class Tournament(BaseModel):
|
class Tournament(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
status: str
|
status: str
|
||||||
|
start_date: date | None
|
||||||
|
end_date: date | None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
||||||
|
|
@ -40,9 +45,12 @@ async def list_tournaments(
|
||||||
) -> list[Tournament]:
|
) -> list[Tournament]:
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
rows = await conn.fetch(
|
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)
|
@router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
|
||||||
|
|
@ -50,17 +58,19 @@ async def create_tournament(
|
||||||
body: TournamentCreate,
|
body: TournamentCreate,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
) -> Tournament:
|
) -> Tournament:
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tournament (organization_id, name)
|
INSERT INTO tournament (organization_id, name, start_date, end_date)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id::text, name, status::text
|
RETURNING id::text, name, status::text, start_date, end_date
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
body.name,
|
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,
|
organization_id,
|
||||||
)
|
)
|
||||||
if locked is None:
|
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(
|
existing = await conn.fetchval(
|
||||||
"SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id
|
"SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id
|
||||||
)
|
)
|
||||||
if existing >= 2:
|
if existing >= 2:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_409_CONFLICT,
|
409, "LIMIT_REACHED", "Turneringen har allerede to lag (ADR-011: nøyaktig to lag)."
|
||||||
detail="Turneringen har allerede to lag (ADR-011: nøyaktig to lag).",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
|
|
@ -189,7 +198,7 @@ async def add_roster_entry(
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
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)
|
team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id)
|
||||||
if team is None:
|
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)).
|
# Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)).
|
||||||
# Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme
|
# Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme
|
||||||
|
|
@ -209,9 +218,10 @@ async def add_roster_entry(
|
||||||
team_id,
|
team_id,
|
||||||
)
|
)
|
||||||
if already_elsewhere:
|
if already_elsewhere:
|
||||||
raise HTTPException(
|
raise app_error(
|
||||||
status.HTTP_409_CONFLICT,
|
409,
|
||||||
detail="Spilleren er allerede rostret på det andre laget i denne turneringen.",
|
"DUPLICATE",
|
||||||
|
"Spilleren er allerede rostret på det andre laget i denne turneringen.",
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot = body.handicap_index_snapshot
|
snapshot = body.handicap_index_snapshot
|
||||||
|
|
@ -257,6 +267,11 @@ class SessionCreate(BaseModel):
|
||||||
# ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
|
# ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
|
||||||
# (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
|
# (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
|
||||||
scoring_mode: str = "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):
|
class SessionOut(BaseModel):
|
||||||
|
|
@ -269,6 +284,9 @@ class SessionOut(BaseModel):
|
||||||
points_per_match: float
|
points_per_match: float
|
||||||
allowance_override: dict | None
|
allowance_override: dict | None
|
||||||
scoring_mode: str
|
scoring_mode: str
|
||||||
|
scheduled_at: datetime | None
|
||||||
|
tee_interval_minutes: int | None
|
||||||
|
start_hole: int
|
||||||
locked_team_ids: list[str]
|
locked_team_ids: list[str]
|
||||||
revealed: bool
|
revealed: bool
|
||||||
|
|
||||||
|
|
@ -284,6 +302,9 @@ def _session_out(row, locked: set[str]) -> SessionOut:
|
||||||
points_per_match=row["points_per_match"],
|
points_per_match=row["points_per_match"],
|
||||||
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
|
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
|
||||||
scoring_mode=row["scoring_mode"],
|
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),
|
locked_team_ids=sorted(locked),
|
||||||
revealed=len(locked) >= 2,
|
revealed=len(locked) >= 2,
|
||||||
)
|
)
|
||||||
|
|
@ -302,7 +323,8 @@ async def list_sessions(
|
||||||
"""
|
"""
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
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
|
FROM session
|
||||||
WHERE tournament_id = $1
|
WHERE tournament_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -332,11 +354,13 @@ async def create_session(
|
||||||
"""
|
"""
|
||||||
INSERT INTO session
|
INSERT INTO session
|
||||||
(organization_id, tournament_id, sequence, name, format,
|
(organization_id, tournament_id, sequence, name, format,
|
||||||
hole_config, course_id, points_per_match, allowance_override, scoring_mode)
|
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
|
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,
|
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
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,
|
organization_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
|
|
@ -348,5 +372,8 @@ async def create_session(
|
||||||
body.points_per_match,
|
body.points_per_match,
|
||||||
allowance_json,
|
allowance_json,
|
||||||
body.scoring_mode,
|
body.scoring_mode,
|
||||||
|
body.scheduled_at,
|
||||||
|
body.tee_interval_minutes,
|
||||||
|
body.start_hole,
|
||||||
)
|
)
|
||||||
return _session_out(row, set())
|
return _session_out(row, set())
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ services:
|
||||||
build: .
|
build: .
|
||||||
container_name: teecup_api
|
container_name: teecup_api
|
||||||
environment:
|
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_SESSION_SECRET: ${TEECUP_SESSION_SECRET}
|
||||||
TEECUP_SMTP_SERVER: ${TEECUP_SMTP_SERVER}
|
TEECUP_SMTP_SERVER: ${TEECUP_SMTP_SERVER}
|
||||||
TEECUP_SMTP_PORT: ${TEECUP_SMTP_PORT}
|
TEECUP_SMTP_PORT: ${TEECUP_SMTP_PORT}
|
||||||
|
|
|
||||||
15
frontend/.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
202
frontend/components/login-form.tsx
Normal 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 på 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 på 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 på 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
frontend/components/ui/button.tsx
Normal 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 }
|
||||||
103
frontend/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
20
frontend/components/ui/input.tsx
Normal 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 }
|
||||||
20
frontend/components/ui/label.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
8
frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
BIN
frontend/public/apple-icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/public/icon-dark-32x32.png
Normal file
|
After Width: | Height: | Size: 585 B |
BIN
frontend/public/icon-light-32x32.png
Normal file
|
After Width: | Height: | Size: 566 B |
26
frontend/public/icon.svg
Normal 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 |
BIN
frontend/public/placeholder-logo.png
Normal file
|
After Width: | Height: | Size: 568 B |
1
frontend/public/placeholder-logo.svg
Normal 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 |
BIN
frontend/public/placeholder-user.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/placeholder.jpg
Normal file
|
After Width: | Height: | Size: 1 KiB |
1
frontend/public/placeholder.svg
Normal 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
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||