Backend, frontend og Caddy-endringen er alle klare og verifisert (Caddy-syntaksen validert OK). Dette er en stor runde — her er full plan for utrulling:
1. Migrasjon — kjør 013_messaging.sql mot ekte teecup_db (ny message-tabell, RLS, ingen endring av eksisterende data). 2. Backend + frontend — docker compose up -d --build teecup_api teecup_frontend. 3. Caddy — legger til /ws/*-ruten i /opt/teeoff/deploy/Caddyfile (allerede skrevet og syntaks-validert). Som i alle tidligere runder som har rørt denne filen: en graceful reload plukker historisk IKKE opp endringen (stale bind-mount-inode), så det trengs en full docker restart teeoff_caddy — det gir noen sekunders nedetid for teeoff.no også, ikke bare teecup.teeoff.no. Etter alt dette: sjekke /health + /dashboard → 200, en reell WebSocket-tilkobling fungerer over wss://teecup.teeoff.no/ws/..., og teeoff.no er tilbake på 200.
This commit is contained in:
parent
2e9c97c943
commit
20ae4fe6ea
13 changed files with 1232 additions and 25 deletions
|
|
@ -283,12 +283,16 @@
|
|||
"Bash(curl -s -w '\\\\n%{http_code}\\\\n' -X POST https://teecup.teeoff.no/auth/login-password -H \"Content-Type: application/json\" -d '{\"email\":\"ikke@finnes.no\",\"password\":\"whatever\"}')",
|
||||
"Bash(curl -s -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
50
013_messaging.sql
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
-- Kommunikasjon (ADR-025): lag-intern chat («det hemmelige rommet») og
|
||||
-- offentlig runde-feed («Banter Board»), delt tabell med en scope-
|
||||
-- diskriminator -- de to bruksmåtene deler samme meldingsform (tekst +
|
||||
-- valgfritt bilde), men har svært ulik synlighet/autorisasjon, håndhevet i
|
||||
-- app-laget (app/team_authz.py sin user_is_rostered_on_team for chat, og
|
||||
-- registration.py sitt gjenbrukte visibility-mønster for feeden).
|
||||
--
|
||||
-- Lag-chat er BEVISST ekte privat -- ingen org-eier/admin-unntak her, ulikt
|
||||
-- ALLE andre autorisasjonssjekker i appen (matches.py/scoring.py sin
|
||||
-- kaptein-/deltaker-sjekk har alltid en org-admin-fallback). «Det hemmelige
|
||||
-- rommet» mister poenget sitt hvis organisatoren kan lese det.
|
||||
|
||||
CREATE TYPE message_scope AS ENUM ('team', 'tournament_feed');
|
||||
|
||||
CREATE TABLE message (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||
scope message_scope NOT NULL,
|
||||
tournament_id uuid NOT NULL REFERENCES tournament(id) ON DELETE CASCADE,
|
||||
team_id uuid REFERENCES team(id) ON DELETE CASCADE,
|
||||
author_user_id uuid NOT NULL REFERENCES app_user(id),
|
||||
-- Frosset ved skrivetidspunkt (samme "snapshot, ikke live join"-prinsipp
|
||||
-- som handicap_index_snapshot, ADR-007) -- en spiller som senere endrer
|
||||
-- visningsnavn, eller mister koblingen til player-raden sin, skal ikke
|
||||
-- endre historiske meldingers avsendernavn.
|
||||
author_display_name text NOT NULL,
|
||||
body text,
|
||||
image_key text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CHECK (
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'tournament_feed' AND team_id IS NULL)
|
||||
),
|
||||
CHECK (body IS NOT NULL OR image_key IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX message_team_idx ON message (team_id, created_at) WHERE scope = 'team';
|
||||
CREATE INDEX message_feed_idx ON message (tournament_id, created_at) WHERE scope = 'tournament_feed';
|
||||
|
||||
ALTER TABLE message ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE message FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY org_isolation ON message
|
||||
USING (organization_id = app_current_org())
|
||||
WITH CHECK (organization_id = app_current_org());
|
||||
|
||||
-- Ingen UPDATE-grant -- meldinger redigeres ikke, kun slettes (moderering/
|
||||
-- egen sletting), se app/routers/messaging.py.
|
||||
GRANT SELECT, INSERT, DELETE ON message TO teecup_app;
|
||||
|
|
@ -225,6 +225,14 @@ vilkårlige lag-referanser), så flere lag eller en knockout-type kan komme sene
|
|||
uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig.
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
31
app/auth.py
31
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),
|
||||
|
|
|
|||
14
app/main.py
14
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")
|
||||
|
|
|
|||
420
app/routers/messaging.py
Normal file
420
app/routers/messaging.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""
|
||||
Kommunikasjon (ADR-025): lag-intern chat («det hemmelige rommet») og
|
||||
offentlig runde-feed («Banter Board»). Delt `message`-tabell (migrasjon 013)
|
||||
med en scope-diskriminator, men to helt ulike autorisasjonsmodeller:
|
||||
|
||||
- Lag-chat (`router`, /orgs/...): org-medlemskap (get_authorized_org) +
|
||||
`user_is_rostered_on_team` -- BEVISST INGEN org-admin-unntak, ulikt resten
|
||||
av appen. Ekte privat.
|
||||
- Offentlig feed (`public_router`, /public/tournaments/...): LESING
|
||||
gjenbruker registration.py sitt trenivå-visibility-mønster uendret
|
||||
(inkl. anonym tilgang for `public`-synlige turneringer). POSTING/SLETTING
|
||||
er strengere -- krever ekte innlogging OG org-medlemskap ELLER faktisk
|
||||
deltakelse i akkurat denne turneringen (ikke bare "kan se den").
|
||||
|
||||
Sanntid via WebSockets (ADR-025 Beslutning B): et rent in-memory
|
||||
tilkoblingsregister PER PROSESS -- trygt med dagens ene `teecup_api`-
|
||||
container, men deles IKKE på tvers av flere prosesser/containere (samme
|
||||
klasse begrensning som den allerede aksepterte in-memory-cachen, se
|
||||
ARCHITECTURE_DECISIONS.md sine "Åpne spørsmål"). WS-rutene kan ikke bruke
|
||||
get_current_user/get_authorized_org direkte (de er Request-typet, ikke
|
||||
WebSocket-kompatible via Depends()) -- egne, minimale kopier av samme
|
||||
sjekker brukes i stedet.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import CurrentUser, get_authorized_org, get_current_user, get_current_user_optional
|
||||
from ..auth import get_current_user_from_websocket
|
||||
from ..db import org_connection, plain_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..team_authz import is_org_admin, user_is_rostered_on_team
|
||||
from .. import storage
|
||||
from .registration import check_visibility, code_matches, is_participant, resolve_org
|
||||
|
||||
router = APIRouter(tags=["messaging"])
|
||||
public_router = APIRouter(prefix="/public/tournaments", tags=["public-messaging"])
|
||||
|
||||
_MESSAGE_COLUMNS = """
|
||||
id::text, author_user_id::text, author_display_name, body, image_key, created_at
|
||||
"""
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: str
|
||||
author_user_id: str
|
||||
author_display_name: str
|
||||
body: str | None
|
||||
image_url: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
def _row_to_message(row) -> MessageOut:
|
||||
return MessageOut(
|
||||
id=row["id"],
|
||||
author_user_id=row["author_user_id"],
|
||||
author_display_name=row["author_display_name"],
|
||||
body=row["body"],
|
||||
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
|
||||
created_at=row["created_at"].isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_author_display_name(conn, organization_id: str, user_id: str) -> str:
|
||||
"""Fryses ved skrivetidspunkt (samme prinsipp som handicap_index_snapshot,
|
||||
ADR-007) -- avsenderens player.display_name i DENNE org-en hvis den
|
||||
finnes, ellers e-postens lokaldel (dekker org-ansatte uten egen
|
||||
spillerprofil som poster i den offentlige feeden)."""
|
||||
name = await conn.fetchval(
|
||||
"SELECT display_name FROM player WHERE organization_id = $1 AND user_id = $2 LIMIT 1",
|
||||
organization_id,
|
||||
user_id,
|
||||
)
|
||||
if name:
|
||||
return name
|
||||
async with plain_connection() as plain:
|
||||
email = await plain.fetchval("SELECT email FROM app_user WHERE id = $1", user_id)
|
||||
return email.split("@")[0] if email else "Ukjent"
|
||||
|
||||
|
||||
async def _read_optional_image(image: UploadFile | None) -> bytes | None:
|
||||
if image is None:
|
||||
return None
|
||||
if image.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
||||
raw = await image.read(storage.MAX_UPLOAD_BYTES + 1)
|
||||
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
||||
return raw
|
||||
|
||||
|
||||
# --- Sanntid: in-memory tilkoblingsregister (se moduldocstring) ------------
|
||||
|
||||
_team_sockets: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
_feed_sockets: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
|
||||
|
||||
async def _broadcast(sockets: set[WebSocket], payload: dict) -> None:
|
||||
dead = []
|
||||
for ws in list(sockets):
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
sockets.discard(ws)
|
||||
|
||||
|
||||
async def _is_org_member(user_id: str, organization_id: str) -> bool:
|
||||
"""WS-variant av get_authorized_org sin medlemskapssjekk -- samme grunn
|
||||
som get_current_user_from_websocket: ingen HTTP Request å binde
|
||||
Depends() til i en WS-scope."""
|
||||
async with plain_connection() as conn:
|
||||
return bool(
|
||||
await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)",
|
||||
user_id,
|
||||
organization_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lag-chat (privat) -- /orgs/...
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/orgs/{organization_id}/teams/{team_id}/messages", response_model=list[MessageOut])
|
||||
async def list_team_messages(
|
||||
team_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[MessageOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
exists = await conn.fetchval("SELECT id FROM team WHERE id = $1", team_id)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_MESSAGE_COLUMNS} FROM message
|
||||
WHERE team_id = $1 AND scope = 'team'
|
||||
ORDER BY created_at
|
||||
""",
|
||||
team_id,
|
||||
)
|
||||
return [_row_to_message(r) for r in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/teams/{team_id}/messages", response_model=MessageOut, status_code=201
|
||||
)
|
||||
async def send_team_message(
|
||||
team_id: str,
|
||||
body: str | None = Form(default=None),
|
||||
image: UploadFile | None = File(default=None),
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> MessageOut:
|
||||
if not body and image is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
||||
raw = await _read_optional_image(image)
|
||||
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
team = await conn.fetchrow(
|
||||
"SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1", team_id
|
||||
)
|
||||
if team is None:
|
||||
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||
|
||||
image_key = None
|
||||
if raw is not None:
|
||||
try:
|
||||
image_key = await storage.upload_image("messages", team_id, raw)
|
||||
except storage.InvalidImageError:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||
|
||||
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO message
|
||||
(organization_id, scope, tournament_id, team_id, author_user_id, author_display_name, body, image_key)
|
||||
VALUES ($1, 'team', $2, $3, $4, $5, $6, $7)
|
||||
RETURNING {_MESSAGE_COLUMNS}
|
||||
""",
|
||||
organization_id,
|
||||
team["tournament_id"],
|
||||
team_id,
|
||||
user.user_id,
|
||||
display_name,
|
||||
body,
|
||||
image_key,
|
||||
)
|
||||
message = _row_to_message(row)
|
||||
await _broadcast(_team_sockets[team_id], message.model_dump())
|
||||
return message
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}", status_code=204
|
||||
)
|
||||
async def delete_team_message(
|
||||
team_id: str,
|
||||
message_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Kun forfatteren selv -- INGEN org-admin-unntak (samme begrunnelse som
|
||||
lesetilgangen: et ekte privat rom har ingen ekstern moderator)."""
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT author_user_id::text AS author_user_id FROM message
|
||||
WHERE id = $1 AND team_id = $2 AND scope = 'team'
|
||||
""",
|
||||
message_id,
|
||||
team_id,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
||||
if row["author_user_id"] != user.user_id:
|
||||
raise app_error(403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne meldinger.")
|
||||
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/orgs/{organization_id}/teams/{team_id}/messages")
|
||||
async def team_chat_ws(websocket: WebSocket, organization_id: str, team_id: str) -> None:
|
||||
user = await get_current_user_from_websocket(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
if not await _is_org_member(user.user_id, organization_id):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
async with org_connection(organization_id) as conn:
|
||||
allowed = await user_is_rostered_on_team(conn, team_id, user.user_id)
|
||||
if not allowed:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
_team_sockets[team_id].add(websocket)
|
||||
try:
|
||||
while True:
|
||||
# Klienten sender ingenting -- denne løkken finnes kun for å
|
||||
# oppdage disconnect (receive() kaster WebSocketDisconnect da).
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
_team_sockets[team_id].discard(websocket)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Offentlig runde-feed -- /public/tournaments/...
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _may_post_to_feed(conn, user: CurrentUser, organization_id: str, tournament_id: str) -> bool:
|
||||
"""Strengere enn LESING (check_visibility): en 'public'-synlig turnering
|
||||
kan leses helt anonymt, men posting krever alltid ekte innlogging OG
|
||||
tilknytning -- org-medlem ELLER faktisk deltaker/registrert i NØYAKTIG
|
||||
denne turneringen. Hindrer at en helt urelatert innlogget bruker (konto
|
||||
et helt annet sted i systemet) kan poste på en fremmed offentlig side."""
|
||||
is_member = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)",
|
||||
user.user_id,
|
||||
organization_id,
|
||||
)
|
||||
if is_member:
|
||||
return True
|
||||
return await is_participant(conn, user.user_id, organization_id, tournament_id)
|
||||
|
||||
|
||||
@public_router.get("/{tournament_id}/feed", response_model=list[MessageOut])
|
||||
async def get_feed(
|
||||
tournament_id: str,
|
||||
code: str | None = None,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> list[MessageOut]:
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if not code_matches(row["join_code"], code):
|
||||
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_MESSAGE_COLUMNS} FROM message
|
||||
WHERE tournament_id = $1 AND scope = 'tournament_feed'
|
||||
ORDER BY created_at
|
||||
""",
|
||||
tournament_id,
|
||||
)
|
||||
return [_row_to_message(r) for r in rows]
|
||||
|
||||
|
||||
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
|
||||
async def post_to_feed(
|
||||
tournament_id: str,
|
||||
body: str | None = Form(default=None),
|
||||
image: UploadFile | None = File(default=None),
|
||||
code: str | None = Form(default=None),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> MessageOut:
|
||||
if not body and image is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
||||
raw = await _read_optional_image(image)
|
||||
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
row = await conn.fetchrow(
|
||||
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if not code_matches(row["join_code"], code):
|
||||
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
||||
raise app_error(
|
||||
403,
|
||||
"NOT_A_PARTICIPANT",
|
||||
"Du må være medlem av organisasjonen eller delta i turneringen for å poste.",
|
||||
)
|
||||
|
||||
image_key = None
|
||||
if raw is not None:
|
||||
try:
|
||||
image_key = await storage.upload_image("messages", tournament_id, raw)
|
||||
except storage.InvalidImageError:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||
|
||||
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
||||
|
||||
inserted = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO message
|
||||
(organization_id, scope, tournament_id, team_id, author_user_id, author_display_name, body, image_key)
|
||||
VALUES ($1, 'tournament_feed', $2, NULL, $3, $4, $5, $6)
|
||||
RETURNING {_MESSAGE_COLUMNS}
|
||||
""",
|
||||
organization_id,
|
||||
tournament_id,
|
||||
user.user_id,
|
||||
display_name,
|
||||
body,
|
||||
image_key,
|
||||
)
|
||||
message = _row_to_message(inserted)
|
||||
await _broadcast(_feed_sockets[tournament_id], message.model_dump())
|
||||
return message
|
||||
|
||||
|
||||
@public_router.delete("/{tournament_id}/feed/{message_id}", status_code=204)
|
||||
async def delete_feed_message(
|
||||
tournament_id: str,
|
||||
message_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Forfatteren selv, ELLER org-eier/admin (moderering) -- ulikt lag-
|
||||
chatten, siden feeden er offentlig og derfor trenger en reell
|
||||
moderasjonsvei."""
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT author_user_id::text AS author_user_id FROM message
|
||||
WHERE id = $1 AND tournament_id = $2 AND scope = 'tournament_feed'
|
||||
""",
|
||||
message_id,
|
||||
tournament_id,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
||||
if row["author_user_id"] != user.user_id and not await is_org_admin(
|
||||
conn, organization_id, user.user_id
|
||||
):
|
||||
raise app_error(
|
||||
403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne meldinger (eller som organisasjonsadministrator)."
|
||||
)
|
||||
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/public/tournaments/{tournament_id}/feed")
|
||||
async def feed_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None:
|
||||
user = await get_current_user_from_websocket(websocket)
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if row is None:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
if not code_matches(row["join_code"], code):
|
||||
try:
|
||||
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||
except Exception:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
_feed_sockets[tournament_id].add(websocket)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
_feed_sockets[tournament_id].discard(websocket)
|
||||
|
|
@ -32,7 +32,7 @@ router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
|
|||
org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"])
|
||||
|
||||
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
36
frontend/app/tournaments/[id]/teams/[teamId]/chat/page.tsx
Normal file
36
frontend/app/tournaments/[id]/teams/[teamId]/chat/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { TeamChat } from "@/components/team-chat"
|
||||
|
||||
export default async function TeamChatPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string; teamId: string }>
|
||||
searchParams: Promise<{ org?: string; name?: string; team?: string }>
|
||||
}) {
|
||||
const { id, teamId } = await params
|
||||
const { org, name, team } = await searchParams
|
||||
const tournamentName = name?.trim() || "Turnering"
|
||||
const teamName = team?.trim() || "Laget"
|
||||
|
||||
// Samme grunn som de andre turnering-undersidene: API-et krever
|
||||
// organization_id på alle team-scopede endepunkter (RLS, se ADR-001/003).
|
||||
if (!org) {
|
||||
return (
|
||||
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamChat
|
||||
organizationId={org}
|
||||
tournamentId={id}
|
||||
teamId={teamId}
|
||||
teamName={teamName}
|
||||
tournamentName={tournamentName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,8 +9,12 @@ import {
|
|||
CheckCircle2,
|
||||
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 }) {
|
||||
|
|
|
|||
295
frontend/components/team-chat.tsx
Normal file
295
frontend/components/team-chat.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// --- Types (matcher API-kontrakten i app/routers/messaging.py) -------------
|
||||
|
||||
type ApiMessage = {
|
||||
id: string
|
||||
author_user_id: string
|
||||
author_display_name: string
|
||||
body: string | null
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const TIME_FMT = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
function wsUrl(path: string): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
return `${protocol}//${window.location.host}${path}`
|
||||
}
|
||||
|
||||
export function TeamChat({
|
||||
organizationId,
|
||||
tournamentId,
|
||||
teamId,
|
||||
teamName,
|
||||
tournamentName,
|
||||
}: {
|
||||
organizationId: string
|
||||
tournamentId: string
|
||||
teamId: string
|
||||
teamName: string
|
||||
tournamentName: string
|
||||
}) {
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<ApiMessage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accessError, setAccessError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [text, setText] = useState("")
|
||||
const [image, setImage] = useState<File | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const [meRes, messagesRes] = await Promise.all([
|
||||
fetch("/auth/me", { credentials: "include" }),
|
||||
fetch(`/orgs/${organizationId}/teams/${teamId}/messages`, { credentials: "include" }),
|
||||
])
|
||||
if (meRes.ok) {
|
||||
const me: { id: string } = await meRes.json()
|
||||
if (!cancelled) setCurrentUserId(me.id)
|
||||
}
|
||||
if (messagesRes.status === 403) {
|
||||
if (!cancelled) {
|
||||
setAccessError(
|
||||
"Du er ikke rostret på dette laget -- lagchatten er privat, kun for spillerne på laget (ikke engang organisasjonens eiere/administratorer har tilgang).",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!messagesRes.ok) throw new Error(`messages: ${messagesRes.status}`)
|
||||
const data: ApiMessage[] = await messagesRes.json()
|
||||
if (!cancelled) setMessages(data)
|
||||
} catch {
|
||||
if (!cancelled) setAccessError("Klarte ikke å laste chatten. Prøv å laste siden på nytt.")
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [organizationId, teamId])
|
||||
|
||||
useEffect(() => {
|
||||
if (accessError) return
|
||||
const socket = new WebSocket(wsUrl(`/ws/orgs/${organizationId}/teams/${teamId}/messages`))
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg: ApiMessage = JSON.parse(event.data)
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||
} catch {
|
||||
// ignorer ugyldig payload
|
||||
}
|
||||
}
|
||||
return () => socket.close()
|
||||
}, [organizationId, teamId, accessError])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" })
|
||||
}, [messages.length])
|
||||
|
||||
async function send(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!text.trim() && !image) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const form = new FormData()
|
||||
if (text.trim()) form.set("body", text.trim())
|
||||
if (image) form.set("image", image)
|
||||
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/messages`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) throw new Error(`send: ${res.status}`)
|
||||
const msg: ApiMessage = await res.json()
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||
setText("")
|
||||
setImage(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
} catch {
|
||||
setError("Klarte ikke å sende meldingen. Prøv igjen.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(messageId: string) {
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/messages/${messageId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok && res.status !== 204) throw new Error(`delete: ${res.status}`)
|
||||
setMessages((prev) => prev.filter((m) => m.id !== messageId))
|
||||
} catch {
|
||||
setError("Klarte ikke å slette meldingen. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
|
||||
const backHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
||||
<Link
|
||||
href={backHref}
|
||||
aria-label="Tilbake til lag og spillere"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Lock aria-hidden="true" className="size-3" />
|
||||
Privat lagchat
|
||||
</span>
|
||||
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{teamName}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
) : accessError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Lock aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">{accessError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-5 py-6">
|
||||
{messages.length === 0 && (
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">
|
||||
Ingen meldinger ennå -- her er kun laget deres, ingen andre kan lese med.
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m) => {
|
||||
const own = m.author_user_id === currentUserId
|
||||
return (
|
||||
<div key={m.id} className={cn("flex flex-col gap-1", own ? "items-end" : "items-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm shadow-black/5",
|
||||
own ? "bg-primary text-primary-foreground" : "border border-border bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{!own && (
|
||||
<span className="mb-0.5 block text-xs font-bold opacity-70">{m.author_display_name}</span>
|
||||
)}
|
||||
{m.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt
|
||||
<img
|
||||
src={m.image_url}
|
||||
alt=""
|
||||
className="mb-1.5 max-h-64 w-full rounded-xl object-cover"
|
||||
/>
|
||||
)}
|
||||
{m.body && <p className="text-[15px] leading-relaxed text-pretty">{m.body}</p>}
|
||||
{own && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(m.id)}
|
||||
aria-label="Slett melding"
|
||||
className="absolute -left-9 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="px-1 text-[11px] text-muted-foreground">
|
||||
{TIME_FMT.format(new Date(m.created_at))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</main>
|
||||
|
||||
<div className="sticky bottom-0 border-t border-border bg-background/95 backdrop-blur">
|
||||
<form onSubmit={send} className="mx-auto flex w-full max-w-2xl flex-col gap-2 px-5 py-3">
|
||||
{error && (
|
||||
<p role="alert" className="text-xs font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{image && (
|
||||
<div className="flex items-center gap-2 self-start rounded-xl bg-muted px-3 py-1.5 text-xs font-medium text-foreground">
|
||||
{image.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImage(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}}
|
||||
aria-label="Fjern bilde"
|
||||
>
|
||||
<X aria-hidden="true" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Legg ved bilde"
|
||||
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
|
||||
>
|
||||
<ImagePlus aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Skriv en melding …"
|
||||
className="h-11 flex-1 rounded-2xl border border-input bg-background px-4 text-base outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={sending || (!text.trim() && !image)}
|
||||
aria-label="Send"
|
||||
className="size-11 shrink-0 rounded-2xl"
|
||||
>
|
||||
<Send aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Copy,
|
||||
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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue