Bruker rapporterte at en runde delt med kategorien "Make" ikke ble synlig for vedkommendes ektefelle. Diagnostisert direkte mot ekte teecup_db (skrivebeskyttede spørringer): eieren hadde kategorisert ektefellen under tre kategorier, mens runden kun delte to av dem. _can_view_round krevde at ALLE en venns kategorier måtte være i rundens synlige sett -- en presisering fra 2026-07-29 som kun ble dokumentert i kode-kommentarer, aldri i denne ADR-en, og som strammet inn utover ADR-036 Beslutning B sin opprinnelige "minst én kategori"-regel fra 2026-07-25. Reversert til den opprinnelige regelen i alle fire duplikate SQL-steder (_can_view_round, _friends_who_can_see_round, list_friends_on_course i rounds.py; feed-listingen i round_messages.py) -- fra "NOT EXISTS en kategori utenfor synlig sett" til "EXISTS en kategori innenfor synlig sett". Scratch-verifisert i begge retninger: venn med overlappende kategori slipper nå inn, venn med ingen overlappende kategori fortsatt korrekt avvist. Se CHANGELOG.md punkt 44 for full verifiseringsdetalj og bekreftelse mot de ekte, virkelige dataene etter utrulling.
548 lines
22 KiB
Python
548 lines
22 KiB
Python
"""
|
|
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 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)
|
|
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
|
|
]
|