Rundespesifikk kommentartråd for org-turneringer (ADR-102)

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>
This commit is contained in:
Erol Haagenrud 2026-08-22 06:39:44 +02:00
parent a915fa7bc4
commit d8d1cf39ae
9 changed files with 1713 additions and 10 deletions

View file

@ -0,0 +1,109 @@
-- =====================================================================
-- TeeCup — Rundespesifikk kommentartråd for org-turneringer, migrasjon
-- 090 (ADR-102). Speiler `058_round_messages.sql` +
-- `059_post_reactions_and_comments.sql` sin round_message/
-- round_message_reaction/round_message_comment, MEN med organization_id
-- + full RLS -- i motsetning til den frittstående sidens bevisst
-- RLS-frie plain_connection()-mønster (ADR-033 Beslutning A), er
-- turneringer ER org-scopet (CLAUDE.md-invarianten).
--
-- Én tråd PER TURNERINGSRUNDE (hele feltet på tvers av flighter deler
-- tråd, IKKE per flight) -- bevisst valgt av bruker, se ADR-102.
--
-- Skriverett = leserett = org-medlemskap (get_authorized_org), IKKE en
-- ny synlighetsmodell -- samme presedens som individual_tournaments.py
-- sin egen modul-docstring allerede fastslår for runde-/deltaker-
-- OPPSETT ("vanlig org-medlemsnivå", ikke det strengere self-only-kravet
-- update_hole har for selve SCORING-en). Moderering: forfatter ELLER
-- org-admin (is_org_admin), samme regel som Banter Board (ADR-025).
--
-- Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging (round_
-- message_tag-motparten er utelatt), ingen WebSocket-kringkasting
-- (individual-tournament-detail.tsx har ingen sanntids-tilkobling i
-- det hele tatt ennå -- eget, uprioritert gap).
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE tournament_round_message (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_id uuid NOT NULL,
author_user_id uuid NOT NULL REFERENCES app_user(id),
-- Frosset ved skrivetidspunkt, samme prinsipp som round_message.author_display_name
-- -- fullt navn (roster-/forfatter-kontekst, ikke direkte adressering,
-- CLAUDE.md navneformat-regel).
author_display_name text NOT NULL,
body text,
image_key text,
-- Offline-kø-idempotens (samme mønster som round_message.client_message_id,
-- migrasjon 073) -- et gjentatt POST med samme id (avbrutt synk prøvd på
-- nytt) returnerer den allerede opprettede meldingen fremfor en duplikat.
client_message_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_round_id)
REFERENCES tournament_round(organization_id, id) ON DELETE CASCADE,
CHECK (body IS NOT NULL OR image_key IS NOT NULL)
);
CREATE INDEX ON tournament_round_message (organization_id, tournament_round_id, created_at);
CREATE INDEX ON tournament_round_message (organization_id, author_user_id);
CREATE UNIQUE INDEX ON tournament_round_message (tournament_round_id, client_message_id)
WHERE client_message_id IS NOT NULL;
CREATE TABLE tournament_round_message_reaction (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_message_id uuid NOT NULL REFERENCES tournament_round_message(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_user(id),
emoji text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tournament_round_message_id, user_id)
);
CREATE INDEX ON tournament_round_message_reaction (organization_id, tournament_round_message_id);
CREATE TABLE tournament_round_message_comment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_message_id uuid NOT NULL REFERENCES tournament_round_message(id) ON DELETE CASCADE,
parent_comment_id uuid REFERENCES tournament_round_message_comment(id) ON DELETE CASCADE,
author_user_id uuid NOT NULL REFERENCES app_user(id),
author_display_name text NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (length(btrim(body)) > 0)
);
CREATE INDEX ON tournament_round_message_comment (organization_id, tournament_round_message_id, created_at);
CREATE INDEX ON tournament_round_message_comment (organization_id, parent_comment_id);
-- ---------------------------------------------------------------------
-- Row-Level Security (samme org_isolation-policy-mønster som 040)
-- ---------------------------------------------------------------------
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'tournament_round_message', 'tournament_round_message_reaction',
'tournament_round_message_comment'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t);
EXECUTE format($p$
CREATE POLICY org_isolation ON %I
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
$p$, t);
END LOOP;
END $$;
-- Samme "slett og opprett på nytt, aldri rediger"-prinsipp som
-- round_message/message -- ingen UPDATE-grant på selve meldingen/
-- kommentaren. Reaksjoner får UPDATE (emoji-bytte er en UPSERT).
GRANT SELECT, INSERT, DELETE ON tournament_round_message TO teecup_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON tournament_round_message_reaction TO teecup_app;
GRANT SELECT, INSERT, DELETE ON tournament_round_message_comment TO teecup_app;

View file

