""" 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. """ import json from datetime import datetime from fastapi import APIRouter, Depends, File, Form, Query, UploadFile from pydantic import BaseModel, Field 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 .notifications import create_notification 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] = [] # --------------------------------------------------------------------------- # @-tagging (ADR-063, 2026-08-11) -- kun FAKTISKE relasjoner er taggbare # (rundedeltakere ELLER avsenderens venner), aldri fritekstsøk i hele # brukerbasen (personvern, se FEATURE_BACKLOG.md-notatet som reiste dette). # `start_index`/`end_index` er BYTE-uavhengige Python-strengindekser inn i # `body` SLIK DEN BLE SKREVET -- trygt siden verken round_message eller # round_message_comment noensinne kan redigeres i etterkant (kun slettes). # --------------------------------------------------------------------------- class TagIn(BaseModel): user_id: str start_index: int = Field(ge=0) end_index: int = Field(gt=0) class TagOut(BaseModel): user_id: str display_name: str start_index: int end_index: int async def _taggable_candidates(conn, round_id: str, author_user_id: str) -> dict[str, str]: """Returnerer {user_id: fullt navn} for alle personer AVSENDEREN faktisk kan tagge i denne runden -- lenkede medspillere i runden, UNION avsenderens egne venner (uansett om vennen selv er med i runden). Brukes BÅDE til søkeendepunktet OG til server-side validering ved innsending -- klientens forslag stoles aldri blindt på.""" rows = await conn.fetch( """ SELECT DISTINCT au.id::text AS user_id, COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name) AS name FROM app_user au WHERE au.id IN ( SELECT rp.user_id FROM round_participant rp WHERE rp.round_id = $1 AND rp.user_id IS NOT NULL ) OR au.id IN ( SELECT CASE WHEN f.requester_user_id = $2 THEN f.addressee_user_id ELSE f.requester_user_id END FROM friendship f WHERE f.status = 'accepted' AND (f.requester_user_id = $2 OR f.addressee_user_id = $2) ) """, round_id, author_user_id, ) candidates = {r["user_id"]: r["name"] for r in rows} candidates.pop(author_user_id, None) # kan ikke tagge seg selv return candidates async def _validate_and_prepare_tags( conn, round_id: str, author_user_id: str, body: str, raw_tags: list[TagIn] ) -> list[TagIn]: """Filtrerer ut ugyldige tags fremfor å avvise HELE innlegget -- en tag med feil offset eller mot en ikke-taggbar person er trolig en klient-/rase-tilstand-feil (personen som ble tagget sluttet f.eks. å være medspiller mellom autocomplete og innsending), ikke noe brukeren bevisst prøver å omgå. Innlegget/kommentaren skal uansett postes.""" if not raw_tags: return [] candidates = await _taggable_candidates(conn, round_id, author_user_id) valid: list[TagIn] = [] for t in raw_tags: if t.user_id not in candidates: continue if t.end_index > len(body) or t.start_index >= t.end_index: continue valid.append(t) return valid async def _insert_tags_and_notify( conn, *, table: str, fk_column: str, message_id: str, round_id: str, author_user_id: str, author_name: str, tags: list[TagIn], ) -> None: for t in tags: await conn.execute( f"INSERT INTO {table} ({fk_column}, tagged_user_id, start_index, end_index) VALUES ($1, $2, $3, $4)", message_id, t.user_id, t.start_index, t.end_index, ) await create_notification( conn, user_id=t.user_id, type="round", message=f"{author_name} tagget deg i et innlegg.", link_path=f"/my-rounds/{round_id}", ) async def _tags_for_messages(conn, table: str, fk_column: str, message_ids: list[str]) -> dict[str, list[TagOut]]: if not message_ids: return {} rows = await conn.fetch( f""" SELECT t.{fk_column}::text AS message_id, t.tagged_user_id::text AS user_id, t.start_index, t.end_index, COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name) AS name FROM {table} t JOIN app_user au ON au.id = t.tagged_user_id WHERE t.{fk_column} = ANY($1::uuid[]) ORDER BY t.start_index """, message_ids, ) result: dict[str, list[TagOut]] = {} for r in rows: result.setdefault(r["message_id"], []).append( TagOut(user_id=r["user_id"], display_name=r["name"], start_index=r["start_index"], end_index=r["end_index"]) ) return result 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 tags: list[TagOut] = [] def _row_to_round_message( row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0, tags: list[TagOut] | None = None ) -> 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, tags=tags or [], ) 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) tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", message_ids) return [ _row_to_round_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0), tags_by_id.get(r["id"])) for r in rows ] @router.get("/rounds/{round_id}/taggable-people") async def list_taggable_people( round_id: str, q: str = Query(default="", max_length=100), user: CurrentUser = Depends(get_current_user), ) -> list[dict]: """Kandidater for @-tagging: rundens lenkede medspillere UNION avsenderens egne venner -- ALDRI et fritekstsøk i hele brukerbasen (personvern). `q` filtrerer på navn, case-insensitivt delstreng-treff.""" async with plain_connection() as conn: await _get_viewable_round_or_404(conn, round_id, user.user_id) candidates = await _taggable_candidates(conn, round_id, user.user_id) needle = q.strip().lower() results = [ {"user_id": uid, "display_name": name} for uid, name in candidates.items() if not needle or needle in name.lower() ] results.sort(key=lambda r: r["display_name"]) return results[:20] @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, max_length=2000), image: UploadFile | None = File(default=None), # JSON-kodet liste av {user_id, start_index, end_index} -- Form()-felt # siden dette endepunktet er multipart (bilde-opplasting), ikke en ren # JSON-body som CommentIn under kan bruke direkte. tags: str | None = Form(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) try: raw_tags = [TagIn(**t) for t in json.loads(tags)] if tags else [] except (json.JSONDecodeError, TypeError, ValueError): raise app_error(400, "VALIDATION_FAILED", "Ugyldig tag-data.") 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) valid_tags = await _validate_and_prepare_tags(conn, round_id, user.user_id, body or "", raw_tags) 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, ) await _insert_tags_and_notify( conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"], round_id=round_id, author_user_id=user.user_id, author_name=display_name, tags=valid_tags, ) # `candidates` gir taggede personers EGNE navn (ikke avsenderens) -- # samme kilde `_validate_and_prepare_tags` allerede validerte mot. candidates = await _taggable_candidates(conn, round_id, user.user_id) tag_outs = [ TagOut(user_id=t.user_id, display_name=candidates.get(t.user_id, ""), start_index=t.start_index, end_index=t.end_index) for t in valid_tags ] message = _row_to_round_message(row, tags=tag_outs) 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 = Field(min_length=1, max_length=2000) parent_comment_id: str | None = None tags: list[TagIn] = [] 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 tags: list[TagOut] = [] def _row_to_round_message_comment(row, tags: list[TagOut] | None = None) -> 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(), tags=tags or [], ) @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, ) comment_ids = [r["id"] for r in rows] tags_by_id = await _tags_for_messages(conn, "round_message_comment_tag", "round_message_comment_id", comment_ids) return [_row_to_round_message_comment(r, tags_by_id.get(r["id"])) 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) valid_tags = await _validate_and_prepare_tags(conn, round_id, user.user_id, body, payload.tags) 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, ) await _insert_tags_and_notify( conn, table="round_message_comment_tag", fk_column="round_message_comment_id", message_id=row["id"], round_id=round_id, author_user_id=user.user_id, author_name=display_name, tags=valid_tags, ) candidates = await _taggable_candidates(conn, round_id, user.user_id) tag_outs = [ TagOut(user_id=t.user_id, display_name=candidates.get(t.user_id, ""), start_index=t.start_index, end_index=t.end_index) for t in valid_tags ] comment = _row_to_round_message_comment(row, tags=tag_outs) # 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 tags: list[TagOut] = [] @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 fc.category 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) tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", 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), tags=tags_by_id.get(r["id"], []), ) for r in rows ]