2026-07-19 22:26:45 +02:00
|
|
|
"""
|
|
|
|
|
Kommunikasjon (ADR-025): lag-intern chat («det hemmelige rommet») og
|
|
|
|
|
offentlig runde-feed («Banter Board»). Delt `message`-tabell (migrasjon 013)
|
|
|
|
|
med en scope-diskriminator, men to helt ulike autorisasjonsmodeller:
|
|
|
|
|
|
|
|
|
|
- Lag-chat (`router`, /orgs/...): org-medlemskap (get_authorized_org) +
|
|
|
|
|
`user_is_rostered_on_team` -- BEVISST INGEN org-admin-unntak, ulikt resten
|
|
|
|
|
av appen. Ekte privat.
|
|
|
|
|
- Offentlig feed (`public_router`, /public/tournaments/...): LESING
|
|
|
|
|
gjenbruker registration.py sitt trenivå-visibility-mønster uendret
|
|
|
|
|
(inkl. anonym tilgang for `public`-synlige turneringer). POSTING/SLETTING
|
|
|
|
|
er strengere -- krever ekte innlogging OG org-medlemskap ELLER faktisk
|
|
|
|
|
deltakelse i akkurat denne turneringen (ikke bare "kan se den").
|
|
|
|
|
|
|
|
|
|
Sanntid via WebSockets (ADR-025 Beslutning B): et rent in-memory
|
|
|
|
|
tilkoblingsregister PER PROSESS -- trygt med dagens ene `teecup_api`-
|
|
|
|
|
container, men deles IKKE på tvers av flere prosesser/containere (samme
|
|
|
|
|
klasse begrensning som den allerede aksepterte in-memory-cachen, se
|
|
|
|
|
ARCHITECTURE_DECISIONS.md sine "Åpne spørsmål"). WS-rutene kan ikke bruke
|
|
|
|
|
get_current_user/get_authorized_org direkte (de er Request-typet, ikke
|
|
|
|
|
WebSocket-kompatible via Depends()) -- egne, minimale kopier av samme
|
|
|
|
|
sjekker brukes i stedet.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, UploadFile, WebSocket, WebSocketDisconnect
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
from ..auth import CurrentUser, get_current_user, get_current_user_optional
|
2026-07-19 22:26:45 +02:00
|
|
|
from ..auth import get_current_user_from_websocket
|
|
|
|
|
from ..db import org_connection, plain_connection
|
|
|
|
|
from ..errors import app_error, translate_db_errors
|
2026-07-19 23:14:17 +02:00
|
|
|
from ..realtime import live_sockets_for
|
2026-07-19 22:26:45 +02:00
|
|
|
from ..team_authz import is_org_admin, user_is_rostered_on_team
|
|
|
|
|
from .. import storage
|
|
|
|
|
from .registration import check_visibility, code_matches, is_participant, resolve_org
|
2026-08-07 22:02:56 +02:00
|
|
|
from .round_messages import ALLOWED_REACTION_EMOJIS, CommentIn, ReactionIn, ReactionSummary
|
2026-07-19 22:26:45 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(tags=["messaging"])
|
|
|
|
|
public_router = APIRouter(prefix="/public/tournaments", tags=["public-messaging"])
|
|
|
|
|
|
|
|
|
|
_MESSAGE_COLUMNS = """
|
|
|
|
|
id::text, author_user_id::text, author_display_name, body, image_key, created_at
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MessageOut(BaseModel):
|
|
|
|
|
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-07-19 22:26:45 +02:00
|
|
|
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
def _row_to_message(
|
|
|
|
|
row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
|
|
|
|
|
) -> MessageOut:
|
2026-07-19 22:26:45 +02:00
|
|
|
return MessageOut(
|
|
|
|
|
id=row["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-07-19 22:26:45 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
# --- Reaksjoner/kommentarer (ADR-046) -- delt mellom lag-chat og ---------
|
|
|
|
|
# --- oppslagstavle, siden begge lagrer i `message` med scope-diskriminator.
|
|
|
|
|
|
|
|
|
|
_MESSAGE_COMMENT_COLUMNS = """
|
|
|
|
|
id::text, message_id::text, parent_comment_id::text, author_user_id::text,
|
|
|
|
|
author_display_name, body, created_at
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CommentOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
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_comment(row) -> CommentOut:
|
|
|
|
|
return CommentOut(
|
|
|
|
|
id=row["id"],
|
|
|
|
|
message_id=row["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 _message_reactions(
|
|
|
|
|
conn, message_ids: list[str], viewer_user_id: str | None
|
|
|
|
|
) -> dict[str, list[ReactionSummary]]:
|
|
|
|
|
if not message_ids:
|
|
|
|
|
return {}
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT mr.message_id::text AS message_id, mr.emoji, count(*) AS count,
|
|
|
|
|
COALESCE(bool_or(mr.user_id = $2), false) AS reacted_by_me,
|
|
|
|
|
array_agg(
|
|
|
|
|
COALESCE(p.display_name, split_part(au.email, '@', 1))
|
|
|
|
|
ORDER BY mr.created_at
|
|
|
|
|
) AS reactors
|
|
|
|
|
FROM message_reaction mr
|
|
|
|
|
JOIN app_user au ON au.id = mr.user_id
|
|
|
|
|
LEFT JOIN player p ON p.organization_id = mr.organization_id AND p.user_id = mr.user_id
|
|
|
|
|
WHERE mr.message_id = ANY($1::uuid[])
|
|
|
|
|
GROUP BY mr.message_id, mr.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 message_id::text AS message_id, count(*) AS count
|
|
|
|
|
FROM message_comment
|
|
|
|
|
WHERE message_id = ANY($1::uuid[])
|
|
|
|
|
GROUP BY message_id
|
|
|
|
|
""",
|
|
|
|
|
message_ids,
|
|
|
|
|
)
|
|
|
|
|
return {r["message_id"]: r["count"] for r in rows}
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 22:26:45 +02:00
|
|
|
async def _resolve_author_display_name(conn, organization_id: str, user_id: str) -> str:
|
|
|
|
|
"""Fryses ved skrivetidspunkt (samme prinsipp som handicap_index_snapshot,
|
|
|
|
|
ADR-007) -- avsenderens player.display_name i DENNE org-en hvis den
|
|
|
|
|
finnes, ellers e-postens lokaldel (dekker org-ansatte uten egen
|
|
|
|
|
spillerprofil som poster i den offentlige feeden)."""
|
|
|
|
|
name = await conn.fetchval(
|
|
|
|
|
"SELECT display_name FROM player WHERE organization_id = $1 AND user_id = $2 LIMIT 1",
|
|
|
|
|
organization_id,
|
|
|
|
|
user_id,
|
|
|
|
|
)
|
|
|
|
|
if name:
|
|
|
|
|
return name
|
|
|
|
|
async with plain_connection() as plain:
|
|
|
|
|
email = await plain.fetchval("SELECT email FROM app_user WHERE id = $1", user_id)
|
|
|
|
|
return email.split("@")[0] if email else "Ukjent"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Sanntid: in-memory tilkoblingsregister (se moduldocstring) ------------
|
|
|
|
|
|
|
|
|
|
_team_sockets: dict[str, set[WebSocket]] = defaultdict(set)
|
|
|
|
|
_feed_sockets: dict[str, set[WebSocket]] = defaultdict(set)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _broadcast(sockets: set[WebSocket], payload: dict) -> None:
|
|
|
|
|
dead = []
|
|
|
|
|
for ws in list(sockets):
|
|
|
|
|
try:
|
|
|
|
|
await ws.send_json(payload)
|
|
|
|
|
except Exception:
|
|
|
|
|
dead.append(ws)
|
|
|
|
|
for ws in dead:
|
|
|
|
|
sockets.discard(ws)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _is_org_member(user_id: str, organization_id: str) -> bool:
|
|
|
|
|
"""WS-variant av get_authorized_org sin medlemskapssjekk -- samme grunn
|
|
|
|
|
som get_current_user_from_websocket: ingen HTTP Request å binde
|
|
|
|
|
Depends() til i en WS-scope."""
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
return bool(
|
|
|
|
|
await conn.fetchval(
|
|
|
|
|
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)",
|
|
|
|
|
user_id,
|
|
|
|
|
organization_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Lag-chat (privat) -- /orgs/...
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/orgs/{organization_id}/teams/{team_id}/messages", response_model=list[MessageOut])
|
|
|
|
|
async def list_team_messages(
|
|
|
|
|
team_id: str,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
organization_id: str,
|
2026-07-19 22:26:45 +02:00
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> list[MessageOut]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
exists = await conn.fetchval("SELECT id FROM team WHERE id = $1", team_id)
|
|
|
|
|
if exists is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
|
|
|
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
|
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
2026-08-07 22:02:56 +02:00
|
|
|
# Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over
|
|
|
|
|
# alt", samme rekkefølge som round_message og tournament_feed nå).
|
2026-07-19 22:26:45 +02:00
|
|
|
rows = await conn.fetch(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT {_MESSAGE_COLUMNS} FROM message
|
|
|
|
|
WHERE team_id = $1 AND scope = 'team'
|
2026-08-07 22:02:56 +02:00
|
|
|
ORDER BY created_at DESC
|
2026-07-19 22:26:45 +02:00
|
|
|
""",
|
|
|
|
|
team_id,
|
|
|
|
|
)
|
2026-08-07 22:02:56 +02:00
|
|
|
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
|
|
|
|
|
]
|
2026-07-19 22:26:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/orgs/{organization_id}/teams/{team_id}/messages", response_model=MessageOut, status_code=201
|
|
|
|
|
)
|
|
|
|
|
async def send_team_message(
|
|
|
|
|
team_id: str,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
organization_id: str,
|
2026-07-19 22:26:45 +02:00
|
|
|
body: str | None = Form(default=None),
|
|
|
|
|
image: UploadFile | None = File(default=None),
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> MessageOut:
|
|
|
|
|
if not body and image is None:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
2026-08-06 07:01:22 +02:00
|
|
|
raw = await storage.read_optional_image(image)
|
2026-07-19 22:26:45 +02:00
|
|
|
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
team = await conn.fetchrow(
|
|
|
|
|
"SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1", team_id
|
|
|
|
|
)
|
|
|
|
|
if team is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
|
|
|
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
|
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
|
|
|
|
|
|
|
|
|
image_key = None
|
|
|
|
|
if raw is not None:
|
|
|
|
|
try:
|
|
|
|
|
image_key = await storage.upload_image("messages", team_id, raw)
|
|
|
|
|
except storage.InvalidImageError:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
|
|
|
|
|
|
|
|
|
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
|
|
|
|
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
INSERT INTO message
|
|
|
|
|
(organization_id, scope, tournament_id, team_id, author_user_id, author_display_name, body, image_key)
|
|
|
|
|
VALUES ($1, 'team', $2, $3, $4, $5, $6, $7)
|
|
|
|
|
RETURNING {_MESSAGE_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
team["tournament_id"],
|
|
|
|
|
team_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
display_name,
|
|
|
|
|
body,
|
|
|
|
|
image_key,
|
|
|
|
|
)
|
|
|
|
|
message = _row_to_message(row)
|
|
|
|
|
await _broadcast(_team_sockets[team_id], message.model_dump())
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}", status_code=204
|
|
|
|
|
)
|
|
|
|
|
async def delete_team_message(
|
|
|
|
|
team_id: str,
|
|
|
|
|
message_id: str,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
organization_id: str,
|
2026-07-19 22:26:45 +02:00
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Kun forfatteren selv -- INGEN org-admin-unntak (samme begrunnelse som
|
|
|
|
|
lesetilgangen: et ekte privat rom har ingen ekstern moderator)."""
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT author_user_id::text AS author_user_id FROM message
|
|
|
|
|
WHERE id = $1 AND team_id = $2 AND scope = 'team'
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
team_id,
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
|
|
|
|
if row["author_user_id"] != user.user_id:
|
|
|
|
|
raise app_error(403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne meldinger.")
|
|
|
|
|
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
# --- Reaksjoner/kommentarer på lag-chat-meldinger (ADR-046) ---------------
|
|
|
|
|
# Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve
|
|
|
|
|
# meldingen (rostret på laget); sletting av kommentar er kun forfatteren,
|
|
|
|
|
# samme "ekte privat rom" begrunnelse som `delete_team_message`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_team_message_or_404(conn, team_id: str, message_id: str) -> None:
|
|
|
|
|
exists = await conn.fetchval(
|
|
|
|
|
"SELECT id FROM message WHERE id = $1 AND team_id = $2 AND scope = 'team'",
|
|
|
|
|
message_id,
|
|
|
|
|
team_id,
|
|
|
|
|
)
|
|
|
|
|
if exists is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put(
|
|
|
|
|
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/reaction",
|
|
|
|
|
response_model=list[ReactionSummary],
|
|
|
|
|
)
|
|
|
|
|
async def set_team_message_reaction(
|
|
|
|
|
team_id: str,
|
|
|
|
|
organization_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 org_connection(organization_id) as conn:
|
|
|
|
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
|
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
|
|
|
|
await _get_team_message_or_404(conn, team_id, message_id)
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO message_reaction (organization_id, message_id, user_id, emoji)
|
|
|
|
|
VALUES ($1, $2, $3, $4)
|
|
|
|
|
ON CONFLICT (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}/teams/{team_id}/messages/{message_id}/reaction",
|
|
|
|
|
response_model=list[ReactionSummary],
|
|
|
|
|
)
|
|
|
|
|
async def remove_team_message_reaction(
|
|
|
|
|
team_id: str,
|
|
|
|
|
organization_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> list[ReactionSummary]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
|
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
|
|
|
|
await _get_team_message_or_404(conn, team_id, message_id)
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"DELETE FROM message_reaction WHERE 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, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments",
|
|
|
|
|
response_model=list[CommentOut],
|
|
|
|
|
)
|
|
|
|
|
async def list_team_message_comments(
|
|
|
|
|
team_id: str,
|
|
|
|
|
organization_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> list[CommentOut]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
|
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
|
|
|
|
await _get_team_message_or_404(conn, team_id, message_id)
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment
|
|
|
|
|
WHERE message_id = $1 ORDER BY created_at ASC
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
)
|
|
|
|
|
return [_row_to_message_comment(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments",
|
|
|
|
|
response_model=CommentOut,
|
|
|
|
|
status_code=201,
|
|
|
|
|
)
|
|
|
|
|
async def post_team_message_comment(
|
|
|
|
|
team_id: str,
|
|
|
|
|
organization_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 org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
|
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
|
|
|
|
await _get_team_message_or_404(conn, team_id, message_id)
|
|
|
|
|
if payload.parent_comment_id is not None:
|
|
|
|
|
parent_exists = await conn.fetchval(
|
|
|
|
|
"SELECT id FROM message_comment WHERE id = $1 AND 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_display_name(conn, organization_id, user.user_id)
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
INSERT INTO message_comment
|
|
|
|
|
(organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
|
|
|
RETURNING {_MESSAGE_COMMENT_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
message_id,
|
|
|
|
|
payload.parent_comment_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
display_name,
|
|
|
|
|
body,
|
|
|
|
|
)
|
|
|
|
|
return _row_to_message_comment(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments/{comment_id}",
|
|
|
|
|
status_code=204,
|
|
|
|
|
)
|
|
|
|
|
async def delete_team_message_comment(
|
|
|
|
|
team_id: str,
|
|
|
|
|
organization_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
comment_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Kun forfatteren selv, INGEN unntak -- samme "ekte privat rom"-
|
|
|
|
|
begrunnelse som `delete_team_message`."""
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT author_user_id::text AS author_user_id FROM message_comment
|
|
|
|
|
WHERE id = $1 AND message_id = $2
|
|
|
|
|
""",
|
|
|
|
|
comment_id,
|
|
|
|
|
message_id,
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
|
|
|
|
if row["author_user_id"] != user.user_id:
|
|
|
|
|
raise app_error(403, "NOT_COMMENT_AUTHOR", "Du kan kun slette dine egne kommentarer.")
|
|
|
|
|
await conn.execute("DELETE FROM message_comment WHERE id = $1", comment_id)
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 22:26:45 +02:00
|
|
|
@router.websocket("/ws/orgs/{organization_id}/teams/{team_id}/messages")
|
|
|
|
|
async def team_chat_ws(websocket: WebSocket, organization_id: str, team_id: str) -> None:
|
|
|
|
|
user = await get_current_user_from_websocket(websocket)
|
|
|
|
|
if user is None:
|
|
|
|
|
await websocket.close(code=4401)
|
|
|
|
|
return
|
|
|
|
|
if not await _is_org_member(user.user_id, organization_id):
|
|
|
|
|
await websocket.close(code=4403)
|
|
|
|
|
return
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
allowed = await user_is_rostered_on_team(conn, team_id, user.user_id)
|
|
|
|
|
if not allowed:
|
|
|
|
|
await websocket.close(code=4403)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
_team_sockets[team_id].add(websocket)
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
# Klienten sender ingenting -- denne løkken finnes kun for å
|
|
|
|
|
# oppdage disconnect (receive() kaster WebSocketDisconnect da).
|
|
|
|
|
await websocket.receive_text()
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
_team_sockets[team_id].discard(websocket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Offentlig runde-feed -- /public/tournaments/...
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _may_post_to_feed(conn, user: CurrentUser, organization_id: str, tournament_id: str) -> bool:
|
|
|
|
|
"""Strengere enn LESING (check_visibility): en 'public'-synlig turnering
|
|
|
|
|
kan leses helt anonymt, men posting krever alltid ekte innlogging OG
|
|
|
|
|
tilknytning -- org-medlem ELLER faktisk deltaker/registrert i NØYAKTIG
|
|
|
|
|
denne turneringen. Hindrer at en helt urelatert innlogget bruker (konto
|
|
|
|
|
et helt annet sted i systemet) kan poste på en fremmed offentlig side."""
|
|
|
|
|
is_member = await conn.fetchval(
|
|
|
|
|
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)",
|
|
|
|
|
user.user_id,
|
|
|
|
|
organization_id,
|
|
|
|
|
)
|
|
|
|
|
if is_member:
|
|
|
|
|
return True
|
|
|
|
|
return await is_participant(conn, user.user_id, organization_id, tournament_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.get("/{tournament_id}/feed", response_model=list[MessageOut])
|
|
|
|
|
async def get_feed(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
code: str | None = None,
|
|
|
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
|
|
|
) -> list[MessageOut]:
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
if not code_matches(row["join_code"], code):
|
|
|
|
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
# Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over
|
|
|
|
|
# alt", samme rekkefølge som round_message og lag-chatten nå).
|
2026-07-19 22:26:45 +02:00
|
|
|
rows = await conn.fetch(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT {_MESSAGE_COLUMNS} FROM message
|
|
|
|
|
WHERE tournament_id = $1 AND scope = 'tournament_feed'
|
2026-08-07 22:02:56 +02:00
|
|
|
ORDER BY created_at DESC
|
2026-07-19 22:26:45 +02:00
|
|
|
""",
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
2026-08-07 22:02:56 +02:00
|
|
|
message_ids = [r["id"] for r in rows]
|
|
|
|
|
reactions_by_id = await _message_reactions(conn, message_ids, user.user_id if user else None)
|
|
|
|
|
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
|
|
|
|
|
]
|
2026-07-19 22:26:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
|
|
|
|
|
async def post_to_feed(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
body: str | None = Form(default=None),
|
|
|
|
|
image: UploadFile | None = File(default=None),
|
|
|
|
|
code: str | None = Form(default=None),
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> MessageOut:
|
|
|
|
|
if not body and image is None:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
2026-08-06 07:01:22 +02:00
|
|
|
raw = await storage.read_optional_image(image)
|
2026-07-19 22:26:45 +02:00
|
|
|
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
if not code_matches(row["join_code"], code):
|
|
|
|
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
|
|
|
|
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
|
|
|
|
raise app_error(
|
|
|
|
|
403,
|
|
|
|
|
"NOT_A_PARTICIPANT",
|
|
|
|
|
"Du må være medlem av organisasjonen eller delta i turneringen for å poste.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
image_key = None
|
|
|
|
|
if raw is not None:
|
|
|
|
|
try:
|
|
|
|
|
image_key = await storage.upload_image("messages", tournament_id, raw)
|
|
|
|
|
except storage.InvalidImageError:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
|
|
|
|
|
|
|
|
|
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
|
|
|
|
|
|
|
|
|
inserted = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
INSERT INTO message
|
|
|
|
|
(organization_id, scope, tournament_id, team_id, author_user_id, author_display_name, body, image_key)
|
|
|
|
|
VALUES ($1, 'tournament_feed', $2, NULL, $3, $4, $5, $6)
|
|
|
|
|
RETURNING {_MESSAGE_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
display_name,
|
|
|
|
|
body,
|
|
|
|
|
image_key,
|
|
|
|
|
)
|
|
|
|
|
message = _row_to_message(inserted)
|
|
|
|
|
await _broadcast(_feed_sockets[tournament_id], message.model_dump())
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.delete("/{tournament_id}/feed/{message_id}", status_code=204)
|
|
|
|
|
async def delete_feed_message(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Forfatteren selv, ELLER org-eier/admin (moderering) -- ulikt lag-
|
|
|
|
|
chatten, siden feeden er offentlig og derfor trenger en reell
|
|
|
|
|
moderasjonsvei."""
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT author_user_id::text AS author_user_id FROM message
|
|
|
|
|
WHERE id = $1 AND tournament_id = $2 AND scope = 'tournament_feed'
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
|
|
|
|
if row["author_user_id"] != user.user_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 meldinger (eller som organisasjonsadministrator)."
|
|
|
|
|
)
|
|
|
|
|
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
# --- Reaksjoner/kommentarer på oppslagstavle-innlegg (ADR-046) -----------
|
|
|
|
|
# Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve
|
|
|
|
|
# innlegget (`_may_post_to_feed`); sletting av kommentar er forfatter
|
|
|
|
|
# ELLER org-admin, samme moderasjonsvei som `delete_feed_message`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_feed_message_or_404(conn, tournament_id: str, message_id: str) -> None:
|
|
|
|
|
exists = await conn.fetchval(
|
|
|
|
|
"SELECT id FROM message WHERE id = $1 AND tournament_id = $2 AND scope = 'tournament_feed'",
|
|
|
|
|
message_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
if exists is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.put("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary])
|
|
|
|
|
async def set_feed_message_reaction(
|
|
|
|
|
tournament_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.")
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
|
|
|
|
raise app_error(
|
|
|
|
|
403,
|
|
|
|
|
"NOT_A_PARTICIPANT",
|
|
|
|
|
"Du må være medlem av organisasjonen eller delta i turneringen for å reagere.",
|
|
|
|
|
)
|
|
|
|
|
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO message_reaction (organization_id, message_id, user_id, emoji)
|
|
|
|
|
VALUES ($1, $2, $3, $4)
|
|
|
|
|
ON CONFLICT (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, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.delete("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary])
|
|
|
|
|
async def remove_feed_message_reaction(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> list[ReactionSummary]:
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
|
|
|
|
raise app_error(
|
|
|
|
|
403,
|
|
|
|
|
"NOT_A_PARTICIPANT",
|
|
|
|
|
"Du må være medlem av organisasjonen eller delta i turneringen for å reagere.",
|
|
|
|
|
)
|
|
|
|
|
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"DELETE FROM message_reaction WHERE 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, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.get("/{tournament_id}/feed/{message_id}/comments", response_model=list[CommentOut])
|
|
|
|
|
async def list_feed_message_comments(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
code: str | None = None,
|
|
|
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
|
|
|
) -> list[CommentOut]:
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
if not code_matches(row["join_code"], code):
|
|
|
|
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
|
|
|
|
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment
|
|
|
|
|
WHERE message_id = $1 ORDER BY created_at ASC
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
)
|
|
|
|
|
return [_row_to_message_comment(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.post("/{tournament_id}/feed/{message_id}/comments", response_model=CommentOut, status_code=201)
|
|
|
|
|
async def post_feed_message_comment(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
payload: CommentIn,
|
|
|
|
|
code: str | None = None,
|
|
|
|
|
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.")
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
if not code_matches(row["join_code"], code):
|
|
|
|
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
|
|
|
|
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
|
|
|
|
raise app_error(
|
|
|
|
|
403,
|
|
|
|
|
"NOT_A_PARTICIPANT",
|
|
|
|
|
"Du må være medlem av organisasjonen eller delta i turneringen for å kommentere.",
|
|
|
|
|
)
|
|
|
|
|
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
|
|
|
|
if payload.parent_comment_id is not None:
|
|
|
|
|
parent_exists = await conn.fetchval(
|
|
|
|
|
"SELECT id FROM message_comment WHERE id = $1 AND 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_display_name(conn, organization_id, user.user_id)
|
|
|
|
|
inserted = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
INSERT INTO message_comment
|
|
|
|
|
(organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
|
|
|
RETURNING {_MESSAGE_COMMENT_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
message_id,
|
|
|
|
|
payload.parent_comment_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
display_name,
|
|
|
|
|
body,
|
|
|
|
|
)
|
|
|
|
|
return _row_to_message_comment(inserted)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@public_router.delete("/{tournament_id}/feed/{message_id}/comments/{comment_id}", status_code=204)
|
|
|
|
|
async def delete_feed_message_comment(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
comment_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Forfatteren selv, ELLER org-eier/admin -- samme moderasjonsvei som
|
|
|
|
|
`delete_feed_message`."""
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT author_user_id::text AS author_user_id FROM message_comment
|
|
|
|
|
WHERE id = $1 AND message_id = $2
|
|
|
|
|
""",
|
|
|
|
|
comment_id,
|
|
|
|
|
message_id,
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
|
|
|
|
if row["author_user_id"] != user.user_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 message_comment WHERE id = $1", comment_id)
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 22:26:45 +02:00
|
|
|
@router.websocket("/ws/public/tournaments/{tournament_id}/feed")
|
|
|
|
|
async def feed_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None:
|
|
|
|
|
user = await get_current_user_from_websocket(websocket)
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
await websocket.close(code=4404)
|
|
|
|
|
return
|
|
|
|
|
if not code_matches(row["join_code"], code):
|
|
|
|
|
try:
|
|
|
|
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
|
|
|
|
except Exception:
|
|
|
|
|
await websocket.close(code=4403)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
_feed_sockets[tournament_id].add(websocket)
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
await websocket.receive_text()
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
_feed_sockets[tournament_id].discard(websocket)
|
2026-07-19 23:14:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.websocket("/ws/public/tournaments/{tournament_id}/live")
|
|
|
|
|
async def tournament_live_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None:
|
|
|
|
|
"""ADR-027: "Følg live"-siden (leaderboard/matcher/scorekort,
|
|
|
|
|
registration.py) sin sanntid. Sender kun et "noe endret seg"-signal --
|
|
|
|
|
se app/realtime.py sin moduldocstring for hvorfor -- klienten reagerer
|
|
|
|
|
ved å hente de vanlige REST-endepunktene på nytt. Samme visibility-sjekk
|
|
|
|
|
som selve de offentlige lese-endepunktene den speiler."""
|
|
|
|
|
user = await get_current_user_from_websocket(websocket)
|
|
|
|
|
organization_id = await resolve_org(tournament_id)
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
await websocket.close(code=4404)
|
|
|
|
|
return
|
|
|
|
|
if not code_matches(row["join_code"], code):
|
|
|
|
|
try:
|
|
|
|
|
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
|
|
|
|
except Exception:
|
|
|
|
|
await websocket.close(code=4403)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await websocket.accept()
|
|
|
|
|
sockets = live_sockets_for(tournament_id)
|
|
|
|
|
sockets.add(websocket)
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
await websocket.receive_text()
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
sockets.discard(websocket)
|