@ -10228,6 +10228,114 @@ mot ekte containere ennå.
--- ---
## ADR-102: Rundespesifikk kommentartråd for org-turneringer (2026-08-22)
**Kontekst:** punkt 7 i "Scoring-paritet: frittstående runde vs.
org-turnering"-gap-listen (FEATURE_BACKLOG.md, skrevet ned samme dag).
Bruker valgte å bygge dette FØR punkt 6 (GPS-slagmåling), fordi GPS-
delingsfunksjonen (ADR-048 Beslutning E) forutsetter et sted å dele
til. Frittstående runder har `RoundMessages` (ADR-044): en rundespesifikk
tråd med bilder, emoji-reaksjoner og trådede kommentarer (ADR-046),
offline-kø, @-tagging. Org-turneringer hadde kun den eksisterende, hele-
turnering-brede "Banter Board" (ADR-025, `message` med
scope='tournament_feed', kun på den offentlige `/t/[id]`-siden) — ingen
tråd knyttet til én bestemt runde i den autentiserte visningen.
**Beslutning A — ekte parallell til RoundMessages, IKKE en utvidelse av
Banter Board.** Bekreftet eksplisitt med bruker (AskUserQuestion):
Banter Board er turnering-bredt og upåvirket av dette; en NY
`tournament_round_message`-tabell (migrasjon 090) speiler
`round_message`/`round_message_reaction`/`round_message_comment`
(058/059) strukturelt, men med `organization_id` + full RLS (org-tenant-
invariant, CLAUDE.md) i stedet for frittstående sidens bevisste RLS-frie
`plain_connection()`-mønster (ADR-033 Beslutning A gjelder ikke her --
turneringer ER org-scopet).
**Beslutning B — én tråd PER TURNERINGSRUNDE, ikke per flight.**
Bekreftet eksplisitt med bruker: til tross for at en turneringsrunde kan
ha titalls/hundrevis av deltakere på tvers av mange flighter (ulikt en
frittstående runde, som alltid er en liten gruppe), deler HELE feltet
én tråd for runden. Enklere datamodell, nærmere Banter Board sin
åpenhet enn den opprinnelige "kun min flight"-intimiteten -- en bevisst
avveining, ikke en forglemmelse.
**Beslutning C — lese-/skriverett = org-medlemskap
(`get_authorized_org`), ikke en ny synlighetsmodell.** Frittstående
runders `_can_view_round` (eier/medspiller/public/friends-kategori,
`rounds.py`) har ingen reell motpart i org-turnering-modellen (RLS-
basert, ikke app-lags synlighet). Matcher den allerede uttalte
presedensen i `individual_tournaments.py` sin egen modul-docstring:
runde-/deltaker-OPPSETT er bevisst på "vanlig org-medlemsnivå" --
kommentering hører til samme kategori handling, ikke den strengere
self-only-regelen `update_hole` har for selve SCORING-en. Bekreftet i
scratch: et vanlig medlem (ikke admin, ikke deltaker i turneringen) kan
poste.
**Beslutning D — moderering: forfatter ELLER org-admin
(`is_org_admin`), speiler Banter Board (ADR-025), ikke frittstående
sidens "forfatter eller RUNDEEIER"** (en turneringsrunde har ingen eier
i den forstand `round.owner_user_id` gir). `app/team_authz.py`s
`is_org_admin` gjenbrukt uendret.
**Beslutning E — bevisst UTENFOR omfang i v1.** (1) Ingen @-tagging --
`PostEngagement`-komponenten (`post-engagement.tsx`) er allerede
gjenbrukt URL-agnostisk av to andre backends (`team-chat.tsx`,
`public-tournament.tsx`) UTEN tagging (samme `roundId`-prop-utelatt-
mønster brukt her), så dette er en presedens, ikke en ad-hoc
avgrensning. (2) Ingen WebSocket-/sanntid-piggyback -- den autentiserte
turneringsvisningen (`individual-tournament-detail.tsx`) har ingen
WS-tilkobling i det hele tatt ennå (bekreftet ved kodesøk), det er et
eget, uprioritert gap (gap-punkt 2 i samme liste). Tråden refetches ved
åpning + en manuell "Oppdater"-knapp. Reaksjoner og trådede kommentarer
ER med (allerede fullt støttet av `PostEngagement` uendret) -- kun
tagging og sanntid er kuttet.
**Backend:** ny fil `app/routers/tournament_round_messages.py`, speiler
`round_messages.py` endepunkt-for-endepunkt (liste/opprett/slett
melding, PUT/DELETE reaksjon, liste/opprett/slett trådet kommentar) men
`org_connection(organization_id)` + `Depends(get_authorized_org)` i
stedet for `plain_connection()`. `_get_tournament_round_or_404` speiler
det etablerte mønsteret fra `update_bbb_hole` (bekreft
`round_row["tournament_id"] == tournament_id`, RLS gjør resten).
Bilde-opplasting gjenbruker `storage.read_optional_image`/
`storage.upload_image` UENDRET (samme MinIO+AVIF-pipeline), ny
`prefix="tournament_round_messages"`. `client_message_id`-idempotens
speiler migrasjon 073 sitt mønster (offline-kø-retry-sikkerhet).
**Frontend:** ny `frontend/components/tournament-round-messages.tsx`,
adaptert kopi av `round-messages.tsx` med URL-ene byttet til
`${base}/rounds/${roundId}/messages...` og uten
MentionTextarea/tagging. `frontend/lib/offline-queue.ts` GJENBRUKT
UENDRET (bekreftet 100% generisk under research -- ingen
rundespesifikk antakelse i selve biblioteket), egen kø-nøkkel
`${base}:${roundId}:tournament-messages` for å unngå kollisjon med en
evt. fremtidig hull-score-offline-kø (gap-punkt 1, ikke bygget ennå).
Montert som en ny, kollapsbar "Kommentarer"-seksjon i `ScoreTab`, samme
mønster som "Vis hele scorekortet" (ADR-100). `individual-tournament-
detail.tsx` sin `/auth/me`-henting utvidet til å hente `currentUserId`
(manglet før, kun `role` ble hentet) -- trengs for forfatter-/
moderasjonssjekk i UI-et.
**Status:** 177/177 pytest (12 nye, dekker hele autorisasjonsmatrisen:
post/slett egen, org-admin sletter andres, ikke-forfatter-ikke-admin
nektet, RLS på feil org gir 404, vanlig medlem kan poste, reaksjon-
upsert/fjern, ugyldig emoji avvist, trådet kommentar + kaskade-sletting,
`client_message_id`-idempotens, body-eller-bilde-krav, feil
tournament_id gir 404), `tsc --noEmit` rent. Full-stack scratch (egen
db/API-/MinIO-container, ekte innlogging inkl. et ekte TOTP-2FA-oppsett
for å teste admin-moderering i nettleseren -- ikke bare i pytest, ingen
fetch-mocking) bekreftet: tekst+bilde postet (AVIF-konvertering
verifisert direkte mot scratch-MinIO), reaksjon, trådet svar, forfatter
sletter egen, ORG-ADMIN sletter en annens (ekte 2FA-innlogging
gjennomført for å bevise dette, ikke anslått), offline-kø fungerende
ende-til-ende (frakoblet → post → banner/"venter på synk" → tilkoblet →
automatisk synk → refetch bekreftet riktig serverstate), mørk modus +
smal mobilskjerm. Alle scratch-ressurser (containere/db/rolle/images)
ryddet opp etter verifisering. IKKE bygget/rullet ut mot ekte
containere ennå.
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -14403,3 +14403,56 @@ Neste steg:
frittstående runde vs. org-turnering") -- erstatter den tidligere frittstående runde vs. org-turnering") -- erstatter den tidligere
muntlige listen. Ingen prioritering/bygging startet -- venter på at muntlige listen. Ingen prioritering/bygging startet -- venter på at
bruker velger rekkefølge blant punktene. bruker velger rekkefølge blant punktene.
160. **Rundespesifikk kommentartråd for org-turneringer (ADR-102),
2026-08-22 -- punkt 7 i gap-listen, valgt før punkt 6 (GPS-
slagmåling, som avhenger av et delingsmål).** Bruker bekreftet
(AskUserQuestion) at dette skal være en EKTE parallell til
frittstående runders `RoundMessages` (ADR-044) -- ikke en utvidelse
av den eksisterende, hele-turnering-brede Banter Board (ADR-025) --
og at tråden skal være ÉN per turneringsrunde (hele feltet deler
tråd, ikke per flight, til tross for at en turneringsrunde kan ha
langt flere deltakere enn en frittstående runde).
**Ny migrasjon `090_tournament_round_message.sql`:**
`tournament_round_message`/`_reaction`/`_comment`, speiler
`round_message`/`round_message_reaction`/`round_message_comment`
(058/059) strukturelt, men MED `organization_id` + full RLS
(org-tenant-invariant) i stedet for frittstående sidens RLS-frie
mønster.
**Backend:** ny fil `app/routers/tournament_round_messages.py`
(speiler `round_messages.py` endepunkt-for-endepunkt, org-scopet).
Lese-/skriverett = org-medlemskap (`get_authorized_org`, samme
presedens som runde-/deltaker-OPPSETT ellers i
`individual_tournaments.py`). Moderering: forfatter ELLER org-admin
(`is_org_admin`), speiler Banter Board. Bilde-opplasting gjenbruker
`storage.py` sin MinIO+AVIF-pipeline uendret.
**Bevisst utenfor omfang i v1** (se ADR-102 Beslutning E): ingen
@-tagging (samme presedens som `team-chat.tsx`/`public-
tournament.tsx`s bruk av `PostEngagement`), ingen WebSocket-/
sanntid-piggyback (den autentiserte turneringsvisningen har ingen
WS-tilkobling ennå -- eget, uprioritert gap). Reaksjoner + trådede
kommentarer ER med.
**Frontend:** ny `frontend/components/tournament-round-messages.tsx`
(adaptert `round-messages.tsx`), montert som en kollapsbar
"Kommentarer"-seksjon i `ScoreTab`, samme mønster som "Vis hele
scorekortet" (ADR-100). `frontend/lib/offline-queue.ts` gjenbrukt
UENDRET (bekreftet 100% generisk).
**Verifisert:** 177/177 pytest (12 nye -- hele autorisasjonsmatrisen,
RLS, idempotens), `tsc --noEmit` rent. Full-stack scratch (egen
db/API-/MinIO-container, ekte innlogging -- inkludert et ekte
TOTP-2FA-oppsett for å teste admin-moderering i nettleseren, ikke
bare i pytest) bekreftet: tekst+bilde postet (AVIF-konvertering
verifisert direkte mot scratch-MinIO), reaksjon, trådet svar,
forfatter sletter egen, org-admin sletter en annens, offline-kø
ende-til-ende (frakoblet -> post -> "venter på synk" -> tilkoblet ->
automatisk synk -> riktig serverstate), mørk modus + smal
mobilskjerm. Alle scratch-ressurser ryddet opp.
**IKKE rullet ut ennå** -- venter på brukerbekreftelse (ny migrasjon
090 mot ekte `teecup_db` + `docker compose build teecup_api
teecup_frontend && up -d`).

