Backend, frontend og Caddy-endringen er alle klare og verifisert (Caddy-syntaksen validert OK). Dette er en stor runde — her er full plan for utrulling:
1. Migrasjon — kjør 013_messaging.sql mot ekte teecup_db (ny message-tabell, RLS, ingen endring av eksisterende data). 2. Backend + frontend — docker compose up -d --build teecup_api teecup_frontend. 3. Caddy — legger til /ws/*-ruten i /opt/teeoff/deploy/Caddyfile (allerede skrevet og syntaks-validert). Som i alle tidligere runder som har rørt denne filen: en graceful reload plukker historisk IKKE opp endringen (stale bind-mount-inode), så det trengs en full docker restart teeoff_caddy — det gir noen sekunders nedetid for teeoff.no også, ikke bare teecup.teeoff.no. Etter alt dette: sjekke /health + /dashboard → 200, en reell WebSocket-tilkobling fungerer over wss://teecup.teeoff.no/ws/..., og teeoff.no er tilbake på 200.
This commit is contained in:
parent
2e9c97c943
commit
20ae4fe6ea
13 changed files with 1232 additions and 25 deletions
|
|
@ -283,12 +283,16 @@
|
||||||
"Bash(curl -s -w '\\\\n%{http_code}\\\\n' -X POST https://teecup.teeoff.no/auth/login-password -H \"Content-Type: application/json\" -d '{\"email\":\"ikke@finnes.no\",\"password\":\"whatever\"}')",
|
"Bash(curl -s -w '\\\\n%{http_code}\\\\n' -X POST https://teecup.teeoff.no/auth/login-password -H \"Content-Type: application/json\" -d '{\"email\":\"ikke@finnes.no\",\"password\":\"whatever\"}')",
|
||||||
"Bash(curl -s -o /dev/null -w \"health: %{http_code}\\\\n\" https://teecup.teeoff.no/health)",
|
"Bash(curl -s -o /dev/null -w \"health: %{http_code}\\\\n\" https://teecup.teeoff.no/health)",
|
||||||
"Bash(curl -s -o /dev/null -w \"dashboard: %{http_code}\\\\n\" https://teecup.teeoff.no/dashboard)",
|
"Bash(curl -s -o /dev/null -w \"dashboard: %{http_code}\\\\n\" https://teecup.teeoff.no/dashboard)",
|
||||||
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no)"
|
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no)",
|
||||||
|
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/registration.py'\\).read\\(\\)\\)\")",
|
||||||
|
"Bash(mkdir -p /opt/teecup/frontend/app/tournaments/\\\\[id\\\\]/teams/\\\\[teamId\\\\]/chat)",
|
||||||
|
"Bash(grep -n -A 20 \"teecup.teeoff.no\" /opt/teeoff/deploy/Caddyfile)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
"/tmp",
|
"/tmp",
|
||||||
"/opt/teecup"
|
"/opt/teecup",
|
||||||
|
"/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
013_messaging.sql
Normal file
50
013_messaging.sql
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
-- Kommunikasjon (ADR-025): lag-intern chat («det hemmelige rommet») og
|
||||||
|
-- offentlig runde-feed («Banter Board»), delt tabell med en scope-
|
||||||
|
-- diskriminator -- de to bruksmåtene deler samme meldingsform (tekst +
|
||||||
|
-- valgfritt bilde), men har svært ulik synlighet/autorisasjon, håndhevet i
|
||||||
|
-- app-laget (app/team_authz.py sin user_is_rostered_on_team for chat, og
|
||||||
|
-- registration.py sitt gjenbrukte visibility-mønster for feeden).
|
||||||
|
--
|
||||||
|
-- Lag-chat er BEVISST ekte privat -- ingen org-eier/admin-unntak her, ulikt
|
||||||
|
-- ALLE andre autorisasjonssjekker i appen (matches.py/scoring.py sin
|
||||||
|
-- kaptein-/deltaker-sjekk har alltid en org-admin-fallback). «Det hemmelige
|
||||||
|
-- rommet» mister poenget sitt hvis organisatoren kan lese det.
|
||||||
|
|
||||||
|
CREATE TYPE message_scope AS ENUM ('team', 'tournament_feed');
|
||||||
|
|
||||||
|
CREATE TABLE message (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||||
|
scope message_scope NOT NULL,
|
||||||
|
tournament_id uuid NOT NULL REFERENCES tournament(id) ON DELETE CASCADE,
|
||||||
|
team_id uuid REFERENCES team(id) ON DELETE CASCADE,
|
||||||
|
author_user_id uuid NOT NULL REFERENCES app_user(id),
|
||||||
|
-- Frosset ved skrivetidspunkt (samme "snapshot, ikke live join"-prinsipp
|
||||||
|
-- som handicap_index_snapshot, ADR-007) -- en spiller som senere endrer
|
||||||
|
-- visningsnavn, eller mister koblingen til player-raden sin, skal ikke
|
||||||
|
-- endre historiske meldingers avsendernavn.
|
||||||
|
author_display_name text NOT NULL,
|
||||||
|
body text,
|
||||||
|
image_key text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CHECK (
|
||||||
|
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||||
|
(scope = 'tournament_feed' AND team_id IS NULL)
|
||||||
|
),
|
||||||
|
CHECK (body IS NOT NULL OR image_key IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX message_team_idx ON message (team_id, created_at) WHERE scope = 'team';
|
||||||
|
CREATE INDEX message_feed_idx ON message (tournament_id, created_at) WHERE scope = 'tournament_feed';
|
||||||
|
|
||||||
|
ALTER TABLE message ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE message FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY org_isolation ON message
|
||||||
|
USING (organization_id = app_current_org())
|
||||||
|
WITH CHECK (organization_id = app_current_org());
|
||||||
|
|
||||||
|
-- Ingen UPDATE-grant -- meldinger redigeres ikke, kun slettes (moderering/
|
||||||
|
-- egen sletting), se app/routers/messaging.py.
|
||||||
|
GRANT SELECT, INSERT, DELETE ON message TO teecup_app;
|
||||||
|
|
@ -225,6 +225,14 @@ vilkårlige lag-referanser), så flere lag eller en knockout-type kan komme sene
|
||||||
uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig.
|
uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig.
|
||||||
Knockout er fanget som egen type i FEATURE_BACKLOG.md.
|
Knockout er fanget som egen type i FEATURE_BACKLOG.md.
|
||||||
|
|
||||||
|
**Merk (2026-07-19):** brukeren har reist ønske om flere turneringsformater
|
||||||
|
UTOVER Ryder Cup-lagformatet (f.eks. «Københavner» — se
|
||||||
|
FEATURE_BACKLOG.md sitt eget punkt for alle fire eksemplene). Minst ett av
|
||||||
|
disse («Københavner»: alle-mot-alle poengfordeling i et felt av spillere,
|
||||||
|
ikke to lag i det hele tatt) passer IKKE inn i denne ADR-ens to-lags-modell —
|
||||||
|
en fremtidig, egen ADR trengs når/hvis dette tas fatt på, samme mønster som
|
||||||
|
knockout-punktet over. Kun notert her ennå, ikke designet eller bygget.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-012 — To scoring-moduser per økt
|
## ADR-012 — To scoring-moduser per økt
|
||||||
|
|
@ -1024,6 +1032,75 @@ utrulling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-025: Kommunikasjon — lag-chat + offentlig runde-feed
|
||||||
|
|
||||||
|
Reist 2026-07-19, rett etter turnering-status-runden. Dekker to ganske ulike
|
||||||
|
ting under samme paraply: lag-intern chat («det hemmelige rommet») og en
|
||||||
|
offentlig runde-feed («Banter Board»). Fire beslutninger avklart eksplisitt
|
||||||
|
med bruker (AskUserQuestion) før bygging.
|
||||||
|
|
||||||
|
**Beslutning A — Begge bygges i samme runde**, ikke lag-chat først som egen
|
||||||
|
runde. Deler mye infrastruktur (meldingsmodell, sanntid-levering), så
|
||||||
|
designes/bygges sammen selv om feeden isolert sett hadde flere åpne
|
||||||
|
spørsmål.
|
||||||
|
|
||||||
|
**Beslutning B — Sanntid via WebSockets**, ikke polling. Løser samtidig det
|
||||||
|
tidligere åpne "sanntid vs. polling"-spørsmålet i FEATURE_BACKLOG.md
|
||||||
|
generelt (samme mekanisme kan gjenbrukes for leaderboard/andre skjermer
|
||||||
|
senere, selv om denne runden kun kobler den til meldinger).
|
||||||
|
**Viktig driftsbegrensning, videreført fra et allerede kjent åpent
|
||||||
|
arkitekturspørsmål (se «Åpne spørsmål» punkt 2 i dette dokumentet):**
|
||||||
|
tilkoblingsregisteret er en in-memory Python-struktur i `teecup_api`-
|
||||||
|
prosessen. Med kun én container/prosess (dagens oppsett) er dette trygt;
|
||||||
|
skaleres API-et til flere prosesser/containere senere, må broadcast flyttes
|
||||||
|
til noe delt (Redis pub/sub e.l.) — samme klasse begrensning som den
|
||||||
|
allerede aksepterte in-memory-cachen.
|
||||||
|
|
||||||
|
**Beslutning C — Lag-chat er EKTE privat: kun rostrede spillere på laget,
|
||||||
|
INGEN unntak for org-eier/admin.** Et bevisst avvik fra appens ellers
|
||||||
|
gjennomgående mønster (kaptein-/deltaker-sjekkene i `team_authz.py` har
|
||||||
|
alltid en org-admin-fallback, se ADR-023). Ny, egen autorisasjonsfunksjon
|
||||||
|
`user_is_rostered_on_team` (uten fallback) brukt KUN her — de eksisterende
|
||||||
|
funksjonene med org-admin-unntak røres ikke, siden de fortsatt er riktige
|
||||||
|
for sine egne bruksområder (oppsett/scoring, der en organisator uten
|
||||||
|
dette ville stått fast tidlig i en turnering).
|
||||||
|
|
||||||
|
**Beslutning D — Bilder med fra start**, ikke utsatt. Gjenbruker
|
||||||
|
`app/storage.py` sin allerede byggede og bevist MinIO+AVIF-konverterings-
|
||||||
|
pipeline (samme mønster som turnering-hero-bilder/sponsorlogoer, ADR-018) —
|
||||||
|
ingen ny opplastingsinfrastruktur trengs, kun en ny `prefix="messages"`.
|
||||||
|
|
||||||
|
**Datamodell:** delt `message`-tabell (migrasjon `013_messaging.sql`) med
|
||||||
|
en `scope`-diskriminator (`team`/`tournament_feed`) i stedet for to separate
|
||||||
|
tabeller — meldingsformen (tekst + valgfritt bilde) er identisk, kun
|
||||||
|
synlighet/autorisasjon skiller dem. `author_display_name` FRYSES ved
|
||||||
|
skrivetidspunkt (samme prinsipp som `handicap_index_snapshot`, ADR-007) —
|
||||||
|
utledet server-side fra avsenderens `player`-rad i org-en (hvis den finnes),
|
||||||
|
ellers e-postens lokaldel som fallback (en org-ansatt uten egen spillerprofil
|
||||||
|
kan fortsatt poste i den offentlige feeden).
|
||||||
|
|
||||||
|
**Offentlig feed — synlighet og posterett, to atskilte spørsmål:**
|
||||||
|
LESING gjenbruker `registration.py` sitt eksisterende trenivå-mønster
|
||||||
|
(`tournament.visibility` + `get_current_user_optional` + deltaker-sjekk,
|
||||||
|
ADR-018) uendret — ingen ny synlighetsmekanisme. POSTING er derimot
|
||||||
|
STRENGERE enn lesing: en anonym leser på en `public`-synlig turnering kan
|
||||||
|
lese feeden, men må logge inn OG være enten org-medlem eller faktisk
|
||||||
|
deltaker/registrert i NØYAKTIG denne turneringen for å få poste — hindrer at
|
||||||
|
en helt urelatert innlogget bruker (konto et helt annet sted i systemet) kan
|
||||||
|
poste på en fremmed offentlig turnering-side bare fordi den er synlig.
|
||||||
|
Moderering: forfatteren selv, ELLER org-eier/admin, kan slette et
|
||||||
|
feed-innlegg. Lag-chat har INGEN moderering utover forfatteren selv (rommet
|
||||||
|
er privat, org-admin har uansett ikke lesetilgang og kan derfor ikke
|
||||||
|
moderere det).
|
||||||
|
|
||||||
|
**Status: ✅ BYGGET 2026-07-19.** Se CLAUDE.md-status for scratch-
|
||||||
|
verifisering og utrulling, inkl. en egen Caddy-rute (`/ws/*`) for
|
||||||
|
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
|
||||||
|
proxyer ikke WebSocket-oppgraderinger pålitelig — samme klasse
|
||||||
|
infrastrukturvalg som media-ruten i MinIO-runden, ADR-018).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Å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:
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,64 @@
|
||||||
denne typen kan legges til senere uten dataomskriving — den legger bare til et
|
denne typen kan legges til senere uten dataomskriving — den legger bare til et
|
||||||
progresjons-lag.
|
progresjons-lag.
|
||||||
|
|
||||||
|
### Flere turneringsformater utover Ryder Cup (reist 2026-07-19)
|
||||||
|
- **Status:** 📋 notert, IKKE designet/bygget — trenger egen ADR-runde senere.
|
||||||
|
- Brukeren ønsker at TeeCup etter hvert skal støtte flere turneringsformater enn
|
||||||
|
dagens rene to-lags Ryder Cup-modell (ADR-011). Fire konkrete eksempler gitt,
|
||||||
|
med ulik arkitektonisk konsekvens (fra «passer nesten inn i dag» til «krever
|
||||||
|
en helt egen datamodell») — notert ordrett under, ikke forenklet, for at
|
||||||
|
reglene skal være presise når dette tas fatt på:
|
||||||
|
|
||||||
|
**«Københavner»** (engelsk: trolig **"Copenhagen"** — direkte oversettelse,
|
||||||
|
brukes noen steder i engelskspråklig golf-litteratur om nettopp dette
|
||||||
|
poengsystemet, men usikkert om det er en universelt anerkjent
|
||||||
|
standardbetegnelse; verdt å dobbeltsjekke før navnet ev. brukes i UI-et).
|
||||||
|
3 spillere, alle mot alle (INGEN lag). 6 poeng fordeles på hvert hull etter
|
||||||
|
relativ plassering: vinner alene → 4-2-0. Vinner + de to andre deler →
|
||||||
|
4-1-1. To deler beste score, én taper → 3-3-0. Alle likt → 2-2-2. Flest
|
||||||
|
poeng totalt etter runden vinner. **Størst arkitektonisk avstand fra i
|
||||||
|
dag:** ingen to-siders match i det hele tatt, individuelt felt med
|
||||||
|
poeng-per-hull-fordeling — passer ikke inn i `match`/`team_a_id`/
|
||||||
|
`team_b_id`-modellen slik den er nå (se merknad ved ADR-011).
|
||||||
|
|
||||||
|
**«High-low-high»** (4 spillere, 2 lag à 2). Per hull: beste spiller
|
||||||
|
(«high») på hvert lag møter hverandre i en del-match, dårligste spiller
|
||||||
|
(«low») på hvert lag møter hverandre i en egen del-match — resultatet av
|
||||||
|
hele hullet i hovedmatchen avgjøres av disse to del-oppgjørene til sammen.
|
||||||
|
Eksempel: hull 1 — spiller A (lag 1) får 3 poeng og slår begge på lag 2
|
||||||
|
(høyest slår høyest), spiller B (lag 1) stryker og taper mot lagets
|
||||||
|
low-motpart. Hullet blir da delt 1-1 siden «high» vant for lag 1 og «low»
|
||||||
|
vant for lag 2. **Arkitektonisk vrien del:** hvem som er «high»/«low» per
|
||||||
|
hull avgjøres AV SCORENE selv, etter at de er registrert — ikke satt opp
|
||||||
|
på forhånd slik dagens `match_participant`-oppsett (fast rolle/side satt
|
||||||
|
ved blind draw) forutsetter.
|
||||||
|
|
||||||
|
**«Robbins»** (foursome med partnerbytte). De første 6 hullene spilles som
|
||||||
|
én foursome-match, deretter bytter alle makker og spiller neste 6 hull som
|
||||||
|
en ny foursome-match, og de siste 6 hullene spilles med den tredje/siste
|
||||||
|
kombinasjonen — slik at alle har spilt med og mot alle i løpet av runden.
|
||||||
|
Seier i en 6-hulls delmatch gir 2 poeng, uavgjort gir 1 poeng, flest poeng
|
||||||
|
totalt vinner. **Arkitektonisk konsekvens:** én økt blir egentlig TRE
|
||||||
|
sekvensielle del-matcher med roterende partnerskap innad i samme runde —
|
||||||
|
dagens modell (én match = ett fast lag-oppsett for hele økten) dekker ikke
|
||||||
|
dette direkte.
|
||||||
|
|
||||||
|
**«Try all»** (2 lag, variant av foursome). Begge spillerne slår egen ball
|
||||||
|
fra tee, men BYTTER ball til andreslaget (spiller A slår spiller B sin
|
||||||
|
ball og omvendt), og paret velger deretter hvilken av de to ballene som
|
||||||
|
skal spilles videre — resten av hullet spilles som ordinær foursome på den
|
||||||
|
valgte ballen. **Minst arkitektonisk avstand fra i dag:** sannsynligvis en
|
||||||
|
ren spilleregel-variant av eksisterende foursome-format (samme
|
||||||
|
poengmodell, bare en annen fremgangsmåte de to første slagene) — trenger
|
||||||
|
trolig ikke ny datamodell, bare en presisering i regelverket/UI-teksten.
|
||||||
|
|
||||||
|
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
|
||||||
|
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
|
||||||
|
vurder de fire hver for seg (ikke som én stor runde), start med «Try all»
|
||||||
|
(lavest kostnad) hvis en rask seier er ønskelig, eller med «Københavner»
|
||||||
|
hvis en bredere individuell/felt-basert turneringstype uansett skal bygges
|
||||||
|
først som fundament for de andre.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kommunikasjon (under design)
|
## Kommunikasjon (under design)
|
||||||
|
|
|
||||||
31
app/auth.py
31
app/auth.py
|
|
@ -34,7 +34,7 @@ import jwt
|
||||||
import pyotp
|
import pyotp
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError, InvalidHash
|
from argon2.exceptions import VerifyMismatchError, InvalidHash
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Request, WebSocket
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import plain_connection
|
from .db import plain_connection
|
||||||
|
|
@ -269,6 +269,35 @@ async def get_current_user_optional(request: Request) -> CurrentUser | None:
|
||||||
return CurrentUser(user_id=user_id)
|
return CurrentUser(user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_from_websocket(websocket: WebSocket) -> CurrentUser | None:
|
||||||
|
"""Som get_current_user_optional, men leser sesjonscookien fra en
|
||||||
|
WebSocket-tilkobling (ADR-025) i stedet for en HTTP Request -- FastAPI
|
||||||
|
sitt Depends()-system kan ikke gjenbruke en Request-typet avhengighet
|
||||||
|
direkte i en WS-rute (ingen ekte Request finnes i en WS-scope), derav
|
||||||
|
denne separate, bevisst minimale kopien av samme dekode-/oppslagslogikk.
|
||||||
|
Returnerer None i stedet for å kaste -- ruten selv avgjør om anonym
|
||||||
|
tilgang er greit (offentlig feed) eller ikke (lag-chat, som lukker
|
||||||
|
tilkoblingen selv ved None)."""
|
||||||
|
token = websocket.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = claims.get("sub")
|
||||||
|
if not user_id or claims.get("stage", "full") != "full":
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
|
||||||
|
if not exists:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return CurrentUser(user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_authorized_org(
|
async def get_authorized_org(
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
|
|
||||||
14
app/main.py
14
app/main.py
|
|
@ -15,7 +15,17 @@ from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .db import init_pool, close_pool
|
from .db import init_pool, close_pool
|
||||||
from .routers import auth, courses, matches, organizations, players, registration, scoring, tournaments
|
from .routers import (
|
||||||
|
auth,
|
||||||
|
courses,
|
||||||
|
matches,
|
||||||
|
messaging,
|
||||||
|
organizations,
|
||||||
|
players,
|
||||||
|
registration,
|
||||||
|
scoring,
|
||||||
|
tournaments,
|
||||||
|
)
|
||||||
from . import storage
|
from . import storage
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,6 +50,8 @@ app.include_router(matches.router)
|
||||||
app.include_router(scoring.router)
|
app.include_router(scoring.router)
|
||||||
app.include_router(registration.router)
|
app.include_router(registration.router)
|
||||||
app.include_router(registration.org_router)
|
app.include_router(registration.org_router)
|
||||||
|
app.include_router(messaging.router)
|
||||||
|
app.include_router(messaging.public_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
420
app/routers/messaging.py
Normal file
420
app/routers/messaging.py
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
"""
|
||||||
|
Kommunikasjon (ADR-025): lag-intern chat («det hemmelige rommet») og
|
||||||
|
offentlig runde-feed («Banter Board»). Delt `message`-tabell (migrasjon 013)
|
||||||
|
med en scope-diskriminator, men to helt ulike autorisasjonsmodeller:
|
||||||
|
|
||||||
|
- Lag-chat (`router`, /orgs/...): org-medlemskap (get_authorized_org) +
|
||||||
|
`user_is_rostered_on_team` -- BEVISST INGEN org-admin-unntak, ulikt resten
|
||||||
|
av appen. Ekte privat.
|
||||||
|
- Offentlig feed (`public_router`, /public/tournaments/...): LESING
|
||||||
|
gjenbruker registration.py sitt trenivå-visibility-mønster uendret
|
||||||
|
(inkl. anonym tilgang for `public`-synlige turneringer). POSTING/SLETTING
|
||||||
|
er strengere -- krever ekte innlogging OG org-medlemskap ELLER faktisk
|
||||||
|
deltakelse i akkurat denne turneringen (ikke bare "kan se den").
|
||||||
|
|
||||||
|
Sanntid via WebSockets (ADR-025 Beslutning B): et rent in-memory
|
||||||
|
tilkoblingsregister PER PROSESS -- trygt med dagens ene `teecup_api`-
|
||||||
|
container, men deles IKKE på tvers av flere prosesser/containere (samme
|
||||||
|
klasse begrensning som den allerede aksepterte in-memory-cachen, se
|
||||||
|
ARCHITECTURE_DECISIONS.md sine "Åpne spørsmål"). WS-rutene kan ikke bruke
|
||||||
|
get_current_user/get_authorized_org direkte (de er Request-typet, ikke
|
||||||
|
WebSocket-kompatible via Depends()) -- egne, minimale kopier av samme
|
||||||
|
sjekker brukes i stedet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, UploadFile, WebSocket, WebSocketDisconnect
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import CurrentUser, get_authorized_org, get_current_user, get_current_user_optional
|
||||||
|
from ..auth import get_current_user_from_websocket
|
||||||
|
from ..db import org_connection, plain_connection
|
||||||
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from ..team_authz import is_org_admin, user_is_rostered_on_team
|
||||||
|
from .. import storage
|
||||||
|
from .registration import check_visibility, code_matches, is_participant, resolve_org
|
||||||
|
|
||||||
|
router = APIRouter(tags=["messaging"])
|
||||||
|
public_router = APIRouter(prefix="/public/tournaments", tags=["public-messaging"])
|
||||||
|
|
||||||
|
_MESSAGE_COLUMNS = """
|
||||||
|
id::text, author_user_id::text, author_display_name, body, image_key, created_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
author_user_id: str
|
||||||
|
author_display_name: str
|
||||||
|
body: str | None
|
||||||
|
image_url: str | None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_message(row) -> MessageOut:
|
||||||
|
return MessageOut(
|
||||||
|
id=row["id"],
|
||||||
|
author_user_id=row["author_user_id"],
|
||||||
|
author_display_name=row["author_display_name"],
|
||||||
|
body=row["body"],
|
||||||
|
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
|
||||||
|
created_at=row["created_at"].isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_author_display_name(conn, organization_id: str, user_id: str) -> str:
|
||||||
|
"""Fryses ved skrivetidspunkt (samme prinsipp som handicap_index_snapshot,
|
||||||
|
ADR-007) -- avsenderens player.display_name i DENNE org-en hvis den
|
||||||
|
finnes, ellers e-postens lokaldel (dekker org-ansatte uten egen
|
||||||
|
spillerprofil som poster i den offentlige feeden)."""
|
||||||
|
name = await conn.fetchval(
|
||||||
|
"SELECT display_name FROM player WHERE organization_id = $1 AND user_id = $2 LIMIT 1",
|
||||||
|
organization_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
async with plain_connection() as plain:
|
||||||
|
email = await plain.fetchval("SELECT email FROM app_user WHERE id = $1", user_id)
|
||||||
|
return email.split("@")[0] if email else "Ukjent"
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_optional_image(image: UploadFile | None) -> bytes | None:
|
||||||
|
if image is None:
|
||||||
|
return None
|
||||||
|
if image.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
||||||
|
raw = await image.read(storage.MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sanntid: in-memory tilkoblingsregister (se moduldocstring) ------------
|
||||||
|
|
||||||
|
_team_sockets: dict[str, set[WebSocket]] = defaultdict(set)
|
||||||
|
_feed_sockets: dict[str, set[WebSocket]] = defaultdict(set)
|
||||||
|
|
||||||
|
|
||||||
|
async def _broadcast(sockets: set[WebSocket], payload: dict) -> None:
|
||||||
|
dead = []
|
||||||
|
for ws in list(sockets):
|
||||||
|
try:
|
||||||
|
await ws.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
dead.append(ws)
|
||||||
|
for ws in dead:
|
||||||
|
sockets.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_org_member(user_id: str, organization_id: str) -> bool:
|
||||||
|
"""WS-variant av get_authorized_org sin medlemskapssjekk -- samme grunn
|
||||||
|
som get_current_user_from_websocket: ingen HTTP Request å binde
|
||||||
|
Depends() til i en WS-scope."""
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
return bool(
|
||||||
|
await conn.fetchval(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)",
|
||||||
|
user_id,
|
||||||
|
organization_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lag-chat (privat) -- /orgs/...
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orgs/{organization_id}/teams/{team_id}/messages", response_model=list[MessageOut])
|
||||||
|
async def list_team_messages(
|
||||||
|
team_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[MessageOut]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
exists = await conn.fetchval("SELECT id FROM team WHERE id = $1", team_id)
|
||||||
|
if exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||||
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||||
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT {_MESSAGE_COLUMNS} FROM message
|
||||||
|
WHERE team_id = $1 AND scope = 'team'
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
return [_row_to_message(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/teams/{team_id}/messages", response_model=MessageOut, status_code=201
|
||||||
|
)
|
||||||
|
async def send_team_message(
|
||||||
|
team_id: str,
|
||||||
|
body: str | None = Form(default=None),
|
||||||
|
image: UploadFile | None = File(default=None),
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> MessageOut:
|
||||||
|
if not body and image is None:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
||||||
|
raw = await _read_optional_image(image)
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
team = await conn.fetchrow(
|
||||||
|
"SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1", team_id
|
||||||
|
)
|
||||||
|
if team is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||||
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||||
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||||
|
|
||||||
|
image_key = None
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
image_key = await storage.upload_image("messages", team_id, raw)
|
||||||
|
except storage.InvalidImageError:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||||
|
|
||||||
|
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
||||||
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO message
|
||||||
|
(organization_id, scope, tournament_id, team_id, author_user_id, author_display_name, body, image_key)
|
||||||
|
VALUES ($1, 'team', $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING {_MESSAGE_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
team["tournament_id"],
|
||||||
|
team_id,
|
||||||
|
user.user_id,
|
||||||
|
display_name,
|
||||||
|
body,
|
||||||
|
image_key,
|
||||||
|
)
|
||||||
|
message = _row_to_message(row)
|
||||||
|
await _broadcast(_team_sockets[team_id], message.model_dump())
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}", status_code=204
|
||||||
|
)
|
||||||
|
async def delete_team_message(
|
||||||
|
team_id: str,
|
||||||
|
message_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
"""Kun forfatteren selv -- INGEN org-admin-unntak (samme begrunnelse som
|
||||||
|
lesetilgangen: et ekte privat rom har ingen ekstern moderator)."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT author_user_id::text AS author_user_id FROM message
|
||||||
|
WHERE id = $1 AND team_id = $2 AND scope = 'team'
|
||||||
|
""",
|
||||||
|
message_id,
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
||||||
|
if row["author_user_id"] != user.user_id:
|
||||||
|
raise app_error(403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne meldinger.")
|
||||||
|
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/orgs/{organization_id}/teams/{team_id}/messages")
|
||||||
|
async def team_chat_ws(websocket: WebSocket, organization_id: str, team_id: str) -> None:
|
||||||
|
user = await get_current_user_from_websocket(websocket)
|
||||||
|
if user is None:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
if not await _is_org_member(user.user_id, organization_id):
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
allowed = await user_is_rostered_on_team(conn, team_id, user.user_id)
|
||||||
|
if not allowed:
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
_team_sockets[team_id].add(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Klienten sender ingenting -- denne løkken finnes kun for å
|
||||||
|
# oppdage disconnect (receive() kaster WebSocketDisconnect da).
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_team_sockets[team_id].discard(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Offentlig runde-feed -- /public/tournaments/...
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _may_post_to_feed(conn, user: CurrentUser, organization_id: str, tournament_id: str) -> bool:
|
||||||
|
"""Strengere enn LESING (check_visibility): en 'public'-synlig turnering
|
||||||
|
kan leses helt anonymt, men posting krever alltid ekte innlogging OG
|
||||||
|
tilknytning -- org-medlem ELLER faktisk deltaker/registrert i NØYAKTIG
|
||||||
|
denne turneringen. Hindrer at en helt urelatert innlogget bruker (konto
|
||||||
|
et helt annet sted i systemet) kan poste på en fremmed offentlig side."""
|
||||||
|
is_member = await conn.fetchval(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)",
|
||||||
|
user.user_id,
|
||||||
|
organization_id,
|
||||||
|
)
|
||||||
|
if is_member:
|
||||||
|
return True
|
||||||
|
return await is_participant(conn, user.user_id, organization_id, tournament_id)
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.get("/{tournament_id}/feed", response_model=list[MessageOut])
|
||||||
|
async def get_feed(
|
||||||
|
tournament_id: str,
|
||||||
|
code: str | None = None,
|
||||||
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
|
) -> list[MessageOut]:
|
||||||
|
organization_id = await resolve_org(tournament_id)
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
if not code_matches(row["join_code"], code):
|
||||||
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||||
|
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT {_MESSAGE_COLUMNS} FROM message
|
||||||
|
WHERE tournament_id = $1 AND scope = 'tournament_feed'
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
return [_row_to_message(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
|
||||||
|
async def post_to_feed(
|
||||||
|
tournament_id: str,
|
||||||
|
body: str | None = Form(default=None),
|
||||||
|
image: UploadFile | None = File(default=None),
|
||||||
|
code: str | None = Form(default=None),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> MessageOut:
|
||||||
|
if not body and image is None:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
||||||
|
raw = await _read_optional_image(image)
|
||||||
|
|
||||||
|
organization_id = await resolve_org(tournament_id)
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
if not code_matches(row["join_code"], code):
|
||||||
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||||
|
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
||||||
|
raise app_error(
|
||||||
|
403,
|
||||||
|
"NOT_A_PARTICIPANT",
|
||||||
|
"Du må være medlem av organisasjonen eller delta i turneringen for å poste.",
|
||||||
|
)
|
||||||
|
|
||||||
|
image_key = None
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
image_key = await storage.upload_image("messages", tournament_id, raw)
|
||||||
|
except storage.InvalidImageError:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||||
|
|
||||||
|
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
||||||
|
|
||||||
|
inserted = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO message
|
||||||
|
(organization_id, scope, tournament_id, team_id, author_user_id, author_display_name, body, image_key)
|
||||||
|
VALUES ($1, 'tournament_feed', $2, NULL, $3, $4, $5, $6)
|
||||||
|
RETURNING {_MESSAGE_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
tournament_id,
|
||||||
|
user.user_id,
|
||||||
|
display_name,
|
||||||
|
body,
|
||||||
|
image_key,
|
||||||
|
)
|
||||||
|
message = _row_to_message(inserted)
|
||||||
|
await _broadcast(_feed_sockets[tournament_id], message.model_dump())
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.delete("/{tournament_id}/feed/{message_id}", status_code=204)
|
||||||
|
async def delete_feed_message(
|
||||||
|
tournament_id: str,
|
||||||
|
message_id: str,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
"""Forfatteren selv, ELLER org-eier/admin (moderering) -- ulikt lag-
|
||||||
|
chatten, siden feeden er offentlig og derfor trenger en reell
|
||||||
|
moderasjonsvei."""
|
||||||
|
organization_id = await resolve_org(tournament_id)
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT author_user_id::text AS author_user_id FROM message
|
||||||
|
WHERE id = $1 AND tournament_id = $2 AND scope = 'tournament_feed'
|
||||||
|
""",
|
||||||
|
message_id,
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
||||||
|
if row["author_user_id"] != user.user_id and not await is_org_admin(
|
||||||
|
conn, organization_id, user.user_id
|
||||||
|
):
|
||||||
|
raise app_error(
|
||||||
|
403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne meldinger (eller som organisasjonsadministrator)."
|
||||||
|
)
|
||||||
|
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/public/tournaments/{tournament_id}/feed")
|
||||||
|
async def feed_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None:
|
||||||
|
user = await get_current_user_from_websocket(websocket)
|
||||||
|
organization_id = await resolve_org(tournament_id)
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
if not code_matches(row["join_code"], code):
|
||||||
|
try:
|
||||||
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||||
|
except Exception:
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
_feed_sockets[tournament_id].add(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_feed_sockets[tournament_id].discard(websocket)
|
||||||
|
|
@ -32,7 +32,7 @@ router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
|
||||||
org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"])
|
org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"])
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_org(tournament_id: str) -> str:
|
async def resolve_org(tournament_id: str) -> str:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
org_id = await conn.fetchval("SELECT public_tournament_org($1)", tournament_id)
|
org_id = await conn.fetchval("SELECT public_tournament_org($1)", tournament_id)
|
||||||
if org_id is None:
|
if org_id is None:
|
||||||
|
|
@ -40,7 +40,7 @@ async def _resolve_org(tournament_id: str) -> str:
|
||||||
return str(org_id)
|
return str(org_id)
|
||||||
|
|
||||||
|
|
||||||
async def _is_participant(conn, user_id: str, organization_id: str, tournament_id: str) -> bool:
|
async def is_participant(conn, user_id: str, organization_id: str, tournament_id: str) -> bool:
|
||||||
"""Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en
|
"""Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en
|
||||||
registrering ELLER en rostret plass i NØYAKTIG denne turneringen?"""
|
registrering ELLER en rostret plass i NØYAKTIG denne turneringen?"""
|
||||||
return await conn.fetchval(
|
return await conn.fetchval(
|
||||||
|
|
@ -67,11 +67,11 @@ async def _is_participant(conn, user_id: str, organization_id: str, tournament_i
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _code_matches(actual_join_code: str, provided_code: str | None) -> bool:
|
def code_matches(actual_join_code: str, provided_code: str | None) -> bool:
|
||||||
return provided_code is not None and provided_code.strip().lower() == actual_join_code.lower()
|
return provided_code is not None and provided_code.strip().lower() == actual_join_code.lower()
|
||||||
|
|
||||||
|
|
||||||
async def _check_visibility(
|
async def check_visibility(
|
||||||
conn,
|
conn,
|
||||||
visibility: str,
|
visibility: str,
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
|
|
@ -88,7 +88,7 @@ async def _check_visibility(
|
||||||
)
|
)
|
||||||
if is_member:
|
if is_member:
|
||||||
return
|
return
|
||||||
if visibility == "participants" and await _is_participant(
|
if visibility == "participants" and await is_participant(
|
||||||
conn, user.user_id, organization_id, tournament_id
|
conn, user.user_id, organization_id, tournament_id
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
@ -107,7 +107,7 @@ async def resolve_join_code(code: str) -> JoinCodeResult:
|
||||||
MÅ registreres FØR /{tournament_id} under, ellers ville "by-code" blitt
|
MÅ registreres FØR /{tournament_id} under, ellers ville "by-code" blitt
|
||||||
tolket som en tournament_id (ugyldig UUID -> 500, ikke 404).
|
tolket som en tournament_id (ugyldig UUID -> 500, ikke 404).
|
||||||
|
|
||||||
Bevisst UAVHENGIG av tournament.visibility -- ingen _check_visibility()-
|
Bevisst UAVHENGIG av tournament.visibility -- ingen check_visibility()-
|
||||||
kall her, i motsetning til alle andre endepunkter i denne filen. Koden
|
kall her, i motsetning til alle andre endepunkter i denne filen. Koden
|
||||||
ER selve invitasjonen; å likevel kreve org-medlemskap/deltakerstatus
|
ER selve invitasjonen; å likevel kreve org-medlemskap/deltakerstatus
|
||||||
ville gjort den verdiløs nettopp for personer som mangler det."""
|
ville gjort den verdiløs nettopp for personer som mangler det."""
|
||||||
|
|
@ -116,7 +116,7 @@ async def resolve_join_code(code: str) -> JoinCodeResult:
|
||||||
if tournament_id is None:
|
if tournament_id is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne koden.")
|
raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne koden.")
|
||||||
|
|
||||||
organization_id = await _resolve_org(str(tournament_id))
|
organization_id = await resolve_org(str(tournament_id))
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
name = await conn.fetchval("SELECT name FROM tournament WHERE id = $1", tournament_id)
|
name = await conn.fetchval("SELECT name FROM tournament WHERE id = $1", tournament_id)
|
||||||
return JoinCodeResult(
|
return JoinCodeResult(
|
||||||
|
|
@ -154,7 +154,7 @@ async def get_public_tournament(
|
||||||
code: str | None = None,
|
code: str | None = None,
|
||||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
) -> PublicTournamentInfo:
|
) -> PublicTournamentInfo:
|
||||||
organization_id = await _resolve_org(tournament_id)
|
organization_id = await resolve_org(tournament_id)
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
|
|
@ -175,8 +175,8 @@ async def get_public_tournament(
|
||||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
|
||||||
# ADR-020 Beslutning A: en matchende kode overstyrer visibility helt.
|
# ADR-020 Beslutning A: en matchende kode overstyrer visibility helt.
|
||||||
if not _code_matches(row["join_code"], code):
|
if not code_matches(row["join_code"], code):
|
||||||
await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||||
|
|
||||||
sponsor_rows = await conn.fetch(
|
sponsor_rows = await conn.fetch(
|
||||||
"SELECT id::text, name, url, logo_key FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
"SELECT id::text, name, url, logo_key FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
||||||
|
|
@ -217,15 +217,15 @@ async def get_public_sessions(
|
||||||
code: str | None = None,
|
code: str | None = None,
|
||||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
) -> list[SessionOut]:
|
) -> list[SessionOut]:
|
||||||
organization_id = await _resolve_org(tournament_id)
|
organization_id = await resolve_org(tournament_id)
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
if not _code_matches(row["join_code"], code):
|
if not code_matches(row["join_code"], code):
|
||||||
await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||||
return await _fetch_sessions(conn, tournament_id)
|
return await _fetch_sessions(conn, tournament_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -260,7 +260,7 @@ async def register_for_tournament(
|
||||||
if not body.consent:
|
if not body.consent:
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Samtykke må gis for å melde seg på.")
|
raise app_error(400, "VALIDATION_FAILED", "Samtykke må gis for å melde seg på.")
|
||||||
|
|
||||||
organization_id = await _resolve_org(tournament_id)
|
organization_id = await resolve_org(tournament_id)
|
||||||
|
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
# Lås turneringsraden: forhindrer at to samtidige påmeldinger begge
|
# Lås turneringsraden: forhindrer at to samtidige påmeldinger begge
|
||||||
|
|
@ -280,8 +280,8 @@ async def register_for_tournament(
|
||||||
# ADR-018 Beslutning D: registrering følger samme synlighetsgrense
|
# ADR-018 Beslutning D: registrering følger samme synlighetsgrense
|
||||||
# som selve landingssiden -- ingen særbehandling. ADR-020 Beslutning A:
|
# som selve landingssiden -- ingen særbehandling. ADR-020 Beslutning A:
|
||||||
# unntaket er en matchende invitasjonskode, som overstyrer dette helt.
|
# unntaket er en matchende invitasjonskode, som overstyrer dette helt.
|
||||||
if not _code_matches(tournament["join_code"], body.code):
|
if not code_matches(tournament["join_code"], body.code):
|
||||||
await _check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user)
|
await check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user)
|
||||||
|
|
||||||
deadline = tournament["registration_deadline"]
|
deadline = tournament["registration_deadline"]
|
||||||
if deadline is not None and deadline < datetime.now(timezone.utc):
|
if deadline is not None and deadline < datetime.now(timezone.utc):
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,11 @@ unntak ville skapt en reell låsning.
|
||||||
from asyncpg import Connection
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
|
||||||
async def _is_org_admin(conn: Connection, organization_id: str, user_id: str) -> bool:
|
async def is_org_admin(conn: Connection, organization_id: str, user_id: str) -> bool:
|
||||||
# organization_membership har ingen RLS-policy (se auth.get_authorized_org
|
# organization_membership har ingen RLS-policy (se auth.get_authorized_org
|
||||||
# sin egen kommentar) -- filtrert eksplisitt på organization_id her, trygt
|
# sin egen kommentar) -- filtrert eksplisitt på organization_id her, trygt
|
||||||
# på samme tilkobling uansett hvilken app.current_org som er satt.
|
# på samme tilkobling uansett hvilken app.current_org som er satt.
|
||||||
is_org_admin = await conn.fetchval(
|
result = await conn.fetchval(
|
||||||
"""
|
"""
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM organization_membership
|
SELECT 1 FROM organization_membership
|
||||||
|
|
@ -54,7 +54,7 @@ async def _is_org_admin(conn: Connection, organization_id: str, user_id: str) ->
|
||||||
organization_id,
|
organization_id,
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
return bool(is_org_admin)
|
return bool(result)
|
||||||
|
|
||||||
|
|
||||||
async def user_is_team_captain(
|
async def user_is_team_captain(
|
||||||
|
|
@ -90,7 +90,7 @@ async def user_is_team_captain(
|
||||||
)
|
)
|
||||||
if may_act:
|
if may_act:
|
||||||
return True
|
return True
|
||||||
return await _is_org_admin(conn, organization_id, user_id)
|
return await is_org_admin(conn, organization_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
async def user_is_match_participant(
|
async def user_is_match_participant(
|
||||||
|
|
@ -116,4 +116,24 @@ async def user_is_match_participant(
|
||||||
)
|
)
|
||||||
if is_participant:
|
if is_participant:
|
||||||
return True
|
return True
|
||||||
return await _is_org_admin(conn, organization_id, user_id)
|
return await is_org_admin(conn, organization_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def user_is_rostered_on_team(conn: Connection, team_id: str, user_id: str) -> bool:
|
||||||
|
"""Kun for lag-chat (ADR-025 Beslutning C) -- BEVISST INGEN org-admin-
|
||||||
|
fallback, ulikt de to funksjonene over. «Det hemmelige rommet» er ekte
|
||||||
|
privat: kun spillere faktisk rostret på laget, aldri organisatoren, uansett
|
||||||
|
org-rolle. Ikke gjenbruk denne for autorisasjon utenfor chat."""
|
||||||
|
return bool(
|
||||||
|
await conn.fetchval(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM team_roster tr
|
||||||
|
JOIN player p ON p.id = tr.player_id
|
||||||
|
WHERE tr.team_id = $1 AND p.user_id = $2
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
team_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
36
frontend/app/tournaments/[id]/teams/[teamId]/chat/page.tsx
Normal file
36
frontend/app/tournaments/[id]/teams/[teamId]/chat/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { TeamChat } from "@/components/team-chat"
|
||||||
|
|
||||||
|
export default async function TeamChatPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; teamId: string }>
|
||||||
|
searchParams: Promise<{ org?: string; name?: string; team?: string }>
|
||||||
|
}) {
|
||||||
|
const { id, teamId } = await params
|
||||||
|
const { org, name, team } = await searchParams
|
||||||
|
const tournamentName = name?.trim() || "Turnering"
|
||||||
|
const teamName = team?.trim() || "Laget"
|
||||||
|
|
||||||
|
// Samme grunn som de andre turnering-undersidene: API-et krever
|
||||||
|
// organization_id på alle team-scopede endepunkter (RLS, se ADR-001/003).
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TeamChat
|
||||||
|
organizationId={org}
|
||||||
|
tournamentId={id}
|
||||||
|
teamId={teamId}
|
||||||
|
teamName={teamName}
|
||||||
|
tournamentName={tournamentName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,12 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock3,
|
Clock3,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
|
ImagePlus,
|
||||||
Lock,
|
Lock,
|
||||||
|
MessageSquare,
|
||||||
|
Send,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -243,12 +247,203 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Sponsors sponsors={t.sponsors} />
|
<Sponsors sponsors={t.sponsors} />
|
||||||
|
|
||||||
|
<TournamentFeed tournamentId={tournamentId} code={code} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Offentlig runde-feed («Banter Board», ADR-025) -------------------------
|
||||||
|
//
|
||||||
|
// LESING er tilgjengelig for alle som kan se selve turneringssiden (samme
|
||||||
|
// visibility-sjekk som info-kallet over -- får denne siden vist, får du også
|
||||||
|
// lest feeden). POSTING er strengere (håndheves server-side, ikke gjettet
|
||||||
|
// her): krever innlogging OG org-medlemskap/deltakelse -- en anonym eller
|
||||||
|
// uvedkommende bruker ser derfor et forklarende felt i stedet for skjemaet.
|
||||||
|
|
||||||
|
type ApiFeedMessage = {
|
||||||
|
id: string
|
||||||
|
author_user_id: string
|
||||||
|
author_display_name: string
|
||||||
|
body: string | null
|
||||||
|
image_url: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEED_TIME_FMT = new Intl.DateTimeFormat("no-NO", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
|
||||||
|
function feedWsUrl(tournamentId: string, code?: string): string {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
const query = code ? `?code=${encodeURIComponent(code)}` : ""
|
||||||
|
return `${protocol}//${window.location.host}/ws/public/tournaments/${tournamentId}/feed${query}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
||||||
|
const [messages, setMessages] = useState<ApiFeedMessage[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [text, setText] = useState("")
|
||||||
|
const [image, setImage] = useState<File | null>(null)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [postError, setPostError] = useState<string | null>(null)
|
||||||
|
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/public/tournaments/${tournamentId}/feed${codeParam}`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data: ApiFeedMessage[]) => {
|
||||||
|
if (!cancelled) setMessages(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [tournamentId, codeParam])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = new WebSocket(feedWsUrl(tournamentId, code))
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg: ApiFeedMessage = JSON.parse(event.data)
|
||||||
|
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||||
|
} catch {
|
||||||
|
// ignorer ugyldig payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => socket.close()
|
||||||
|
}, [tournamentId, code])
|
||||||
|
|
||||||
|
async function send(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!text.trim() && !image) return
|
||||||
|
setSending(true)
|
||||||
|
setPostError(null)
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
if (text.trim()) form.set("body", text.trim())
|
||||||
|
if (image) form.set("image", image)
|
||||||
|
if (code) form.set("code", code)
|
||||||
|
const res = await fetch(`/public/tournaments/${tournamentId}/feed`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
setPostError("Du må logge inn for å poste i feeden.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.status === 403) {
|
||||||
|
setPostError("Du må være medlem av arrangøren eller delta i turneringen for å poste.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`post: ${res.status}`)
|
||||||
|
const msg: ApiFeedMessage = await res.json()
|
||||||
|
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||||
|
setText("")
|
||||||
|
setImage(null)
|
||||||
|
} catch {
|
||||||
|
setPostError("Klarte ikke å poste. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Runde-feed" className="flex flex-col gap-3 border-t border-border pt-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare aria-hidden="true" className="size-4 text-primary" />
|
||||||
|
<h2 className="text-base font-bold text-foreground">Oppslagstavle</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Ingenting postet ennå -- bli den første.</p>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className="rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||||
|
<div className="mb-1.5 flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">{m.author_display_name}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{FEED_TIME_FMT.format(new Date(m.created_at))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{m.image_url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt
|
||||||
|
<img src={m.image_url} alt="" className="mb-2 max-h-72 w-full rounded-xl object-cover" />
|
||||||
|
)}
|
||||||
|
{m.body && <p className="text-sm leading-relaxed text-foreground text-pretty">{m.body}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={send} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
|
||||||
|
{postError && (
|
||||||
|
<p role="alert" className="text-xs font-medium text-destructive text-pretty">
|
||||||
|
{postError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{image && (
|
||||||
|
<div className="flex w-fit items-center gap-2 rounded-xl bg-muted px-3 py-1.5 text-xs font-medium text-foreground">
|
||||||
|
{image.name}
|
||||||
|
<button type="button" onClick={() => setImage(null)} aria-label="Fjern bilde">
|
||||||
|
<X aria-hidden="true" className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
id="feed-image-input"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="feed-image-input"
|
||||||
|
className="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ImagePlus aria-hidden="true" className="size-5" />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Skriv noe til feeden …"
|
||||||
|
className="h-10 flex-1 rounded-xl border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={sending || (!text.trim() && !image)}
|
||||||
|
aria-label="Post"
|
||||||
|
className="size-10 shrink-0 rounded-xl"
|
||||||
|
>
|
||||||
|
<Send aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Banner ----------------------------------------------------------------
|
// --- Banner ----------------------------------------------------------------
|
||||||
|
|
||||||
function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: string }) {
|
function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: string }) {
|
||||||
|
|
|
||||||
295
frontend/components/team-chat.tsx
Normal file
295
frontend/components/team-chat.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// --- Types (matcher API-kontrakten i app/routers/messaging.py) -------------
|
||||||
|
|
||||||
|
type ApiMessage = {
|
||||||
|
id: string
|
||||||
|
author_user_id: string
|
||||||
|
author_display_name: string
|
||||||
|
body: string | null
|
||||||
|
image_url: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_FMT = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
|
||||||
|
|
||||||
|
function wsUrl(path: string): string {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
return `${protocol}//${window.location.host}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamChat({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
|
teamId,
|
||||||
|
teamName,
|
||||||
|
tournamentName,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
|
teamId: string
|
||||||
|
teamName: string
|
||||||
|
tournamentName: string
|
||||||
|
}) {
|
||||||
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
|
||||||
|
const [messages, setMessages] = useState<ApiMessage[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [accessError, setAccessError] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [text, setText] = useState("")
|
||||||
|
const [image, setImage] = useState<File | null>(null)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [meRes, messagesRes] = await Promise.all([
|
||||||
|
fetch("/auth/me", { credentials: "include" }),
|
||||||
|
fetch(`/orgs/${organizationId}/teams/${teamId}/messages`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
if (meRes.ok) {
|
||||||
|
const me: { id: string } = await meRes.json()
|
||||||
|
if (!cancelled) setCurrentUserId(me.id)
|
||||||
|
}
|
||||||
|
if (messagesRes.status === 403) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAccessError(
|
||||||
|
"Du er ikke rostret på dette laget -- lagchatten er privat, kun for spillerne på laget (ikke engang organisasjonens eiere/administratorer har tilgang).",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!messagesRes.ok) throw new Error(`messages: ${messagesRes.status}`)
|
||||||
|
const data: ApiMessage[] = await messagesRes.json()
|
||||||
|
if (!cancelled) setMessages(data)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setAccessError("Klarte ikke å laste chatten. Prøv å laste siden på nytt.")
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, teamId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessError) return
|
||||||
|
const socket = new WebSocket(wsUrl(`/ws/orgs/${organizationId}/teams/${teamId}/messages`))
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg: ApiMessage = JSON.parse(event.data)
|
||||||
|
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||||
|
} catch {
|
||||||
|
// ignorer ugyldig payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => socket.close()
|
||||||
|
}, [organizationId, teamId, accessError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ block: "end" })
|
||||||
|
}, [messages.length])
|
||||||
|
|
||||||
|
async function send(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!text.trim() && !image) return
|
||||||
|
setSending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
if (text.trim()) form.set("body", text.trim())
|
||||||
|
if (image) form.set("image", image)
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`send: ${res.status}`)
|
||||||
|
const msg: ApiMessage = await res.json()
|
||||||
|
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||||
|
setText("")
|
||||||
|
setImage(null)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å sende meldingen. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(messageId: string) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/messages/${messageId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok && res.status !== 204) throw new Error(`delete: ${res.status}`)
|
||||||
|
setMessages((prev) => prev.filter((m) => m.id !== messageId))
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å slette meldingen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={backHref}
|
||||||
|
aria-label="Tilbake til lag og spillere"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Lock aria-hidden="true" className="size-3" />
|
||||||
|
Privat lagchat
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{teamName}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : accessError ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<Lock aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">{accessError}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-5 py-6">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="mt-8 text-center text-sm text-muted-foreground">
|
||||||
|
Ingen meldinger ennå -- her er kun laget deres, ingen andre kan lese med.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => {
|
||||||
|
const own = m.author_user_id === currentUserId
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={cn("flex flex-col gap-1", own ? "items-end" : "items-start")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm shadow-black/5",
|
||||||
|
own ? "bg-primary text-primary-foreground" : "border border-border bg-card text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!own && (
|
||||||
|
<span className="mb-0.5 block text-xs font-bold opacity-70">{m.author_display_name}</span>
|
||||||
|
)}
|
||||||
|
{m.image_url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt
|
||||||
|
<img
|
||||||
|
src={m.image_url}
|
||||||
|
alt=""
|
||||||
|
className="mb-1.5 max-h-64 w-full rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{m.body && <p className="text-[15px] leading-relaxed text-pretty">{m.body}</p>}
|
||||||
|
{own && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(m.id)}
|
||||||
|
aria-label="Slett melding"
|
||||||
|
className="absolute -left-9 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="px-1 text-[11px] text-muted-foreground">
|
||||||
|
{TIME_FMT.format(new Date(m.created_at))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 border-t border-border bg-background/95 backdrop-blur">
|
||||||
|
<form onSubmit={send} className="mx-auto flex w-full max-w-2xl flex-col gap-2 px-5 py-3">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{image && (
|
||||||
|
<div className="flex items-center gap-2 self-start rounded-xl bg-muted px-3 py-1.5 text-xs font-medium text-foreground">
|
||||||
|
{image.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setImage(null)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||||
|
}}
|
||||||
|
aria-label="Fjern bilde"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
aria-label="Legg ved bilde"
|
||||||
|
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ImagePlus aria-hidden="true" className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Skriv en melding …"
|
||||||
|
className="h-11 flex-1 rounded-2xl border border-input bg-background px-4 text-base outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={sending || (!text.trim() && !image)}
|
||||||
|
aria-label="Send"
|
||||||
|
className="size-11 shrink-0 rounded-2xl"
|
||||||
|
>
|
||||||
|
<Send aria-hidden="true" className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Copy,
|
Copy,
|
||||||
Flag,
|
Flag,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
MessageCircle,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Plus,
|
Plus,
|
||||||
Star,
|
Star,
|
||||||
|
|
@ -367,6 +368,7 @@ export function TournamentDetail({
|
||||||
team={team}
|
team={team}
|
||||||
otherTeam={otherTeam}
|
otherTeam={otherTeam}
|
||||||
pool={pool}
|
pool={pool}
|
||||||
|
chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`}
|
||||||
findPlayerTeam={findPlayerTeam}
|
findPlayerTeam={findPlayerTeam}
|
||||||
onToggleCaptain={toggleCaptain}
|
onToggleCaptain={toggleCaptain}
|
||||||
onRemovePlayer={removePlayer}
|
onRemovePlayer={removePlayer}
|
||||||
|
|
@ -515,6 +517,7 @@ function TeamPanel({
|
||||||
team,
|
team,
|
||||||
otherTeam,
|
otherTeam,
|
||||||
pool,
|
pool,
|
||||||
|
chatHref,
|
||||||
findPlayerTeam,
|
findPlayerTeam,
|
||||||
onToggleCaptain,
|
onToggleCaptain,
|
||||||
onRemovePlayer,
|
onRemovePlayer,
|
||||||
|
|
@ -525,6 +528,7 @@ function TeamPanel({
|
||||||
team: Team
|
team: Team
|
||||||
otherTeam: Team | null
|
otherTeam: Team | null
|
||||||
pool: ApiPlayer[]
|
pool: ApiPlayer[]
|
||||||
|
chatHref: string
|
||||||
findPlayerTeam: (playerId: string) => Team | null
|
findPlayerTeam: (playerId: string) => Team | null
|
||||||
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void
|
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void
|
||||||
onRemovePlayer: (teamId: string, rosterId: string) => void
|
onRemovePlayer: (teamId: string, rosterId: string) => void
|
||||||
|
|
@ -568,6 +572,13 @@ function TeamPanel({
|
||||||
{team.roster.length} {team.roster.length === 1 ? "spiller" : "spillere"}
|
{team.roster.length} {team.roster.length === 1 ? "spiller" : "spillere"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href={chatHref}
|
||||||
|
title="Privat lagchat -- kun for spillere rostret på laget"
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-xl border border-border text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<MessageCircle aria-hidden="true" className="size-4" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue