""" Kommentarer/bilder på frittstående runder (ADR-044) -- "Banter Board" for en enkelt runde (`round`/migrasjon 020), pluss en samlet `/feed`-side som aggregerer disse på tvers av runder brukeren har innsyn i. To bevisste forskjeller fra org-feeden (`messaging.py`, ADR-025): - Egen `round_message`-tabell (migrasjon 058), IKKE en utvidelse av den org-scopede `message`-tabellen -- `round` har verken `organization_id` eller RLS (ADR-033 Beslutning A), så all autorisasjon her skjer i app-laget via `plain_connection()`, ikke database-policy. - Skriverett = leserett: ALLE som kan SE runden (`_can_view_round`, gjenbrukt uendret fra `rounds.py`, ADR-036 fase 2) kan også POSTE -- samme "kan se = kan bidra"-modell som org-feeden, men her er synlighetssjekken allerede bygget og trenger ingen egen medlemskaps-/deltakelses-sjekk i tillegg. Sanntid: INGEN egen WebSocket-kanal. Gjenbruker `broadcast_round_update` (app/realtime.py), som allerede kringkastes ved hver hull-endring og allerede konsumeres av `/ws/rounds/{id}/live` / `/ws/public/rounds/{id}/ live` -- klienten reagerer med å hente meldingslisten på nytt, samme "noe endret seg"-mønster som resten av runde-sanntiden. """ from datetime import datetime from fastapi import APIRouter, Depends, File, Form, Query, UploadFile from pydantic import BaseModel from .. import storage from ..auth import CurrentUser, get_current_user, get_current_user_optional from ..db import plain_connection from ..errors import app_error, translate_db_errors from ..realtime import broadcast_round_update from .rounds import _get_viewable_round_or_404, _resolve_round_message_author_name router = APIRouter(tags=["round-messages"]) _ROUND_MESSAGE_COLUMNS = """ id::text, round_id::text, author_user_id::text, author_display_name, body, image_key, created_at """ # ADR-046: ett fast, kuratert reaksjonssett -- "like (eller andre emojier)", # ikke et fritt emoji-utvalg. Delt med messaging.py (importert derfra). ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"} class ReactionIn(BaseModel): emoji: str class ReactionSummary(BaseModel): emoji: str count: int reacted_by_me: bool # Fulle navn (roster-kontekst, ikke direkte adressering -- CLAUDE.md # navneformat-regel), i rekkefølgen de reagerte -- brukeren ba # eksplisitt 2026-08-07 om å kunne se HVEM som reagerte, ikke bare et # antall. Hentes i SAMME grupperte batch-spørring som selve # oppsummeringen (array_agg), ingen ekstra tur-retur ved visning. reactors: list[str] = [] class RoundMessageOut(BaseModel): id: str 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 def _row_to_round_message( row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0 ) -> RoundMessageOut: return RoundMessageOut( id=row["id"], round_id=row["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, ) async def _round_message_reactions( conn, message_ids: list[str], viewer_user_id: str | None ) -> dict[str, list[ReactionSummary]]: """Grupperte reaksjonsoppsummeringer for en batch med round_message-ID-er -- unngår N+1 når en meldingsliste rendres.""" if not message_ids: return {} rows = await conn.fetch( """ SELECT rmr.round_message_id::text AS message_id, rmr.emoji, count(*) AS count, COALESCE(bool_or(rmr.user_id = $2), false) AS reacted_by_me, array_agg( COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name) ORDER BY rmr.created_at ) AS reactors FROM round_message_reaction rmr JOIN app_user au ON au.id = rmr.user_id WHERE rmr.round_message_id = ANY($1::uuid[]) GROUP BY rmr.round_message_id, rmr.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 _round_message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]: if not message_ids: return {} rows = await conn.fetch( """ SELECT round_message_id::text AS message_id, count(*) AS count FROM round_message_comment WHERE round_message_id = ANY($1::uuid[]) GROUP BY round_message_id """, message_ids, ) return {r["message_id"]: r["count"] for r in rows} # --------------------------------------------------------------------------- # Runde-nivå kommentarer/bilder # --------------------------------------------------------------------------- @router.get("/rounds/{round_id}/messages", response_model=list[RoundMessageOut]) async def list_round_messages( round_id: str, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[RoundMessageOut]: async with plain_connection() as conn: await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None) # Nyeste øverst (2026-08-06, brukerens eksplisitte ønske) -- dette # er en oppdateringsstrøm om runden, ikke en samtale man leser # kronologisk fra start (ulikt lag-chatten, som beholder eldst- # først -- se app/routers/messaging.py). rows = await conn.fetch( f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at DESC", round_id, ) message_ids = [r["id"] for r in rows] reactions_by_id = await _round_message_reactions(conn, message_ids, user.user_id if user else None) comment_counts = await _round_message_comment_counts(conn, message_ids) return [ _row_to_round_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0)) for r in rows ] @router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201) async def post_round_message( round_id: str, body: str | None = Form(default=None), image: UploadFile | None = File(default=None), 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 plain_connection() as conn, translate_db_errors(): # Selve synlighetssjekken ER post-sjekken -- "kan se = kan bidra", # ingen snevrere gate i tillegg (ADR-044 Beslutning A). await _get_viewable_round_or_404(conn, round_id, user.user_id) image_key = None if raw is not None: try: image_key = await storage.upload_image("round_messages", round_id, raw) except storage.InvalidImageError: raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") display_name = await _resolve_round_message_author_name(conn, user.user_id) row = await conn.fetchrow( f""" INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key) VALUES ($1, $2, $3, $4, $5) RETURNING {_ROUND_MESSAGE_COLUMNS} """, round_id, user.user_id, display_name, body, image_key, ) message = _row_to_round_message(row) await broadcast_round_update(round_id) return message @router.delete("/rounds/{round_id}/messages/{message_id}", status_code=204) async def delete_round_message( round_id: str, message_id: str, user: CurrentUser = Depends(get_current_user), ) -> None: """Forfatteren selv, ELLER rundens eier (moderering -- rundens analog til org-admin), samme modell som org-feedens `delete_feed_message`.""" async with plain_connection() as conn: row = await conn.fetchrow( """ SELECT rm.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id FROM round_message rm JOIN round r ON r.id = rm.round_id WHERE rm.id = $1 AND rm.round_id = $2 """, message_id, round_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.") if user.user_id not in (row["author_user_id"], row["owner_user_id"]): raise app_error( 403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne innlegg (eller som rundeeier).", ) await conn.execute("DELETE FROM round_message WHERE id = $1", message_id) await broadcast_round_update(round_id) # --------------------------------------------------------------------------- # Reaksjoner (ADR-046) -- "kan se = kan bidra", samme som selve innlegget. # --------------------------------------------------------------------------- async def _get_round_message_or_404(conn, round_id: str, message_id: str) -> None: exists = await conn.fetchval( "SELECT id FROM round_message WHERE id = $1 AND round_id = $2", message_id, round_id ) if exists is None: raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.") @router.put("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary]) async def set_round_message_reaction( round_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 plain_connection() as conn: await _get_viewable_round_or_404(conn, round_id, user.user_id) await _get_round_message_or_404(conn, round_id, message_id) # Én reaksjon per bruker per innlegg -- bytte av emoji erstatter, # stables ikke (ADR-046). await conn.execute( """ INSERT INTO round_message_reaction (round_message_id, user_id, emoji) VALUES ($1, $2, $3) ON CONFLICT (round_message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now() """, message_id, user.user_id, payload.emoji, ) summaries = await _round_message_reactions(conn, [message_id], user.user_id) return summaries.get(message_id, []) @router.delete("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary]) async def remove_round_message_reaction( round_id: str, message_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[ReactionSummary]: async with plain_connection() as conn: await _get_viewable_round_or_404(conn, round_id, user.user_id) await _get_round_message_or_404(conn, round_id, message_id) await conn.execute( "DELETE FROM round_message_reaction WHERE round_message_id = $1 AND user_id = $2", message_id, user.user_id, ) summaries = await _round_message_reactions(conn, [message_id], user.user_id) return summaries.get(message_id, []) # --------------------------------------------------------------------------- # Trådede kommentarer (ADR-046) -- flat lagring (parent_comment_id), frontend # bygger tre-strukturen. Kronologisk (eldst først) -- en samtaletråd leses # top-til-bunn, ulikt de kronologisk OMVENDTE innleggs-/meldingsstrømmene # ("nyeste først, over alt", 2026-08-06) som dette henger under. # --------------------------------------------------------------------------- _ROUND_MESSAGE_COMMENT_COLUMNS = """ id::text, round_message_id::text, parent_comment_id::text, author_user_id::text, author_display_name, body, created_at """ class CommentIn(BaseModel): body: str parent_comment_id: str | None = None class CommentOut(BaseModel): id: str 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_round_message_comment(row) -> CommentOut: return CommentOut( id=row["id"], round_message_id=row["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(), ) @router.get("/rounds/{round_id}/messages/{message_id}/comments", response_model=list[CommentOut]) async def list_round_message_comments( round_id: str, message_id: str, user: CurrentUser | None = Depends(get_current_user_optional), ) -> list[CommentOut]: async with plain_connection() as conn: await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None) await _get_round_message_or_404(conn, round_id, message_id) rows = await conn.fetch( f""" SELECT {_ROUND_MESSAGE_COMMENT_COLUMNS} FROM round_message_comment WHERE round_message_id = $1 ORDER BY created_at ASC """, message_id, ) return [_row_to_round_message_comment(r) for r in rows] @router.post( "/rounds/{round_id}/messages/{message_id}/comments", response_model=CommentOut, status_code=201 ) async def post_round_message_comment( round_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 plain_connection() as conn, translate_db_errors(): await _get_viewable_round_or_404(conn, round_id, user.user_id) await _get_round_message_or_404(conn, round_id, message_id) if payload.parent_comment_id is not None: parent_exists = await conn.fetchval( "SELECT id FROM round_message_comment WHERE id = $1 AND 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_round_message_author_name(conn, user.user_id) row = await conn.fetchrow( f""" INSERT INTO round_message_comment (round_message_id, parent_comment_id, author_user_id, author_display_name, body) VALUES ($1, $2, $3, $4, $5) RETURNING {_ROUND_MESSAGE_COMMENT_COLUMNS} """, message_id, payload.parent_comment_id, user.user_id, display_name, body, ) comment = _row_to_round_message_comment(row) # INGEN broadcast_round_update her (ADR-046) -- kommentarer/reaksjoner # har bevisst ingen live-push til andre samtidige seere i v1 (kun egen # handling refetcher). Ville ellers trigget RoundMessages sin # "noe endret seg"-refetch for ALLE meldinger på siden ved hver eneste # kommentar, og med det kollapset enhver allerede utvidet tråd andre # steder på siden -- utilsiktet UX-regresjon oppdaget under # nettleserverifisering. return comment @router.delete("/rounds/{round_id}/messages/{message_id}/comments/{comment_id}", status_code=204) async def delete_round_message_comment( round_id: str, message_id: str, comment_id: str, user: CurrentUser = Depends(get_current_user), ) -> None: """Forfatteren selv, ELLER rundens eier -- samme modell som selve innlegget (`delete_round_message`). Kaskade-sletter eventuelle svar under (FK ON DELETE CASCADE, ADR-046).""" async with plain_connection() as conn: row = await conn.fetchrow( """ SELECT c.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id FROM round_message_comment c JOIN round_message rm ON rm.id = c.round_message_id JOIN round r ON r.id = rm.round_id WHERE c.id = $1 AND c.round_message_id = $2 AND rm.round_id = $3 """, comment_id, message_id, round_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.") if user.user_id not in (row["author_user_id"], row["owner_user_id"]): raise app_error( 403, "NOT_COMMENT_AUTHOR", "Du kan kun slette dine egne kommentarer (eller som rundeeier).", ) await conn.execute("DELETE FROM round_message_comment WHERE id = $1", comment_id) # INGEN broadcast_round_update her -- se samme begrunnelse i # post_round_message_comment over. # --------------------------------------------------------------------------- # Samlet feed -- egne runder + venners synlige runder (ADR-036 fase 2) # --------------------------------------------------------------------------- class FeedEntryOut(BaseModel): id: str round_id: str round_owner_user_id: str round_owner_display_name: str course_name: str played_at: 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 @router.get("/feed", response_model=list[FeedEntryOut]) async def get_feed( before: datetime | None = Query(default=None), limit: int = Query(default=20, ge=1, le=50), user: CurrentUser = Depends(get_current_user), ) -> list[FeedEntryOut]: # Reverst av `_can_view_round` (starter fra VIEWEREN, ikke fra # rundeeieren) -- samme retning og samme 'friends'-predikat som # `list_friends_on_course` (rounds.py), kopiert inn her i stedet for # faktorisert ut, samme bevisste "grei duplisering på denne skalaen" # begrunnelse som der (se rounds.py sin kommentar over # `list_friends_on_course`). Justeringer mot den funksjonen: egne # runder er ALLTID med (uansett visibility_mode), ingen "spiller # nå/siste 24t"-filter -- dette er en all-time, paginert liste av # round_message-rader, ikke en "hvem er live nå"-widget. async with plain_connection() as conn: rows = await conn.fetch( """ SELECT rm.id::text AS id, rm.round_id::text AS round_id, r.owner_user_id::text AS round_owner_user_id, COALESCE(owner.first_name || ' ' || owner.last_name, owner.display_name) AS round_owner_display_name, r.course_name_snapshot AS course_name, r.played_at, rm.author_user_id::text AS author_user_id, rm.author_display_name, rm.body, rm.image_key, rm.created_at FROM round_message rm JOIN round r ON r.id = rm.round_id JOIN app_user owner ON owner.id = r.owner_user_id WHERE ( r.owner_user_id = $1 OR EXISTS ( SELECT 1 FROM round_participant rp WHERE rp.round_id = r.id AND rp.user_id = $1 ) OR r.visibility_mode = 'public' OR ( r.visibility_mode = 'friends' AND EXISTS ( SELECT 1 FROM friendship f WHERE f.status = 'accepted' AND ((f.requester_user_id = $1 AND f.addressee_user_id = r.owner_user_id) OR (f.requester_user_id = r.owner_user_id AND f.addressee_user_id = $1)) ) AND EXISTS ( SELECT 1 FROM friend_categorization fc WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1 ) AND NOT EXISTS ( SELECT 1 FROM friend_categorization fc WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1 AND fc.category NOT IN ( SELECT category FROM round_visible_category WHERE round_id = r.id ) ) ) ) AND ($2::timestamptz IS NULL OR rm.created_at < $2::timestamptz) ORDER BY rm.created_at DESC LIMIT $3 """, user.user_id, before, limit, ) entry_ids = [r["id"] for r in rows] reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id) comment_counts = await _round_message_comment_counts(conn, entry_ids) return [ FeedEntryOut( id=r["id"], round_id=r["round_id"], round_owner_user_id=r["round_owner_user_id"], round_owner_display_name=r["round_owner_display_name"], course_name=r["course_name"], played_at=r["played_at"].isoformat(), author_user_id=r["author_user_id"], author_display_name=r["author_display_name"], body=r["body"], image_url=storage.public_url(r["image_key"]) if r["image_key"] else None, created_at=r["created_at"].isoformat(), reactions=reactions_by_id.get(r["id"], []), comment_count=comment_counts.get(r["id"], 0), ) for r in rows ]