diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 41f6b28..243b935 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 -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 \"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": [ "/opt/teeoff/deploy", "/tmp", - "/opt/teecup" + "/opt/teecup", + "/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]" ] } } diff --git a/013_messaging.sql b/013_messaging.sql new file mode 100644 index 0000000..4270848 --- /dev/null +++ b/013_messaging.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 305db3f..16436eb 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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. 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 @@ -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å) Disse må avklares før eller under de relevante fasene: diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 31f7fd4..04a0fb8 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -477,6 +477,64 @@ denne typen kan legges til senere uten dataomskriving — den legger bare til et 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) diff --git a/app/auth.py b/app/auth.py index 50f862b..da0c870 100644 --- a/app/auth.py +++ b/app/auth.py @@ -34,7 +34,7 @@ import jwt import pyotp from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, InvalidHash -from fastapi import Depends, Request +from fastapi import Depends, Request, WebSocket from .config import settings 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) +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( organization_id: str, user: CurrentUser = Depends(get_current_user), diff --git a/app/main.py b/app/main.py index 1790bb2..28c9674 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,17 @@ from contextlib import asynccontextmanager from fastapi import FastAPI 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 @@ -40,6 +50,8 @@ app.include_router(matches.router) app.include_router(scoring.router) app.include_router(registration.router) app.include_router(registration.org_router) +app.include_router(messaging.router) +app.include_router(messaging.public_router) @app.get("/health") diff --git a/app/routers/messaging.py b/app/routers/messaging.py new file mode 100644 index 0000000..59d6c18 --- /dev/null +++ b/app/routers/messaging.py @@ -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) diff --git a/app/routers/registration.py b/app/routers/registration.py index d720b5d..24cf666 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -32,7 +32,7 @@ router = APIRouter(prefix="/public/tournaments", 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: org_id = await conn.fetchval("SELECT public_tournament_org($1)", tournament_id) if org_id is None: @@ -40,7 +40,7 @@ async def _resolve_org(tournament_id: str) -> str: 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 registrering ELLER en rostret plass i NØYAKTIG denne turneringen?""" 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() -async def _check_visibility( +async def check_visibility( conn, visibility: str, organization_id: str, @@ -88,7 +88,7 @@ async def _check_visibility( ) if is_member: return - if visibility == "participants" and await _is_participant( + if visibility == "participants" and await is_participant( conn, user.user_id, organization_id, tournament_id ): 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 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 ER selve invitasjonen; å likevel kreve org-medlemskap/deltakerstatus 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: 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: name = await conn.fetchval("SELECT name FROM tournament WHERE id = $1", tournament_id) return JoinCodeResult( @@ -154,7 +154,7 @@ async def get_public_tournament( code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> PublicTournamentInfo: - organization_id = await _resolve_org(tournament_id) + organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: row = await conn.fetchrow( """ @@ -175,8 +175,8 @@ async def get_public_tournament( raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") # ADR-020 Beslutning A: en matchende kode overstyrer visibility helt. - if not _code_matches(row["join_code"], code): - await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user) + if not code_matches(row["join_code"], code): + await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) sponsor_rows = await conn.fetch( "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, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[SessionOut]: - organization_id = await _resolve_org(tournament_id) + 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) + if not code_matches(row["join_code"], code): + await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) return await _fetch_sessions(conn, tournament_id) @@ -260,7 +260,7 @@ async def register_for_tournament( if not body.consent: 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(): # 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 # som selve landingssiden -- ingen særbehandling. ADR-020 Beslutning A: # unntaket er en matchende invitasjonskode, som overstyrer dette helt. - if not _code_matches(tournament["join_code"], body.code): - await _check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user) + if not code_matches(tournament["join_code"], body.code): + await check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user) deadline = tournament["registration_deadline"] if deadline is not None and deadline < datetime.now(timezone.utc): diff --git a/app/team_authz.py b/app/team_authz.py index ecc730c..f582381 100644 --- a/app/team_authz.py +++ b/app/team_authz.py @@ -40,11 +40,11 @@ unntak ville skapt en reell låsning. 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 # sin egen kommentar) -- filtrert eksplisitt på organization_id her, trygt # på samme tilkobling uansett hvilken app.current_org som er satt. - is_org_admin = await conn.fetchval( + result = await conn.fetchval( """ SELECT EXISTS ( SELECT 1 FROM organization_membership @@ -54,7 +54,7 @@ async def _is_org_admin(conn: Connection, organization_id: str, user_id: str) -> organization_id, user_id, ) - return bool(is_org_admin) + return bool(result) async def user_is_team_captain( @@ -90,7 +90,7 @@ async def user_is_team_captain( ) if may_act: 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( @@ -116,4 +116,24 @@ async def user_is_match_participant( ) if is_participant: 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, + ) + ) diff --git a/frontend/app/tournaments/[id]/teams/[teamId]/chat/page.tsx b/frontend/app/tournaments/[id]/teams/[teamId]/chat/page.tsx new file mode 100644 index 0000000..9331339 --- /dev/null +++ b/frontend/app/tournaments/[id]/teams/[teamId]/chat/page.tsx @@ -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 ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/components/public-tournament.tsx b/frontend/components/public-tournament.tsx index 4dfb0ea..a18aa7c 100644 --- a/frontend/components/public-tournament.tsx +++ b/frontend/components/public-tournament.tsx @@ -9,8 +9,12 @@ import { CheckCircle2, Clock3, Hourglass, + ImagePlus, Lock, + MessageSquare, + Send, ShieldAlert, + X, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -243,12 +247,203 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string; )} + + ) } +// --- 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([]) + const [loading, setLoading] = useState(true) + const [text, setText] = useState("") + const [image, setImage] = useState(null) + const [sending, setSending] = useState(false) + const [postError, setPostError] = useState(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 ( +
+
+
+ + {loading ? ( +
+ + ) : ( +
+ {messages.length === 0 && ( +

Ingenting postet ennå -- bli den første.

+ )} + {messages.map((m) => ( +
+
+ {m.author_display_name} + + {FEED_TIME_FMT.format(new Date(m.created_at))} + +
+ {m.image_url && ( + // eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt + + )} + {m.body &&

{m.body}

} +
+ ))} +
+ )} + +
+ {postError && ( +

+ {postError} +

+ )} + {image && ( +
+ {image.name} + +
+ )} +
+ setImage(e.target.files?.[0] ?? null)} + /> + + 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" + /> + +
+
+
+ ) +} + // --- Banner ---------------------------------------------------------------- function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: string }) { diff --git a/frontend/components/team-chat.tsx b/frontend/components/team-chat.tsx new file mode 100644 index 0000000..0083241 --- /dev/null +++ b/frontend/components/team-chat.tsx @@ -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(null) + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(true) + const [accessError, setAccessError] = useState(null) + const [error, setError] = useState(null) + const [text, setText] = useState("") + const [image, setImage] = useState(null) + const [sending, setSending] = useState(false) + const bottomRef = useRef(null) + const fileInputRef = useRef(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 ( +
+
+
+ +
+
+ + {loading ? ( +
+ + ) : accessError ? ( +
+
+
+

{accessError}

+
+ ) : ( + <> +
+ {messages.length === 0 && ( +

+ Ingen meldinger ennå -- her er kun laget deres, ingen andre kan lese med. +

+ )} + {messages.map((m) => { + const own = m.author_user_id === currentUserId + return ( +
+
+ {!own && ( + {m.author_display_name} + )} + {m.image_url && ( + // eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt + + )} + {m.body &&

{m.body}

} + {own && ( + + )} +
+ + {TIME_FMT.format(new Date(m.created_at))} + +
+ ) + })} +
+
+ +
+
+ {error && ( +

+ {error} +

+ )} + {image && ( +
+ {image.name} + +
+ )} +
+ setImage(e.target.files?.[0] ?? null)} + /> + + 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" + /> + +
+
+
+ + )} +
+ ) +} diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx index ee0fd1c..5ece325 100644 --- a/frontend/components/tournament-detail.tsx +++ b/frontend/components/tournament-detail.tsx @@ -9,6 +9,7 @@ import { Copy, Flag, KeyRound, + MessageCircle, MoreVertical, Plus, Star, @@ -367,6 +368,7 @@ export function TournamentDetail({ team={team} otherTeam={otherTeam} pool={pool} + chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`} findPlayerTeam={findPlayerTeam} onToggleCaptain={toggleCaptain} onRemovePlayer={removePlayer} @@ -515,6 +517,7 @@ function TeamPanel({ team, otherTeam, pool, + chatHref, findPlayerTeam, onToggleCaptain, onRemovePlayer, @@ -525,6 +528,7 @@ function TeamPanel({ team: Team otherTeam: Team | null pool: ApiPlayer[] + chatHref: string findPlayerTeam: (playerId: string) => Team | null onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void onRemovePlayer: (teamId: string, rosterId: string) => void @@ -568,6 +572,13 @@ function TeamPanel({ {team.roster.length} {team.roster.length === 1 ? "spiller" : "spillere"}
+ +