""" Rundespesifikk kommentartråd for org-turneringer (ADR-102) -- ekte parallell til `round_messages.py` sin `RoundMessages` for frittstående runder, IKKE en utvidelse av den eksisterende, hele-turnering-brede "Banter Board" (`messaging.py`, scope='tournament_feed', ADR-025) -- den røres ikke av denne modulen. Bevisste forskjeller fra round_messages.py sitt mønster: - `tournament_round_message` (migrasjon 090) har `organization_id` + full RLS -- i motsetning til frittstående runders bevisst RLS-frie `plain_connection()`-mønster (ADR-033 Beslutning A gjelder ikke her, turneringer ER org-scopet, CLAUDE.md-invarianten). All lesing/ skriving går via `org_connection(organization_id)`. - Skriverett = leserett = org-medlemskap (`get_authorized_org`), IKKE frittstående sidens `_can_view_round` (eier/medspiller/public/friends- kategori har ingen reell motpart i org-turnering-modellen). Samme presedens som individual_tournaments.py sin egen modul-docstring fastslår for runde-/deltaker-OPPSETT for øvrig ("vanlig org- medlemsnivå") -- kommentering er samme kategori handling, ikke den strengere self-only-regelen `update_hole` har for selve SCORING-en. - Moderering: forfatter ELLER org-admin (`is_org_admin`), speiler Banter Board sin egen moderasjonsregel (ADR-025), ikke frittstående sidens "forfatter eller RUNDEEIER" (turneringsrunder har ingen eier i den forstand). - Én tråd PER TURNERINGSRUNDE (hele feltet på tvers av flighter deler tråd), bevisst valgt av bruker -- IKKE per flight. - Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging, ingen WebSocket-kringkasting (den autentiserte turneringsvisningen har ingen sanntids-tilkobling i det hele tatt ennå -- eget, uprioritert gap). """ from fastapi import APIRouter, Depends, File, Form, UploadFile from pydantic import BaseModel, Field from .. import storage from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors from ..team_authz import is_org_admin router = APIRouter(tags=["tournament-round-messages"]) # Samme kuraterte reaksjonssett som round_messages.py/messaging.py (ADR-046). ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"} _MESSAGE_COLUMNS = """ id::text, tournament_round_id::text, author_user_id::text, author_display_name, body, image_key, created_at """ _COMMENT_COLUMNS = """ id::text, tournament_round_message_id::text, parent_comment_id::text, author_user_id::text, author_display_name, body, created_at """ class ReactionIn(BaseModel): emoji: str class ReactionSummary(BaseModel): emoji: str count: int reacted_by_me: bool reactors: list[str] = [] class RoundMessageOut(BaseModel): id: str tournament_round_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 class CommentIn(BaseModel): body: str = Field(min_length=1, max_length=2000) parent_comment_id: str | None = None class CommentOut(BaseModel): id: str tournament_round_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( row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0 ) -> RoundMessageOut: return RoundMessageOut( id=row["id"], tournament_round_id=row["tournament_round_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, ) def _row_to_comment(row) -> CommentOut: return CommentOut( id=row["id"], tournament_round_message_id=row["tournament_round_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 _resolve_author_name(conn, user_id: str) -> str: """Fullt navn (roster-/forfatter-kontekst, ikke direkte adressering -- CLAUDE.md navneformat-regel), frosset ved skrivetidspunkt. Egen, lokal kopi av samme oppslag round_messages.py/messaging.py hver har sin egen versjon av -- denne modulen har ikke tidligere importert fra noen av dem, og hører uansett ikke hjemme i rounds.py (frittstående-side).""" row = await conn.fetchrow( "SELECT first_name, last_name, display_name FROM app_user WHERE id = $1", user_id ) if row["first_name"] and row["last_name"]: return f"{row['first_name']} {row['last_name']}" return row["display_name"] async def _get_tournament_round_or_404(conn, tournament_id: str, round_id: str) -> None: row = await conn.fetchval( "SELECT tournament_id::text FROM tournament_round WHERE id = $1", round_id ) if row is None or row != tournament_id: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") async def _get_message_or_404(conn, round_id: str, message_id: str) -> None: exists = await conn.fetchval( "SELECT id FROM tournament_round_message WHERE id = $1 AND tournament_round_id = $2", message_id, round_id, ) if exists is None: raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.") async def _message_reactions( conn, message_ids: list[str], viewer_user_id: str ) -> dict[str, list[ReactionSummary]]: if not message_ids: return {} rows = await conn.fetch( """ SELECT trmr.tournament_round_message_id::text AS message_id, trmr.emoji, count(*) AS count, COALESCE(bool_or(trmr.user_id = $2), false) AS reacted_by_me, array_agg( COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name) ORDER BY trmr.created_at ) AS reactors FROM tournament_round_message_reaction trmr JOIN app_user au ON au.id = trmr.user_id WHERE trmr.tournament_round_message_id = ANY($1::uuid[]) GROUP BY trmr.tournament_round_message_id, trmr.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 tournament_round_message_id::text AS message_id, count(*) AS count FROM tournament_round_message_comment WHERE tournament_round_message_id = ANY($1::uuid[]) GROUP BY tournament_round_message_id """, message_ids, ) return {r["message_id"]: r["count"] for r in rows} # --------------------------------------------------------------------------- # Runde-nivå kommentarer/bilder # --------------------------------------------------------------------------- @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages", response_model=list[RoundMessageOut], ) async def list_tournament_round_messages( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> list[RoundMessageOut]: async with org_connection(organization_id) as conn: await _get_tournament_round_or_404(conn, tournament_id, round_id) # Nyeste øverst -- samme "oppdateringsstrøm, ikke kronologisk # samtale"-mønster som round_messages.py (ADR-044). rows = await conn.fetch( f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message " "WHERE tournament_round_id = $1 ORDER BY created_at DESC", round_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}/tournaments/{tournament_id}/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201, ) async def post_tournament_round_message( tournament_id: str, round_id: str, body: str | None = Form(default=None, max_length=2000), image: UploadFile | None = File(default=None), client_message_id: str | None = Form(default=None), organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> RoundMessageOut: 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(): await _get_tournament_round_or_404(conn, tournament_id, round_id) if client_message_id is not None: existing = await conn.fetchrow( f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message " "WHERE tournament_round_id = $1 AND client_message_id = $2", round_id, client_message_id, ) if existing is not None: reactions = await _message_reactions(conn, [existing["id"]], user.user_id) comment_counts = await _message_comment_counts(conn, [existing["id"]]) return _row_to_message( existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0) ) image_key = None if raw is not None: try: image_key = await storage.upload_image("tournament_round_messages", round_id, raw) except storage.InvalidImageError: raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") display_name = await _resolve_author_name(conn, user.user_id) row = await conn.fetchrow( f""" INSERT INTO tournament_round_message (organization_id, tournament_round_id, author_user_id, author_display_name, body, image_key, client_message_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {_MESSAGE_COLUMNS} """, organization_id, round_id, user.user_id, display_name, body, image_key, client_message_id, ) return _row_to_message(row) @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}", status_code=204, ) async def delete_tournament_round_message( tournament_id: str, round_id: str, message_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> None: """Forfatteren selv, ELLER org-eier/admin -- samme modell som Banter Board sin `delete_feed_message` (ADR-025), ikke frittstående sidens "runde-eier" (turneringsrunder har ingen eier i den forstand).""" async with org_connection(organization_id) as conn: await _get_tournament_round_or_404(conn, tournament_id, round_id) author_id = await conn.fetchval( "SELECT author_user_id::text FROM tournament_round_message " "WHERE id = $1 AND tournament_round_id = $2", message_id, round_id, ) if author_id is None: raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.") if user.user_id != author_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 innlegg (eller som organisasjonsadministrator)." ) await conn.execute("DELETE FROM tournament_round_message WHERE id = $1", message_id) # --------------------------------------------------------------------------- # Reaksjoner # --------------------------------------------------------------------------- @router.put( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary], ) async def set_tournament_round_message_reaction( tournament_id: str, round_id: str, message_id: str, payload: ReactionIn, organization_id: str = Depends(get_authorized_org), 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: await _get_tournament_round_or_404(conn, tournament_id, round_id) await _get_message_or_404(conn, round_id, message_id) await conn.execute( """ INSERT INTO tournament_round_message_reaction (organization_id, tournament_round_message_id, user_id, emoji) VALUES ($1, $2, $3, $4) ON CONFLICT (tournament_round_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}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary], ) async def remove_tournament_round_message_reaction( tournament_id: str, round_id: str, message_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> list[ReactionSummary]: async with org_connection(organization_id) as conn: await _get_tournament_round_or_404(conn, tournament_id, round_id) await _get_message_or_404(conn, round_id, message_id) await conn.execute( "DELETE FROM tournament_round_message_reaction WHERE tournament_round_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, []) # --------------------------------------------------------------------------- # Trådede kommentarer -- flat lagring (parent_comment_id), frontend bygger # tre-strukturen. Kronologisk (eldst først), ulikt meldingslisten selv. # --------------------------------------------------------------------------- @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments", response_model=list[CommentOut], ) async def list_tournament_round_message_comments( tournament_id: str, round_id: str, message_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[CommentOut]: async with org_connection(organization_id) as conn: await _get_tournament_round_or_404(conn, tournament_id, round_id) await _get_message_or_404(conn, round_id, message_id) rows = await conn.fetch( f"SELECT {_COMMENT_COLUMNS} FROM tournament_round_message_comment " "WHERE tournament_round_message_id = $1 ORDER BY created_at ASC", message_id, ) return [_row_to_comment(r) for r in rows] @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments", response_model=CommentOut, status_code=201, ) async def post_tournament_round_message_comment( tournament_id: str, round_id: str, message_id: str, payload: CommentIn, organization_id: str = Depends(get_authorized_org), 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(): await _get_tournament_round_or_404(conn, tournament_id, round_id) await _get_message_or_404(conn, round_id, message_id) if payload.parent_comment_id is not None: parent_exists = await conn.fetchval( "SELECT id FROM tournament_round_message_comment " "WHERE id = $1 AND tournament_round_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_name(conn, user.user_id) row = await conn.fetchrow( f""" INSERT INTO tournament_round_message_comment (organization_id, tournament_round_message_id, parent_comment_id, author_user_id, author_display_name, body) VALUES ($1, $2, $3, $4, $5, $6) RETURNING {_COMMENT_COLUMNS} """, organization_id, message_id, payload.parent_comment_id, user.user_id, display_name, body, ) return _row_to_comment(row) @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments/{comment_id}", status_code=204, ) async def delete_tournament_round_message_comment( tournament_id: str, round_id: str, message_id: str, comment_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> None: """Forfatteren selv, ELLER org-eier/admin -- samme modell som selve innlegget. Kaskade-sletter eventuelle svar under (FK ON DELETE CASCADE).""" async with org_connection(organization_id) as conn: await _get_tournament_round_or_404(conn, tournament_id, round_id) await _get_message_or_404(conn, round_id, message_id) author_id = await conn.fetchval( "SELECT author_user_id::text FROM tournament_round_message_comment " "WHERE id = $1 AND tournament_round_message_id = $2", comment_id, message_id, ) if author_id is None: raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.") if user.user_id != author_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 tournament_round_message_comment WHERE id = $1", comment_id)