""" 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_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 ..realtime import live_sockets_for 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 from .round_messages import ALLOWED_REACTION_EMOJIS, CommentIn, ReactionIn, ReactionSummary 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 reactions: list[ReactionSummary] = [] comment_count: int = 0 def _row_to_message( row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0 ) -> 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(), reactions=reactions or [], comment_count=comment_count, ) # --- Reaksjoner/kommentarer (ADR-046) -- delt mellom lag-chat og --------- # --- oppslagstavle, siden begge lagrer i `message` med scope-diskriminator. _MESSAGE_COMMENT_COLUMNS = """ id::text, message_id::text, parent_comment_id::text, author_user_id::text, author_display_name, body, created_at """ class CommentOut(BaseModel): id: str message_id: str parent_comment_id: str | None author_user_id: str author_display_name: str body: str created_at: str def _row_to_message_comment(row) -> CommentOut: return CommentOut( id=row["id"], message_id=row["message_id"], parent_comment_id=row["parent_comment_id"], author_user_id=row["author_user_id"], author_display_name=row["author_display_name"], body=row["body"], created_at=row["created_at"].isoformat(), ) async def _message_reactions( conn, message_ids: list[str], viewer_user_id: str | None ) -> dict[str, list[ReactionSummary]]: if not message_ids: return {} rows = await conn.fetch( """ SELECT mr.message_id::text AS message_id, mr.emoji, count(*) AS count, COALESCE(bool_or(mr.user_id = $2), false) AS reacted_by_me, array_agg( COALESCE(p.display_name, split_part(au.email, '@', 1)) ORDER BY mr.created_at ) AS reactors FROM message_reaction mr JOIN app_user au ON au.id = mr.user_id LEFT JOIN player p ON p.organization_id = mr.organization_id AND p.user_id = mr.user_id WHERE mr.message_id = ANY($1::uuid[]) GROUP BY mr.message_id, mr.emoji """, message_ids, viewer_user_id, ) result: dict[str, list[ReactionSummary]] = {} for r in rows: result.setdefault(r["message_id"], []).append( ReactionSummary( emoji=r["emoji"], count=r["count"], reacted_by_me=r["reacted_by_me"], reactors=list(r["reactors"]), ) ) return result async def _message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]: if not message_ids: return {} rows = await conn.fetch( """ SELECT message_id::text AS message_id, count(*) AS count FROM message_comment WHERE message_id = ANY($1::uuid[]) GROUP BY message_id """, message_ids, ) return {r["message_id"]: r["count"] for r in rows} 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" # --- 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, 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.") # Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over # alt", samme rekkefølge som round_message og tournament_feed nå). rows = await conn.fetch( f""" SELECT {_MESSAGE_COLUMNS} FROM message WHERE team_id = $1 AND scope = 'team' ORDER BY created_at DESC """, team_id, ) message_ids = [r["id"] for r in rows] reactions_by_id = await _message_reactions(conn, message_ids, user.user_id) comment_counts = await _message_comment_counts(conn, message_ids) return [ _row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0)) 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, organization_id: str, body: str | None = Form(default=None), image: UploadFile | None = File(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 storage.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, 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) # --- Reaksjoner/kommentarer på lag-chat-meldinger (ADR-046) --------------- # Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve # meldingen (rostret på laget); sletting av kommentar er kun forfatteren, # samme "ekte privat rom" begrunnelse som `delete_team_message`. async def _get_team_message_or_404(conn, team_id: str, message_id: str) -> None: exists = await conn.fetchval( "SELECT id FROM message WHERE id = $1 AND team_id = $2 AND scope = 'team'", message_id, team_id, ) if exists is None: raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.") @router.put( "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary], ) async def set_team_message_reaction( team_id: str, organization_id: str, message_id: str, payload: ReactionIn, user: CurrentUser = Depends(get_current_user), ) -> list[ReactionSummary]: if payload.emoji not in ALLOWED_REACTION_EMOJIS: raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.") async with org_connection(organization_id) as conn: 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.") await _get_team_message_or_404(conn, team_id, message_id) await conn.execute( """ INSERT INTO message_reaction (organization_id, message_id, user_id, emoji) VALUES ($1, $2, $3, $4) ON CONFLICT (message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now() """, organization_id, message_id, user.user_id, payload.emoji, ) summaries = await _message_reactions(conn, [message_id], user.user_id) return summaries.get(message_id, []) @router.delete( "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary], ) async def remove_team_message_reaction( team_id: str, organization_id: str, message_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[ReactionSummary]: async with org_connection(organization_id) as conn: 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.") await _get_team_message_or_404(conn, team_id, message_id) await conn.execute( "DELETE FROM message_reaction WHERE message_id = $1 AND user_id = $2", message_id, user.user_id, ) summaries = await _message_reactions(conn, [message_id], user.user_id) return summaries.get(message_id, []) @router.get( "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments", response_model=list[CommentOut], ) async def list_team_message_comments( team_id: str, organization_id: str, message_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[CommentOut]: async with org_connection(organization_id) as conn: 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.") await _get_team_message_or_404(conn, team_id, message_id) rows = await conn.fetch( f""" SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment WHERE message_id = $1 ORDER BY created_at ASC """, message_id, ) return [_row_to_message_comment(r) for r in rows] @router.post( "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments", response_model=CommentOut, status_code=201, ) async def post_team_message_comment( team_id: str, organization_id: str, message_id: str, payload: CommentIn, user: CurrentUser = Depends(get_current_user), ) -> CommentOut: body = payload.body.strip() if not body: raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.") async with org_connection(organization_id) as conn, translate_db_errors(): 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.") await _get_team_message_or_404(conn, team_id, message_id) if payload.parent_comment_id is not None: parent_exists = await conn.fetchval( "SELECT id FROM message_comment WHERE id = $1 AND message_id = $2", payload.parent_comment_id, message_id, ) if parent_exists is None: raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.") display_name = await _resolve_author_display_name(conn, organization_id, user.user_id) row = await conn.fetchrow( f""" INSERT INTO message_comment (organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body) VALUES ($1, $2, $3, $4, $5, $6) RETURNING {_MESSAGE_COMMENT_COLUMNS} """, organization_id, message_id, payload.parent_comment_id, user.user_id, display_name, body, ) return _row_to_message_comment(row) @router.delete( "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments/{comment_id}", status_code=204, ) async def delete_team_message_comment( team_id: str, organization_id: str, message_id: str, comment_id: str, user: CurrentUser = Depends(get_current_user), ) -> None: """Kun forfatteren selv, INGEN unntak -- samme "ekte privat rom"- begrunnelse som `delete_team_message`.""" async with org_connection(organization_id) as conn: row = await conn.fetchrow( """ SELECT author_user_id::text AS author_user_id FROM message_comment WHERE id = $1 AND message_id = $2 """, comment_id, message_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.") if row["author_user_id"] != user.user_id: raise app_error(403, "NOT_COMMENT_AUTHOR", "Du kan kun slette dine egne kommentarer.") await conn.execute("DELETE FROM message_comment WHERE id = $1", comment_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) # Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over # alt", samme rekkefølge som round_message og lag-chatten nå). rows = await conn.fetch( f""" SELECT {_MESSAGE_COLUMNS} FROM message WHERE tournament_id = $1 AND scope = 'tournament_feed' ORDER BY created_at DESC """, tournament_id, ) message_ids = [r["id"] for r in rows] reactions_by_id = await _message_reactions(conn, message_ids, user.user_id if user else None) comment_counts = await _message_comment_counts(conn, message_ids) return [ _row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0)) 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 storage.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) # --- Reaksjoner/kommentarer på oppslagstavle-innlegg (ADR-046) ----------- # Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve # innlegget (`_may_post_to_feed`); sletting av kommentar er forfatter # ELLER org-admin, samme moderasjonsvei som `delete_feed_message`. async def _get_feed_message_or_404(conn, tournament_id: str, message_id: str) -> None: exists = await conn.fetchval( "SELECT id FROM message WHERE id = $1 AND tournament_id = $2 AND scope = 'tournament_feed'", message_id, tournament_id, ) if exists is None: raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.") @public_router.put("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary]) async def set_feed_message_reaction( tournament_id: str, message_id: str, payload: ReactionIn, user: CurrentUser = Depends(get_current_user), ) -> list[ReactionSummary]: if payload.emoji not in ALLOWED_REACTION_EMOJIS: raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.") organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: 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 å reagere.", ) await _get_feed_message_or_404(conn, tournament_id, message_id) await conn.execute( """ INSERT INTO message_reaction (organization_id, message_id, user_id, emoji) VALUES ($1, $2, $3, $4) ON CONFLICT (message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now() """, organization_id, message_id, user.user_id, payload.emoji, ) summaries = await _message_reactions(conn, [message_id], user.user_id) return summaries.get(message_id, []) @public_router.delete("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary]) async def remove_feed_message_reaction( tournament_id: str, message_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[ReactionSummary]: organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn: 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 å reagere.", ) await _get_feed_message_or_404(conn, tournament_id, message_id) await conn.execute( "DELETE FROM message_reaction WHERE message_id = $1 AND user_id = $2", message_id, user.user_id, ) summaries = await _message_reactions(conn, [message_id], user.user_id) return summaries.get(message_id, []) @public_router.get("/{tournament_id}/feed/{message_id}/comments", response_model=list[CommentOut]) async def list_feed_message_comments( tournament_id: str, message_id: str, code: str | None = None, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[CommentOut]: 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) await _get_feed_message_or_404(conn, tournament_id, message_id) rows = await conn.fetch( f""" SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment WHERE message_id = $1 ORDER BY created_at ASC """, message_id, ) return [_row_to_message_comment(r) for r in rows] @public_router.post("/{tournament_id}/feed/{message_id}/comments", response_model=CommentOut, status_code=201) async def post_feed_message_comment( tournament_id: str, message_id: str, payload: CommentIn, code: str | None = None, user: CurrentUser = Depends(get_current_user), ) -> CommentOut: body = payload.body.strip() if not body: raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.") 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 å kommentere.", ) await _get_feed_message_or_404(conn, tournament_id, message_id) if payload.parent_comment_id is not None: parent_exists = await conn.fetchval( "SELECT id FROM message_comment WHERE id = $1 AND message_id = $2", payload.parent_comment_id, message_id, ) if parent_exists is None: raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.") display_name = await _resolve_author_display_name(conn, organization_id, user.user_id) inserted = await conn.fetchrow( f""" INSERT INTO message_comment (organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body) VALUES ($1, $2, $3, $4, $5, $6) RETURNING {_MESSAGE_COMMENT_COLUMNS} """, organization_id, message_id, payload.parent_comment_id, user.user_id, display_name, body, ) return _row_to_message_comment(inserted) @public_router.delete("/{tournament_id}/feed/{message_id}/comments/{comment_id}", status_code=204) async def delete_feed_message_comment( tournament_id: str, message_id: str, comment_id: str, user: CurrentUser = Depends(get_current_user), ) -> None: """Forfatteren selv, ELLER org-eier/admin -- samme moderasjonsvei som `delete_feed_message`.""" 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_comment WHERE id = $1 AND message_id = $2 """, comment_id, message_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Kommentaren 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_COMMENT_AUTHOR", "Du kan kun slette dine egne kommentarer (eller som organisasjonsadministrator).", ) await conn.execute("DELETE FROM message_comment WHERE id = $1", comment_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) @router.websocket("/ws/public/tournaments/{tournament_id}/live") async def tournament_live_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None: """ADR-027: "Følg live"-siden (leaderboard/matcher/scorekort, registration.py) sin sanntid. Sender kun et "noe endret seg"-signal -- se app/realtime.py sin moduldocstring for hvorfor -- klienten reagerer ved å hente de vanlige REST-endepunktene på nytt. Samme visibility-sjekk som selve de offentlige lese-endepunktene den speiler.""" 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() sockets = live_sockets_for(tournament_id) sockets.add(websocket) try: while True: await websocket.receive_text() except WebSocketDisconnect: pass finally: sockets.discard(websocket)