teecup/app/routers/round_messages.py
Erol Haagenrud 8b1c73cf4f Fiks rundedeling: minst én kategori er nok, ikke alle (ADR-036 E)
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.
2026-08-08 12:37:09 +02:00

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
]