Ekte parallell til frittstående runders RoundMessages (ADR-044), ikke en utvidelse av Banter Board (ADR-025) -- ny org-scopet (RLS) tournament_round_message-tabell, én tråd per turneringsrunde. Reaksjoner og trådede kommentarer med via eksisterende PostEngagement-komponent uendret; @-tagging og sanntid bevisst utenfor v1 (se ADR-102). Migrasjon 090 IKKE kjørt mot ekte teecup_db ennå -- venter på brukerbekreftelse før utrulling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
473 lines
19 KiB
Python
473 lines
19 KiB
Python
"""
|
|
Rundespesifikk kommentartråd for org-turneringer (ADR-102) -- ekte
|
|
parallell til `round_messages.py` sin `RoundMessages` for frittstående
|
|
runder, IKKE en utvidelse av den eksisterende, hele-turnering-brede
|
|
"Banter Board" (`messaging.py`, scope='tournament_feed', ADR-025) --
|
|
den røres ikke av denne modulen.
|
|
|
|
Bevisste forskjeller fra round_messages.py sitt mønster:
|
|
|
|
- `tournament_round_message` (migrasjon 090) har `organization_id` +
|
|
full RLS -- i motsetning til frittstående runders bevisst RLS-frie
|
|
`plain_connection()`-mønster (ADR-033 Beslutning A gjelder ikke her,
|
|
turneringer ER org-scopet, CLAUDE.md-invarianten). All lesing/
|
|
skriving går via `org_connection(organization_id)`.
|
|
- Skriverett = leserett = org-medlemskap (`get_authorized_org`), IKKE
|
|
frittstående sidens `_can_view_round` (eier/medspiller/public/friends-
|
|
kategori har ingen reell motpart i org-turnering-modellen). Samme
|
|
presedens som individual_tournaments.py sin egen modul-docstring
|
|
fastslår for runde-/deltaker-OPPSETT for øvrig ("vanlig org-
|
|
medlemsnivå") -- kommentering er samme kategori handling, ikke den
|
|
strengere self-only-regelen `update_hole` har for selve SCORING-en.
|
|
- Moderering: forfatter ELLER org-admin (`is_org_admin`), speiler
|
|
Banter Board sin egen moderasjonsregel (ADR-025), ikke frittstående
|
|
sidens "forfatter eller RUNDEEIER" (turneringsrunder har ingen eier i
|
|
den forstand).
|
|
- Én tråd PER TURNERINGSRUNDE (hele feltet på tvers av flighter deler
|
|
tråd), bevisst valgt av bruker -- IKKE per flight.
|
|
- Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging, ingen
|
|
WebSocket-kringkasting (den autentiserte turneringsvisningen har ingen
|
|
sanntids-tilkobling i det hele tatt ennå -- eget, uprioritert gap).
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
|
from pydantic import BaseModel, Field
|
|
|
|
from .. import storage
|
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
|
from ..db import org_connection
|
|
from ..errors import app_error, translate_db_errors
|
|
from ..team_authz import is_org_admin
|
|
|
|
router = APIRouter(tags=["tournament-round-messages"])
|
|
|
|
# Samme kuraterte reaksjonssett som round_messages.py/messaging.py (ADR-046).
|
|
ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"}
|
|
|
|
_MESSAGE_COLUMNS = """
|
|
id::text, tournament_round_id::text, author_user_id::text, author_display_name,
|
|
body, image_key, created_at
|
|
"""
|
|
_COMMENT_COLUMNS = """
|
|
id::text, tournament_round_message_id::text, parent_comment_id::text,
|
|
author_user_id::text, author_display_name, body, created_at
|
|
"""
|
|
|
|
|
|
class ReactionIn(BaseModel):
|
|
emoji: str
|
|
|
|
|
|
class ReactionSummary(BaseModel):
|
|
emoji: str
|
|
count: int
|
|
reacted_by_me: bool
|
|
reactors: list[str] = []
|
|
|
|
|
|
class RoundMessageOut(BaseModel):
|
|
id: str
|
|
tournament_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
|
|
|
|
|
|
class CommentIn(BaseModel):
|
|
body: str = Field(min_length=1, max_length=2000)
|
|
parent_comment_id: str | None = None
|
|
|
|
|
|
class CommentOut(BaseModel):
|
|
id: str
|
|
tournament_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_message(
|
|
row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
|
|
) -> RoundMessageOut:
|
|
return RoundMessageOut(
|
|
id=row["id"],
|
|
tournament_round_id=row["tournament_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,
|
|
)
|
|
|
|
|
|
def _row_to_comment(row) -> CommentOut:
|
|
return CommentOut(
|
|
id=row["id"],
|
|
tournament_round_message_id=row["tournament_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(),
|
|
)
|
|
|
|
|
|
async def _resolve_author_name(conn, user_id: str) -> str:
|
|
"""Fullt navn (roster-/forfatter-kontekst, ikke direkte adressering --
|
|
CLAUDE.md navneformat-regel), frosset ved skrivetidspunkt. Egen, lokal
|
|
kopi av samme oppslag round_messages.py/messaging.py hver har sin egen
|
|
versjon av -- denne modulen har ikke tidligere importert fra noen av
|
|
dem, og hører uansett ikke hjemme i rounds.py (frittstående-side)."""
|
|
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"]
|
|
|
|
|
|
async def _get_tournament_round_or_404(conn, tournament_id: str, round_id: str) -> None:
|
|
row = await conn.fetchval(
|
|
"SELECT tournament_id::text FROM tournament_round WHERE id = $1", round_id
|
|
)
|
|
if row is None or row != tournament_id:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
|
async def _get_message_or_404(conn, round_id: str, message_id: str) -> None:
|
|
exists = await conn.fetchval(
|
|
"SELECT id FROM tournament_round_message WHERE id = $1 AND tournament_round_id = $2",
|
|
message_id, round_id,
|
|
)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
|
|
|
|
|
|
async def _message_reactions(
|
|
conn, message_ids: list[str], viewer_user_id: str
|
|
) -> dict[str, list[ReactionSummary]]:
|
|
if not message_ids:
|
|
return {}
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT trmr.tournament_round_message_id::text AS message_id, trmr.emoji, count(*) AS count,
|
|
COALESCE(bool_or(trmr.user_id = $2), false) AS reacted_by_me,
|
|
array_agg(
|
|
COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name)
|
|
ORDER BY trmr.created_at
|
|
) AS reactors
|
|
FROM tournament_round_message_reaction trmr
|
|
JOIN app_user au ON au.id = trmr.user_id
|
|
WHERE trmr.tournament_round_message_id = ANY($1::uuid[])
|
|
GROUP BY trmr.tournament_round_message_id, trmr.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 _message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]:
|
|
if not message_ids:
|
|
return {}
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT tournament_round_message_id::text AS message_id, count(*) AS count
|
|
FROM tournament_round_message_comment
|
|
WHERE tournament_round_message_id = ANY($1::uuid[])
|
|
GROUP BY tournament_round_message_id
|
|
""",
|
|
message_ids,
|
|
)
|
|
return {r["message_id"]: r["count"] for r in rows}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runde-nivå kommentarer/bilder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages",
|
|
response_model=list[RoundMessageOut],
|
|
)
|
|
async def list_tournament_round_messages(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> list[RoundMessageOut]:
|
|
async with org_connection(organization_id) as conn:
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
# Nyeste øverst -- samme "oppdateringsstrøm, ikke kronologisk
|
|
# samtale"-mønster som round_messages.py (ADR-044).
|
|
rows = await conn.fetch(
|
|
f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message "
|
|
"WHERE tournament_round_id = $1 ORDER BY created_at DESC",
|
|
round_id,
|
|
)
|
|
message_ids = [r["id"] for r in rows]
|
|
reactions_by_id = await _message_reactions(conn, message_ids, user.user_id)
|
|
comment_counts = await _message_comment_counts(conn, message_ids)
|
|
return [
|
|
_row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages",
|
|
response_model=RoundMessageOut,
|
|
status_code=201,
|
|
)
|
|
async def post_tournament_round_message(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
body: str | None = Form(default=None, max_length=2000),
|
|
image: UploadFile | None = File(default=None),
|
|
client_message_id: str | None = Form(default=None),
|
|
organization_id: str = Depends(get_authorized_org),
|
|
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 org_connection(organization_id) as conn, translate_db_errors():
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
|
|
if client_message_id is not None:
|
|
existing = await conn.fetchrow(
|
|
f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message "
|
|
"WHERE tournament_round_id = $1 AND client_message_id = $2",
|
|
round_id, client_message_id,
|
|
)
|
|
if existing is not None:
|
|
reactions = await _message_reactions(conn, [existing["id"]], user.user_id)
|
|
comment_counts = await _message_comment_counts(conn, [existing["id"]])
|
|
return _row_to_message(
|
|
existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0)
|
|
)
|
|
|
|
image_key = None
|
|
if raw is not None:
|
|
try:
|
|
image_key = await storage.upload_image("tournament_round_messages", round_id, raw)
|
|
except storage.InvalidImageError:
|
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
|
|
|
display_name = await _resolve_author_name(conn, user.user_id)
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
INSERT INTO tournament_round_message
|
|
(organization_id, tournament_round_id, author_user_id, author_display_name,
|
|
body, image_key, client_message_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING {_MESSAGE_COLUMNS}
|
|
""",
|
|
organization_id, round_id, user.user_id, display_name, body, image_key, client_message_id,
|
|
)
|
|
return _row_to_message(row)
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}",
|
|
status_code=204,
|
|
)
|
|
async def delete_tournament_round_message(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
message_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> None:
|
|
"""Forfatteren selv, ELLER org-eier/admin -- samme modell som Banter
|
|
Board sin `delete_feed_message` (ADR-025), ikke frittstående sidens
|
|
"runde-eier" (turneringsrunder har ingen eier i den forstand)."""
|
|
async with org_connection(organization_id) as conn:
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
author_id = await conn.fetchval(
|
|
"SELECT author_user_id::text FROM tournament_round_message "
|
|
"WHERE id = $1 AND tournament_round_id = $2",
|
|
message_id, round_id,
|
|
)
|
|
if author_id is None:
|
|
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
|
|
if user.user_id != author_id and not await is_org_admin(conn, organization_id, user.user_id):
|
|
raise app_error(
|
|
403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne innlegg (eller som organisasjonsadministrator)."
|
|
)
|
|
await conn.execute("DELETE FROM tournament_round_message WHERE id = $1", message_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reaksjoner
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.put(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction",
|
|
response_model=list[ReactionSummary],
|
|
)
|
|
async def set_tournament_round_message_reaction(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
message_id: str,
|
|
payload: ReactionIn,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
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 org_connection(organization_id) as conn:
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
await _get_message_or_404(conn, round_id, message_id)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO tournament_round_message_reaction (organization_id, tournament_round_message_id, user_id, emoji)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (tournament_round_message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
|
|
""",
|
|
organization_id, message_id, user.user_id, payload.emoji,
|
|
)
|
|
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
|
return summaries.get(message_id, [])
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction",
|
|
response_model=list[ReactionSummary],
|
|
)
|
|
async def remove_tournament_round_message_reaction(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
message_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> list[ReactionSummary]:
|
|
async with org_connection(organization_id) as conn:
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
await _get_message_or_404(conn, round_id, message_id)
|
|
await conn.execute(
|
|
"DELETE FROM tournament_round_message_reaction WHERE tournament_round_message_id = $1 AND user_id = $2",
|
|
message_id, user.user_id,
|
|
)
|
|
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
|
return summaries.get(message_id, [])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trådede kommentarer -- flat lagring (parent_comment_id), frontend bygger
|
|
# tre-strukturen. Kronologisk (eldst først), ulikt meldingslisten selv.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments",
|
|
response_model=list[CommentOut],
|
|
)
|
|
async def list_tournament_round_message_comments(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
message_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[CommentOut]:
|
|
async with org_connection(organization_id) as conn:
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
await _get_message_or_404(conn, round_id, message_id)
|
|
rows = await conn.fetch(
|
|
f"SELECT {_COMMENT_COLUMNS} FROM tournament_round_message_comment "
|
|
"WHERE tournament_round_message_id = $1 ORDER BY created_at ASC",
|
|
message_id,
|
|
)
|
|
return [_row_to_comment(r) for r in rows]
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments",
|
|
response_model=CommentOut,
|
|
status_code=201,
|
|
)
|
|
async def post_tournament_round_message_comment(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
message_id: str,
|
|
payload: CommentIn,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
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 org_connection(organization_id) as conn, translate_db_errors():
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
await _get_message_or_404(conn, round_id, message_id)
|
|
if payload.parent_comment_id is not None:
|
|
parent_exists = await conn.fetchval(
|
|
"SELECT id FROM tournament_round_message_comment "
|
|
"WHERE id = $1 AND tournament_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_author_name(conn, user.user_id)
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
INSERT INTO tournament_round_message_comment
|
|
(organization_id, tournament_round_message_id, parent_comment_id, author_user_id, author_display_name, body)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING {_COMMENT_COLUMNS}
|
|
""",
|
|
organization_id, message_id, payload.parent_comment_id, user.user_id, display_name, body,
|
|
)
|
|
return _row_to_comment(row)
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments/{comment_id}",
|
|
status_code=204,
|
|
)
|
|
async def delete_tournament_round_message_comment(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
message_id: str,
|
|
comment_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> None:
|
|
"""Forfatteren selv, ELLER org-eier/admin -- samme modell som selve
|
|
innlegget. Kaskade-sletter eventuelle svar under (FK ON DELETE CASCADE)."""
|
|
async with org_connection(organization_id) as conn:
|
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
|
await _get_message_or_404(conn, round_id, message_id)
|
|
author_id = await conn.fetchval(
|
|
"SELECT author_user_id::text FROM tournament_round_message_comment "
|
|
"WHERE id = $1 AND tournament_round_message_id = $2",
|
|
comment_id, message_id,
|
|
)
|
|
if author_id is None:
|
|
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
|
if user.user_id != author_id and not await is_org_admin(conn, organization_id, user.user_id):
|
|
raise app_error(
|
|
403, "NOT_COMMENT_AUTHOR",
|
|
"Du kan kun slette dine egne kommentarer (eller som organisasjonsadministrator).",
|
|
)
|
|
await conn.execute("DELETE FROM tournament_round_message_comment WHERE id = $1", comment_id)
|