teecup/app/routers/round_messages.py

549 lines
22 KiB
Python
Raw Normal View History

"""
Kommentarer/bilder frittstående runder (ADR-044) -- "Banter Board" for
en enkelt runde (`round`/migrasjon 020), pluss en samlet `/feed`-side som
aggregerer disse 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), 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 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, 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
2026-08-07 22:02:56 +02:00
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
"""
2026-08-07 22:02:56 +02:00
# 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
2026-08-07 22:02:56 +02:00
reactions: list[ReactionSummary] = []
comment_count: int = 0
2026-08-07 22:02:56 +02:00
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(),
2026-08-07 22:02:56 +02:00
reactions=reactions or [],
comment_count=comment_count,
)
2026-08-07 22:02:56 +02:00
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,
)
2026-08-07 22:02:56 +02:00
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)
2026-08-07 22:02:56 +02:00
# 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(
2026-08-07 22:02:56 +02:00
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at DESC",
round_id,
)
2026-08-07 22:02:56 +02:00
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, max_length=2000),
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)
2026-08-07 22:02:56 +02:00
# ---------------------------------------------------------------------------
# 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)
2026-08-07 22:02:56 +02:00
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
2026-08-07 22:02:56 +02:00
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,
)
2026-08-07 22:02:56 +02:00
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(),
2026-08-07 22:02:56 +02:00
reactions=reactions_by_id.get(r["id"], []),
comment_count=comment_counts.get(r["id"], 0),
)
for r in rows
]