View file

@ -4991,12 +4991,13 @@ runde HAR, org-turnering MANGLER, om ikke annet er sagt):**
eller Flagg-GPS (begge allerede bygget begge steder, se over). Antatt eller Flagg-GPS (begge allerede bygget begge steder, se over). Antatt
**middels/stor** -- `round_shot`s eierskap er i dag kablet til **middels/stor** -- `round_shot`s eierskap er i dag kablet til
frittstående-runde-XOR-en (ADR-048 Beslutning C), ekte backend-arbeid. frittstående-runde-XOR-en (ADR-048 Beslutning C), ekte backend-arbeid.
7. **Kommentarfelt/tråd på runden finnes ikke for turneringsrunder.** 7. ✅ **Kommentarfelt/tråd på runden -- FERDIG BYGGET 2026-08-22 (ADR-102),
`<RoundMessages>` (ADR-044, med bilde-vedlegg + offline-kø + push) er se CHANGELOG punkt 160.** Ny `tournament_round_message`-tabell
kun koblet til `round_id`. Ingen tilsvarende i den autentiserte (org-scopet, RLS), én tråd per turneringsrunde (hele feltet, ikke
turneringsvisningen. Antatt **middels** -- UI-komponenten er stort per flight -- bevisst valg). Reaksjoner + trådede kommentarer med.
sett gjenbrukbar, men trenger enten en ny `tournament_round_message`- Bevisst utenfor v1: @-tagging, sanntid/WebSocket. Verifisert i
tabell eller en generalisering av eierskapsmodellen. scratch, **ikke rullet ut mot ekte containere ennå** -- venter på
migrasjon 090 + deploy-bekreftelse.
8. **Tilskuer-metrikkveksling (brutto/netto/Stableford/poeng) finnes kun 8. **Tilskuer-metrikkveksling (brutto/netto/Stableford/poeng) finnes kun
for frittstående runder.** `round-leaderboard.tsx` sin `MetricToggle` for frittstående runder.** `round-leaderboard.tsx` sin `MetricToggle`
har ingen motpart i `stroke-play-leaderboard.tsx` (som eksplisitt har har ingen motpart i `stroke-play-leaderboard.tsx` (som eksplisitt har
@ -5019,3 +5020,34 @@ avgjør om punkt 9 skal tas med i samme runde eller utsettes). Ingen
bygging startet på noen av disse ennå -- denne seksjonen er kun bygging startet på noen av disse ennå -- denne seksjonen er kun
gap-fangst, ikke et design. Når et punkt velges, skriv en egen ADR før gap-fangst, ikke et design. Når et punkt velges, skriv en egen ADR før
bygging (samme mønster som ADR-099/100/101). bygging (samme mønster som ADR-099/100/101).
## Idé, ikke spesifisert: notatbok/anteckningsbok i scoreførings-sidene (2026-08-22) — 📋 fanget, ikke designet
Bruker: "Jeg ønsker meg en notatbok, gjerne en popup fra menyen på
toppen når jeg er i scoreføringssidene. Den skal kunne gis beskjed om
kun å åpnes manuelt, kun åpnes før eller etter hver runde, eller før
eller etter registrering av score på et hull."
Fanget opp mens arbeidet med punkt 7 over (rundespesifikk
kommentartråd, ADR-102) pågikk -- ikke undersøkt eller designet ennå.
Uavklart, trenger en egen avklaringsrunde FØR bygging:
- **Innhold:** hva slags notater -- fritekst? faste felt (vær, baneforhold,
utstyrsvalg)? knyttet til ett hull, én runde, eller spilleren generelt
på tvers av runder?
- **Lagring/omfang:** frittstående runder, org-turneringer, eller begge
(samme "parity"-spørsmål som resten av denne gap-listen)? Privat for
spilleren alene, eller delt med medspillere/flighten (som
kommentartråden over)?
- **Triggerlogikk for automatisk åpning:** "før/etter hver runde" og
"før/etter registrering av score på et hull" er fire distinkte
tidspunkt -- trenger presis avklaring av hva "runde"/"hull" betyr her
(hver runde i en flerrundeturnering? hvert nytt hull spilleren
beveger seg til?), og om brukeren kan velge FLERE av de fire
samtidig eller kun ett.
- **Plassering:** "popup fra menyen på toppen" -- gjelder dette samme
toppmeny i BÅDE frittstående rundesiden og org-turnering-siden
(ScoreTab), eller kun én av dem?
**Neste steg:** egen avklaringsrunde med bruker (innhold, lagringsomfang,
eksakt triggerlogikk, plassering) FØR design/bygging -- ingen
beslutninger tatt ennå, kun fanget opp her for å ikke gå tapt.

View file

@ -32,6 +32,7 @@ from .routers import (
round_messages, round_messages,
rounds, rounds,
scoring, scoring,
tournament_round_messages,
tournaments, tournaments,
) )
from . import storage from . import storage
@ -66,6 +67,7 @@ app.include_router(messaging.router)
app.include_router(messaging.public_router) app.include_router(messaging.public_router)
app.include_router(rounds.router) app.include_router(rounds.router)
app.include_router(round_messages.router) app.include_router(round_messages.router)
app.include_router(tournament_round_messages.router)
app.include_router(friends.router) app.include_router(friends.router)
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(print_pdf.router) app.include_router(print_pdf.router)

