""" 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 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 """ 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 def _row_to_round_message(row) -> 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(), ) async def _resolve_round_message_author_name(conn, user_id: str) -> str: """Frosset ved skrivetidspunkt, samme prinsipp som `message.author_display_name` -- her FULLT navn (forfatter-liste, ikke direkte adressering, CLAUDE.md navneformat-regel). Ingen org-scopet `player.display_name` å falle tilbake på her (runder er ikke org-scopet) -- `app_user.display_name` er NOT NULL og alltid til stede, så ingen ekstra fallback trengs.""" 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"] # --------------------------------------------------------------------------- # 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) rows = await conn.fetch( f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at", round_id, ) return [_row_to_round_message(r) 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) # --------------------------------------------------------------------------- # 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 @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, ) 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(), ) for r in rows ]