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:
Erol Haagenrud 2026-07-19 22:26:45 +02:00
parent 2e9c97c943
commit 20ae4fe6ea
13 changed files with 1232 additions and 25 deletions

View file

@ -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]"
]
}
}

50
013_messaging.sql Normal file
View 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;

View file

@ -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:

View file

@ -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)

View file

@ -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),

View file

@ -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")

420
app/routers/messaging.py Normal file
View 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 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 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)

View file

@ -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:
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):

View file

@ -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 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,
)
)

View 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. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<TeamChat
organizationId={org}
tournamentId={id}
teamId={teamId}
teamName={teamName}
tournamentName={tournamentName}
/>
)
}

View file

@ -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;
)}
<Sponsors sponsors={t.sponsors} />
<TournamentFeed tournamentId={tournamentId} code={code} />
</div>
</main>
</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 ----------------------------------------------------------------
function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: string }) {

View 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>
)
}

View file

@ -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"}
</span>
</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>
<ul className="flex flex-col">