View file

@ -0,0 +1,473 @@
"""
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 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)

View file

@ -72,6 +72,7 @@ import { TournamentPresentationPanel } from "@/components/tournament-presentatio
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { CourseTemplatePicker } from "@/components/course-template-editor" import { CourseTemplatePicker } from "@/components/course-template-editor"
import { TournamentRoundMessages } from "@/components/tournament-round-messages"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) -- // --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) --
@ -410,6 +411,9 @@ export function IndividualTournamentDetail({
// sperre/begrunnelse som tournament-detail.tsx (lagformatet). // sperre/begrunnelse som tournament-detail.tsx (lagformatet).
const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null) const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
// Rundespesifikk kommentartråd (ADR-102) -- forfatter-/moderasjons-
// sjekk trenger viewerens egen bruker-id, ikke bare rollen.
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
const base = `/orgs/${organizationId}/tournaments/${tournamentId}` const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
@ -417,10 +421,18 @@ export function IndividualTournamentDetail({
let cancelled = false let cancelled = false
fetch("/auth/me", { credentials: "include" }) fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null)) .then((res) => (res.ok ? res.json() : null))
.then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => { .then(
if (cancelled || !me) return (
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null) me: {
}) id: string
organizations: { organization_id: string; role: "owner" | "admin" | "member" }[]
} | null,
) => {
if (cancelled || !me) return
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
setCurrentUserId(me.id)
},
)
.catch(() => {}) .catch(() => {})
return () => { return () => {
cancelled = true cancelled = true
@ -1102,6 +1114,8 @@ export function IndividualTournamentDetail({
rounds={rounds} rounds={rounds}
scoringMethod={tournament.scoring_method} scoringMethod={tournament.scoring_method}
participants={participants} participants={participants}
currentUserId={currentUserId}
isOrgAdmin={isOrgAdmin}
onError={setError} onError={setError}
/> />
)} )}
@ -2963,12 +2977,16 @@ function ScoreTab({
rounds, rounds,
scoringMethod, scoringMethod,
participants, participants,
currentUserId,
isOrgAdmin,
onError, onError,
}: { }: {
base: string base: string
rounds: ApiRound[] rounds: ApiRound[]
scoringMethod: string | null scoringMethod: string | null
participants: ApiParticipant[] participants: ApiParticipant[]
currentUserId: string | null
isOrgAdmin: boolean
onError: (message: string) => void onError: (message: string) => void
}) { }) {
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "") const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
@ -3115,6 +3133,9 @@ function ScoreTab({
// samme fiks som round-detail.tsx sin PlayerHoleCards/ScorecardGrid- // samme fiks som round-detail.tsx sin PlayerHoleCards/ScorecardGrid-
// splitt. // splitt.
const [showFullScorecard, setShowFullScorecard] = useState(false) const [showFullScorecard, setShowFullScorecard] = useState(false)
// Rundespesifikk kommentartråd (ADR-102) -- kollapset som standard,
// samme mønster som "Vis hele scorekortet".
const [showComments, setShowComments] = useState(false)
const wizardIdx = chain.findIndex((rp) => rp.id === wizardPlayerId) const wizardIdx = chain.findIndex((rp) => rp.id === wizardPlayerId)
const hasNextPlayer = wizardIdx !== -1 && wizardIdx < chain.length - 1 const hasNextPlayer = wizardIdx !== -1 && wizardIdx < chain.length - 1
const nextPlayerName = hasNextPlayer ? chain[wizardIdx + 1].player_name : null const nextPlayerName = hasNextPlayer ? chain[wizardIdx + 1].player_name : null
@ -3415,6 +3436,34 @@ function ScoreTab({
)} )}
</div> </div>
{/* Rundespesifikk kommentartråd (ADR-102) -- ekte parallell til
frittstående runders RoundMessages, IKKE Banter Board
(tournament_feed, hele turneringen). Kollapset som standard,
samme mønster som "Vis hele scorekortet" over. */}
{round && currentUserId && (
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => setShowComments((v) => !v)}
aria-expanded={showComments}
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Kommentarer
<ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</button>
{showComments && (
<div className="border-t border-border p-3">
<TournamentRoundMessages
base={base}
roundId={roundId}
currentUserId={currentUserId}
isOrgAdmin={isOrgAdmin}
/>
</div>
)}
</div>
)}
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon, {/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
uavhengig av gross_strokes-registreringen over. */} uavhengig av gross_strokes-registreringen over. */}
{round && scoringMethod === "bingo_bango_bongo" && ( {round && scoringMethod === "bingo_bango_bongo" && (

View file

@ -0,0 +1,561 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { cn } from "@/lib/utils"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
/**
* Rundespesifikk kommentartråd for org-turneringer (ADR-102) -- ekte
* parallell til `RoundMessages` (frittstående runder), IKKE en utvidelse
* av den eksisterende hele-turnering-brede Banter Board (ADR-025).
*
* Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging (samme
* presedens som team-chat.tsx/public-tournament.tsx -- PostEngagement
* sin `roundId`-prop utelates bevisst, komposereren er en vanlig
* tekstboks), ingen sanntid/WebSocket-piggyback (denne autentiserte
* visningen har ingen WS-tilkobling i det hele tatt ennå -- eget,
* uprioritert gap). Refetches ved mount + en manuell "Oppdater"-knapp.
*/
export type ApiTournamentRoundMessage = {
id: string
tournament_round_id: string
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
}
// Et lokalt, ikke-synkronisert innlegg (offline-kø), speiler round-messages.tsx.
type PendingMessage = ApiTournamentRoundMessage & { _clientMessageId: string }
export type TournamentRoundMessagesProps = {
base: string // `/orgs/{organizationId}/tournaments/{tournamentId}`
roundId: string
currentUserId: string
isOrgAdmin: boolean
}
const ACCEPTED_IMAGE_TYPES = "image/*"
const MAX_IMAGE_HEIGHT = 300
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
try {
const data = await res.json()
const message = data?.detail?.message
if (typeof message === "string" && message.length > 0) return message
} catch {
// Ikke JSON -- bruk fallback.
}
return fallback
}
function formatRelativeTime(iso: string): { label: string; title: string } {
const then = new Date(iso)
const title = then.toLocaleString("nb-NO", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
const diffMs = Date.now() - then.getTime()
const diffSec = Math.round(diffMs / 1000)
const diffMin = Math.round(diffSec / 60)
const diffHour = Math.round(diffMin / 60)
const diffDay = Math.round(diffHour / 24)
let label: string
if (diffSec < 45) label = "nå nettopp"
else if (diffMin < 60) label = `for ${diffMin} min siden`
else if (diffHour < 24) label = `for ${diffHour} t siden`
else if (diffDay === 1) label = "i går"
else if (diffDay < 7) label = `for ${diffDay} dager siden`
else
label = then.toLocaleDateString("nb-NO", {
day: "numeric",
month: "short",
})
return { label, title }
}
export function TournamentRoundMessages({ base, roundId, currentUserId, isOrgAdmin }: TournamentRoundMessagesProps) {
const messagesUrl = `${base}/rounds/${roundId}/messages`
const [messages, setMessages] = useState<ApiTournamentRoundMessage[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [body, setBody] = useState("")
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
// Egen kø-navnerom -- unngår kollisjon med en evt. fremtidig hull-score-kø
// på samme runde (gap-punkt 1, ikke koblet på ennå), selv om begge ville
// delt samme IndexedDB-database.
const messagesMatchId = `${base}:${roundId}:tournament-messages`
const [isOnline, setIsOnline] = useState(true)
const [pendingCount, setPendingCount] = useState(0)
const [syncingMessages, setSyncingMessages] = useState(false)
const [pendingMessages, setPendingMessages] = useState<PendingMessage[]>([])
const fileInputRef = useRef<HTMLInputElement | null>(null)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
const load = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const res = await fetch(messagesUrl, { credentials: "include" })
if (!res.ok) throw new Error(`Status ${res.status}`)
const data: ApiTournamentRoundMessage[] = await res.json()
setMessages(data)
} catch {
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
}, [messagesUrl])
useEffect(() => {
void load()
}, [load])
const flushPendingMessages = useCallback(async () => {
if (syncingMessages) return
setSyncingMessages(true)
try {
const outcomes = await flushQueue(messagesMatchId)
if (outcomes.length === 0) return
for (const o of outcomes) {
const sentBody = o.entry.body as Record<string, unknown>
const clientMessageId = sentBody.client_message_id as string | undefined
setPendingMessages((prev) => {
const match = prev.find((m) => m._clientMessageId === clientMessageId)
if (match?.image_url) URL.revokeObjectURL(match.image_url)
return prev.filter((m) => m._clientMessageId !== clientMessageId)
})
}
const failed = outcomes.filter((o) => !o.ok)
if (failed.length > 0) {
setPostError(
`${failed.length} ${failed.length === 1 ? "kommentar" : "kommentarer"} kunne ikke synkroniseres: ${failed[0].message}`,
)
}
setPendingCount(await queueCount(messagesMatchId))
await load()
} finally {
setSyncingMessages(false)
}
}, [messagesMatchId, syncingMessages, load])
useEffect(() => {
setIsOnline(navigator.onLine)
function handleOnline() {
setIsOnline(true)
void flushPendingMessages()
}
function handleOffline() {
setIsOnline(false)
}
window.addEventListener("online", handleOnline)
window.addEventListener("offline", handleOffline)
return () => {
window.removeEventListener("online", handleOnline)
window.removeEventListener("offline", handleOffline)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesMatchId])
useEffect(() => {
let cancelled = false
queueCount(messagesMatchId)
.then((count) => {
if (cancelled) return
setPendingCount(count)
if (count > 0 && navigator.onLine) void flushPendingMessages()
})
.catch(() => {})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesMatchId])
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview)
}
}, [imagePreview])
const clearSelectedImage = useCallback(() => {
setImageFile(null)
setImagePreview((prev) => {
if (prev) URL.revokeObjectURL(prev)
return null
})
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
}, [])
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > MAX_UPLOAD_BYTES) {
setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
e.target.value = ""
return
}
setPostError(null)
setImageFile(file)
setImagePreview((prev) => {
if (prev) URL.revokeObjectURL(prev)
return URL.createObjectURL(file)
})
}
const trimmedBody = body.trim()
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
async function queueMessageOffline(fields: Record<string, string | Blob>, clientMessageId: string) {
await enqueueWrite({
url: messagesUrl,
method: "POST",
body: fields,
matchId: messagesMatchId,
isMultipart: true,
})
const pendingImageUrl = imageFile ? URL.createObjectURL(imageFile) : null
setPendingMessages((prev) => [
{
id: `pending-${clientMessageId}`,
tournament_round_id: roundId,
author_user_id: currentUserId,
author_display_name: "Du",
body: trimmedBody || null,
image_url: pendingImageUrl,
created_at: new Date().toISOString(),
reactions: [],
comment_count: 0,
_clientMessageId: clientMessageId,
},
...prev,
])
setPendingCount((c) => c + 1)
setBody("")
clearSelectedImage()
}
async function handlePost(e: React.FormEvent) {
e.preventDefault()
if (!canPost) return
setPosting(true)
setPostError(null)
const clientMessageId = crypto.randomUUID()
const fields: Record<string, string | Blob> = { client_message_id: clientMessageId }
if (trimmedBody.length > 0) fields.body = trimmedBody
if (imageFile) fields.image = imageFile
if (!navigator.onLine) {
await queueMessageOffline(fields, clientMessageId)
setPosting(false)
return
}
try {
const formData = new FormData()
for (const [k, v] of Object.entries(fields)) formData.append(k, v)
const res = await fetch(messagesUrl, {
method: "POST",
credentials: "include",
body: formData,
})
if (!res.ok) {
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
return
}
const created: ApiTournamentRoundMessage = await res.json()
setMessages((prev) => [created, ...prev])
setBody("")
clearSelectedImage()
} catch {
await queueMessageOffline(fields, clientMessageId)
} finally {
setPosting(false)
}
}
async function handleDelete(id: string) {
setDeletingId(id)
setPostError(null)
try {
const res = await fetch(`${messagesUrl}/${id}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) throw new Error(`Status ${res.status}`)
setMessages((prev) => prev.filter((m) => m.id !== id))
} catch {
setPostError("Kunne ikke slette kommentaren. Prøv igjen.")
} finally {
setDeletingId(null)
}
}
return (
<section className="flex flex-col gap-4" aria-label="Kommentarer og bilder fra turneringsrunden">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-bold text-foreground">Kommentarer</h3>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="inline-flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-xs font-semibold text-muted-foreground outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<RefreshCw aria-hidden="true" className={cn("size-3.5", loading && "animate-spin")} />
Oppdater
</button>
</div>
{/* Composer */}
<form
onSubmit={handlePost}
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5"
>
<label htmlFor="tournament-round-message-body" className="sr-only">
Skriv en kommentar
</label>
<textarea
id="tournament-round-message-body"
value={body}
onChange={(e) => {
setBody(e.target.value)
if (postError) setPostError(null)
}}
placeholder="Skriv en kommentar..."
rows={3}
className="w-full resize-y rounded-xl border border-border bg-background px-3 py-2.5 text-base text-foreground outline-none transition-all duration-200 ease-in-out placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
{imagePreview && (
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted p-2.5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview || "/placeholder.svg"}
alt="Forhåndsvisning av valgt bilde"
className="size-14 shrink-0 rounded-lg object-cover"
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{imageFile?.name}
</span>
<button
type="button"
onClick={clearSelectedImage}
className="inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground outline-none transition-all duration-200 ease-in-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<X aria-hidden="true" className="size-4" />
Fjern
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<input
ref={cameraInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onChange={handleFileChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={() => cameraInputRef.current?.click()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<Camera aria-hidden="true" className="size-5" />
Ta bilde
</button>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
onChange={handleFileChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<ImagePlus aria-hidden="true" className="size-5" />
Velg fra galleri
</button>
</div>
<button
type="submit"
disabled={!canPost}
className="inline-flex h-11 min-w-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-base font-medium text-primary-foreground outline-none transition-all duration-200 ease-in-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{posting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"
/>
) : (
<Send aria-hidden="true" className="size-5" />
)}
{posting ? "Poster..." : "Post"}
</button>
</div>
{postError && (
<p role="alert" className="text-sm font-medium text-destructive">
{postError}
</p>
)}
</form>
{!isOnline && (
<div className="flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
<span>Du er offline. Kommentaren lagres lokalt og postes automatisk når du er tilbake nett.</span>
</div>
)}
{pendingCount > 0 && (
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
<span>
{pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} synkronisering.
</span>
{isOnline && (
<button
type="button"
onClick={() => void flushPendingMessages()}
disabled={syncingMessages}
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
>
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncingMessages && "animate-spin")} />
Synkroniser
</button>
)}
</div>
)}
{loading ? (
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
<span
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<span className="sr-only">Laster kommentarer...</span>
</div>
) : loadError ? (
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
{loadError}
</p>
) : pendingMessages.length === 0 && messages.length === 0 ? (
<p className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
Ingen kommentarer ennå vær den første til å dele et bilde eller en kommentar fra runden.
</p>
) : (
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{[...pendingMessages, ...messages].map((m) => {
const isPending = "_clientMessageId" in m
const canDelete = !isPending && (m.author_user_id === currentUserId || isOrgAdmin)
const isDeleting = deletingId === m.id
const { label, title } = formatRelativeTime(m.created_at)
return (
<li key={m.id} className="flex flex-col gap-3 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-base font-semibold text-foreground">
{m.author_display_name}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground">
<Clock aria-hidden="true" className="size-3.5 shrink-0" />
Venter synk
</span>
) : (
<time
dateTime={m.created_at}
title={title}
className="text-sm font-medium text-muted-foreground"
>
{label}
</time>
)}
</div>
{canDelete && (
<button
type="button"
onClick={() => handleDelete(m.id)}
disabled={isDeleting}
className="inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-destructive outline-none transition-all duration-200 ease-in-out hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{isDeleting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-destructive/30 border-t-destructive"
/>
) : (
<Trash2 aria-hidden="true" className="size-4" />
)}
Slett
</button>
)}
</div>
{m.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.image_url || "/placeholder.svg"}
alt={`Bilde delt av ${m.author_display_name}`}
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
className="w-full rounded-xl object-cover"
/>
)}
{m.body && (
<p className="text-base leading-relaxed text-foreground whitespace-pre-wrap">{m.body}</p>
)}
{!isPending && (
<PostEngagement
apiBase={`${messagesUrl}/${m.id}`}
currentUserId={currentUserId || null}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={isOrgAdmin}
/>
)}
</li>
)
})}
</ul>
)}
</section>
)
}
export default TournamentRoundMessages

View file

@ -0,0 +1,316 @@
"""
Rundespesifikk kommentartråd for org-turneringer (ADR-102). Ekte parallell
til round_messages.py sin RoundMessages, men org-scopet (RLS) i stedet for
plain_connection() -- skriverett = leserett = org-medlemskap
(get_authorized_org), moderering = forfatter ELLER org-admin (is_org_admin),
samme regel som Banter Board (ADR-025). Én tråd PER TURNERINGSRUNDE (hele
feltet deler tråd, ikke per flight).
"""
import uuid
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.tournament_round_messages import (
CommentIn,
ReactionIn,
delete_tournament_round_message,
delete_tournament_round_message_comment,
list_tournament_round_message_comments,
list_tournament_round_messages,
post_tournament_round_message,
post_tournament_round_message_comment,
remove_tournament_round_message_reaction,
set_tournament_round_message_reaction,
)
from tests.conftest import (
add_membership,
create_org,
create_tournament,
create_tournament_round,
create_course,
create_user,
)
async def _setup(org_id):
tournament_id = await create_tournament(org_id, name="Kommentar-turnering")
course_id = await create_course(org_id, name="Kommentarbanen")
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, round_id
async def test_post_and_list_message(pool):
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user(display_name="Forfatter Testesen")
await add_membership(org_id, author_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Fin runde i dag!", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert created.body == "Fin runde i dag!"
assert created.tournament_round_id == round_id
assert created.author_display_name == "Forfatter Testesen"
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert [m.id for m in listed] == [created.id]
async def test_client_message_id_idempotent(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
client_message_id = str(uuid.uuid4())
first = await post_tournament_round_message(
tournament_id, round_id, body="Duplikat-test", image=None, client_message_id=client_message_id,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
second = await post_tournament_round_message(
tournament_id, round_id, body="Duplikat-test", image=None, client_message_id=client_message_id,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert first.id == second.id
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert len(listed) == 1
async def test_body_or_image_required(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
with pytest.raises(HTTPException) as exc_info:
await post_tournament_round_message(
tournament_id, round_id, body=None, image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 400
async def test_wrong_tournament_id_404(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
other_tournament_id = await create_tournament(org_id, name="En annen turnering")
author_id = await create_user()
await add_membership(org_id, author_id)
with pytest.raises(HTTPException) as exc_info:
await post_tournament_round_message(
other_tournament_id, round_id, body="Feil turnering", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 404
async def test_rls_blocks_cross_org_access(pool) -> None:
org_a = await create_org()
org_b = await create_org()
tournament_id, round_id = await _setup(org_a)
author_id = await create_user()
await add_membership(org_a, author_id)
await add_membership(org_b, author_id)
await post_tournament_round_message(
tournament_id, round_id, body="Kun org A", image=None, client_message_id=None,
organization_id=org_a, user=CurrentUser(user_id=author_id),
)
# Samme (tournament_id, round_id) forsøkt lest med FEIL org_id satt --
# RLS filtrerer bort tournament_round-raden, _get_tournament_round_or_404
# ser den derfor ikke og gir 404 (ikke et 0-treffs, "tom liste"-svar).
with pytest.raises(HTTPException) as exc_info:
await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_b, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 404
async def test_delete_by_author(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Slett meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
await delete_tournament_round_message(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert listed == []
async def test_delete_by_org_admin_not_author(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
admin_id = await create_user()
await add_membership(org_id, author_id, role="member")
await add_membership(org_id, admin_id, role="admin")
created = await post_tournament_round_message(
tournament_id, round_id, body="Slett meg som admin", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
await delete_tournament_round_message(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=admin_id),
)
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert listed == []
async def test_delete_rejected_for_unrelated_member(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
other_member_id = await create_user()
await add_membership(org_id, author_id, role="member")
await add_membership(org_id, other_member_id, role="member")
created = await post_tournament_round_message(
tournament_id, round_id, body="Ikke slett meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
with pytest.raises(HTTPException) as exc_info:
await delete_tournament_round_message(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=other_member_id),
)
assert exc_info.value.status_code == 403
async def test_ordinary_member_can_post(pool) -> None:
"""Skriverett = org-medlemskap, ikke deltaker-i-akkurat-denne-turneringen
eller admin -- bekrefter at et VANLIG medlem (ikke admin/deltaker) kan
poste, samme "vanlig org-medlemsnivå"-presedens som resten av
individual_tournaments.py sitt oppsett (ADR-102)."""
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
member_id = await create_user()
await add_membership(org_id, member_id, role="member")
created = await post_tournament_round_message(
tournament_id, round_id, body="Jeg er bare medlem", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=member_id),
)
assert created.body == "Jeg er bare medlem"
async def test_reaction_upsert_and_remove(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
reactor_id = await create_user()
await add_membership(org_id, author_id)
await add_membership(org_id, reactor_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Reager på meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
summaries = await set_tournament_round_message_reaction(
tournament_id, round_id, created.id, ReactionIn(emoji="👍"),
organization_id=org_id, user=CurrentUser(user_id=reactor_id),
)
assert len(summaries) == 1
assert summaries[0].emoji == "👍"
assert summaries[0].count == 1
assert summaries[0].reacted_by_me is True
# Bytte av emoji erstatter, stables ikke.
summaries = await set_tournament_round_message_reaction(
tournament_id, round_id, created.id, ReactionIn(emoji="❤️"),
organization_id=org_id, user=CurrentUser(user_id=reactor_id),
)
assert len(summaries) == 1
assert summaries[0].emoji == "❤️"
summaries = await remove_tournament_round_message_reaction(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=reactor_id),
)
assert summaries == []
async def test_invalid_emoji_rejected(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Test", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
with pytest.raises(HTTPException) as exc_info:
await set_tournament_round_message_reaction(
tournament_id, round_id, created.id, ReactionIn(emoji="🐸"),
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 400
async def test_threaded_comment_create_list_delete(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
commenter_id = await create_user()
admin_id = await create_user()
await add_membership(org_id, author_id, role="member")
await add_membership(org_id, commenter_id, role="member")
await add_membership(org_id, admin_id, role="admin")
message = await post_tournament_round_message(
tournament_id, round_id, body="Kommenter meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
top_comment = await post_tournament_round_message_comment(
tournament_id, round_id, message.id, CommentIn(body="Bra spilt!"),
organization_id=org_id, user=CurrentUser(user_id=commenter_id),
)
reply = await post_tournament_round_message_comment(
tournament_id, round_id, message.id, CommentIn(body="Enig!", parent_comment_id=top_comment.id),
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
comments = await list_tournament_round_message_comments(
tournament_id, round_id, message.id, organization_id=org_id,
)
assert {c.id for c in comments} == {top_comment.id, reply.id}
assert next(c for c in comments if c.id == reply.id).parent_comment_id == top_comment.id
# Ikke-forfatter-ikke-admin nektes.
with pytest.raises(HTTPException) as exc_info:
await delete_tournament_round_message_comment(
tournament_id, round_id, message.id, top_comment.id,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 403
# Org-admin kan slette andres kommentar -- kaskade-sletter svaret under.
await delete_tournament_round_message_comment(
tournament_id, round_id, message.id, top_comment.id,
organization_id=org_id, user=CurrentUser(user_id=admin_id),
)
remaining = await list_tournament_round_message_comments(
tournament_id, round_id, message.id, organization_id=org_id,
)
assert remaining == []