270 lines
11 KiB
Python
270 lines
11 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
|
||
|
|
|
||
|
|
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
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
def _row_to_round_message(row) -> 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(),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _resolve_round_message_author_name(conn, user_id: str) -> str:
|
||
|
|
"""Frosset ved skrivetidspunkt, samme prinsipp som
|
||
|
|
`message.author_display_name` -- her FULLT navn (forfatter-liste, ikke
|
||
|
|
direkte adressering, CLAUDE.md navneformat-regel). Ingen org-scopet
|
||
|
|
`player.display_name` å falle tilbake på her (runder er ikke
|
||
|
|
org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
|
||
|
|
stede, så ingen ekstra fallback trengs."""
|
||
|
|
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"]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 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)
|
||
|
|
rows = await conn.fetch(
|
||
|
|
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at",
|
||
|
|
round_id,
|
||
|
|
)
|
||
|
|
return [_row_to_round_message(r) 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)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 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
|
||
|
|
|
||
|
|
|
||
|
|
@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 NOT 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 NOT 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,
|
||
|
|
)
|
||
|
|
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(),
|
||
|
|
)
|
||
|
|
for r in rows
|
||
|
|
]
|