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:
parent
a915fa7bc4
commit
d8d1cf39ae
9 changed files with 1713 additions and 10 deletions
109
090_tournament_round_message.sql
Normal file
109
090_tournament_round_message.sql
Normal 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;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
53
CHANGELOG.md
53
CHANGELOG.md
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
473
app/routers/tournament_round_messages.py
Normal file
473
app/routers/tournament_round_messages.py
Normal 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 på tvers av flighter deler
|
||||||
|
tråd), bevisst valgt av bruker -- IKKE per flight.
|
||||||
|
- Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging, ingen
|
||||||
|
WebSocket-kringkasting (den autentiserte turneringsvisningen har ingen
|
||||||
|
sanntids-tilkobling i det hele tatt ennå -- eget, uprioritert gap).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .. import storage
|
||||||
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
|
from ..db import org_connection
|
||||||
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from ..team_authz import is_org_admin
|
||||||
|
|
||||||
|
router = APIRouter(tags=["tournament-round-messages"])
|
||||||
|
|
||||||
|
# Samme kuraterte reaksjonssett som round_messages.py/messaging.py (ADR-046).
|
||||||
|
ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"}
|
||||||
|
|
||||||
|
_MESSAGE_COLUMNS = """
|
||||||
|
id::text, tournament_round_id::text, author_user_id::text, author_display_name,
|
||||||
|
body, image_key, created_at
|
||||||
|
"""
|
||||||
|
_COMMENT_COLUMNS = """
|
||||||
|
id::text, tournament_round_message_id::text, parent_comment_id::text,
|
||||||
|
author_user_id::text, author_display_name, body, created_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionIn(BaseModel):
|
||||||
|
emoji: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionSummary(BaseModel):
|
||||||
|
emoji: str
|
||||||
|
count: int
|
||||||
|
reacted_by_me: bool
|
||||||
|
reactors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class RoundMessageOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
tournament_round_id: str
|
||||||
|
author_user_id: str
|
||||||
|
author_display_name: str
|
||||||
|
body: str | None
|
||||||
|
image_url: str | None
|
||||||
|
created_at: str
|
||||||
|
reactions: list[ReactionSummary] = []
|
||||||
|
comment_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CommentIn(BaseModel):
|
||||||
|
body: str = Field(min_length=1, max_length=2000)
|
||||||
|
parent_comment_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
tournament_round_message_id: str
|
||||||
|
parent_comment_id: str | None
|
||||||
|
author_user_id: str
|
||||||
|
author_display_name: str
|
||||||
|
body: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_message(
|
||||||
|
row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
|
||||||
|
) -> RoundMessageOut:
|
||||||
|
return RoundMessageOut(
|
||||||
|
id=row["id"],
|
||||||
|
tournament_round_id=row["tournament_round_id"],
|
||||||
|
author_user_id=row["author_user_id"],
|
||||||
|
author_display_name=row["author_display_name"],
|
||||||
|
body=row["body"],
|
||||||
|
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
|
||||||
|
created_at=row["created_at"].isoformat(),
|
||||||
|
reactions=reactions or [],
|
||||||
|
comment_count=comment_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_comment(row) -> CommentOut:
|
||||||
|
return CommentOut(
|
||||||
|
id=row["id"],
|
||||||
|
tournament_round_message_id=row["tournament_round_message_id"],
|
||||||
|
parent_comment_id=row["parent_comment_id"],
|
||||||
|
author_user_id=row["author_user_id"],
|
||||||
|
author_display_name=row["author_display_name"],
|
||||||
|
body=row["body"],
|
||||||
|
created_at=row["created_at"].isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_author_name(conn, user_id: str) -> str:
|
||||||
|
"""Fullt navn (roster-/forfatter-kontekst, ikke direkte adressering --
|
||||||
|
CLAUDE.md navneformat-regel), frosset ved skrivetidspunkt. Egen, lokal
|
||||||
|
kopi av samme oppslag round_messages.py/messaging.py hver har sin egen
|
||||||
|
versjon av -- denne modulen har ikke tidligere importert fra noen av
|
||||||
|
dem, og hører uansett ikke hjemme i rounds.py (frittstående-side)."""
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT first_name, last_name, display_name FROM app_user WHERE id = $1", user_id
|
||||||
|
)
|
||||||
|
if row["first_name"] and row["last_name"]:
|
||||||
|
return f"{row['first_name']} {row['last_name']}"
|
||||||
|
return row["display_name"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_tournament_round_or_404(conn, tournament_id: str, round_id: str) -> None:
|
||||||
|
row = await conn.fetchval(
|
||||||
|
"SELECT tournament_id::text FROM tournament_round WHERE id = $1", round_id
|
||||||
|
)
|
||||||
|
if row is None or row != tournament_id:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_message_or_404(conn, round_id: str, message_id: str) -> None:
|
||||||
|
exists = await conn.fetchval(
|
||||||
|
"SELECT id FROM tournament_round_message WHERE id = $1 AND tournament_round_id = $2",
|
||||||
|
message_id, round_id,
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _message_reactions(
|
||||||
|
conn, message_ids: list[str], viewer_user_id: str
|
||||||
|
) -> dict[str, list[ReactionSummary]]:
|
||||||
|
if not message_ids:
|
||||||
|
return {}
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT trmr.tournament_round_message_id::text AS message_id, trmr.emoji, count(*) AS count,
|
||||||
|
COALESCE(bool_or(trmr.user_id = $2), false) AS reacted_by_me,
|
||||||
|
array_agg(
|
||||||
|
COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name)
|
||||||
|
ORDER BY trmr.created_at
|
||||||
|
) AS reactors
|
||||||
|
FROM tournament_round_message_reaction trmr
|
||||||
|
JOIN app_user au ON au.id = trmr.user_id
|
||||||
|
WHERE trmr.tournament_round_message_id = ANY($1::uuid[])
|
||||||
|
GROUP BY trmr.tournament_round_message_id, trmr.emoji
|
||||||
|
""",
|
||||||
|
message_ids,
|
||||||
|
viewer_user_id,
|
||||||
|
)
|
||||||
|
result: dict[str, list[ReactionSummary]] = {}
|
||||||
|
for r in rows:
|
||||||
|
result.setdefault(r["message_id"], []).append(
|
||||||
|
ReactionSummary(
|
||||||
|
emoji=r["emoji"], count=r["count"], reacted_by_me=r["reacted_by_me"],
|
||||||
|
reactors=list(r["reactors"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]:
|
||||||
|
if not message_ids:
|
||||||
|
return {}
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT tournament_round_message_id::text AS message_id, count(*) AS count
|
||||||
|
FROM tournament_round_message_comment
|
||||||
|
WHERE tournament_round_message_id = ANY($1::uuid[])
|
||||||
|
GROUP BY tournament_round_message_id
|
||||||
|
""",
|
||||||
|
message_ids,
|
||||||
|
)
|
||||||
|
return {r["message_id"]: r["count"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runde-nivå kommentarer/bilder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages",
|
||||||
|
response_model=list[RoundMessageOut],
|
||||||
|
)
|
||||||
|
async def list_tournament_round_messages(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[RoundMessageOut]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
# Nyeste øverst -- samme "oppdateringsstrøm, ikke kronologisk
|
||||||
|
# samtale"-mønster som round_messages.py (ADR-044).
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message "
|
||||||
|
"WHERE tournament_round_id = $1 ORDER BY created_at DESC",
|
||||||
|
round_id,
|
||||||
|
)
|
||||||
|
message_ids = [r["id"] for r in rows]
|
||||||
|
reactions_by_id = await _message_reactions(conn, message_ids, user.user_id)
|
||||||
|
comment_counts = await _message_comment_counts(conn, message_ids)
|
||||||
|
return [
|
||||||
|
_row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages",
|
||||||
|
response_model=RoundMessageOut,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def post_tournament_round_message(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
body: str | None = Form(default=None, max_length=2000),
|
||||||
|
image: UploadFile | None = File(default=None),
|
||||||
|
client_message_id: str | None = Form(default=None),
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> RoundMessageOut:
|
||||||
|
if not body and image is None:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
||||||
|
raw = await storage.read_optional_image(image)
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
|
||||||
|
if client_message_id is not None:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message "
|
||||||
|
"WHERE tournament_round_id = $1 AND client_message_id = $2",
|
||||||
|
round_id, client_message_id,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
reactions = await _message_reactions(conn, [existing["id"]], user.user_id)
|
||||||
|
comment_counts = await _message_comment_counts(conn, [existing["id"]])
|
||||||
|
return _row_to_message(
|
||||||
|
existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
image_key = None
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
image_key = await storage.upload_image("tournament_round_messages", round_id, raw)
|
||||||
|
except storage.InvalidImageError:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||||
|
|
||||||
|
display_name = await _resolve_author_name(conn, user.user_id)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO tournament_round_message
|
||||||
|
(organization_id, tournament_round_id, author_user_id, author_display_name,
|
||||||
|
body, image_key, client_message_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING {_MESSAGE_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id, round_id, user.user_id, display_name, body, image_key, client_message_id,
|
||||||
|
)
|
||||||
|
return _row_to_message(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
async def delete_tournament_round_message(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
message_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
"""Forfatteren selv, ELLER org-eier/admin -- samme modell som Banter
|
||||||
|
Board sin `delete_feed_message` (ADR-025), ikke frittstående sidens
|
||||||
|
"runde-eier" (turneringsrunder har ingen eier i den forstand)."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
author_id = await conn.fetchval(
|
||||||
|
"SELECT author_user_id::text FROM tournament_round_message "
|
||||||
|
"WHERE id = $1 AND tournament_round_id = $2",
|
||||||
|
message_id, round_id,
|
||||||
|
)
|
||||||
|
if author_id is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
|
||||||
|
if user.user_id != author_id and not await is_org_admin(conn, organization_id, user.user_id):
|
||||||
|
raise app_error(
|
||||||
|
403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne innlegg (eller som organisasjonsadministrator)."
|
||||||
|
)
|
||||||
|
await conn.execute("DELETE FROM tournament_round_message WHERE id = $1", message_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reaksjoner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction",
|
||||||
|
response_model=list[ReactionSummary],
|
||||||
|
)
|
||||||
|
async def set_tournament_round_message_reaction(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
message_id: str,
|
||||||
|
payload: ReactionIn,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[ReactionSummary]:
|
||||||
|
if payload.emoji not in ALLOWED_REACTION_EMOJIS:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
await _get_message_or_404(conn, round_id, message_id)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tournament_round_message_reaction (organization_id, tournament_round_message_id, user_id, emoji)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (tournament_round_message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
|
||||||
|
""",
|
||||||
|
organization_id, message_id, user.user_id, payload.emoji,
|
||||||
|
)
|
||||||
|
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
||||||
|
return summaries.get(message_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction",
|
||||||
|
response_model=list[ReactionSummary],
|
||||||
|
)
|
||||||
|
async def remove_tournament_round_message_reaction(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
message_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[ReactionSummary]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
await _get_message_or_404(conn, round_id, message_id)
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM tournament_round_message_reaction WHERE tournament_round_message_id = $1 AND user_id = $2",
|
||||||
|
message_id, user.user_id,
|
||||||
|
)
|
||||||
|
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
||||||
|
return summaries.get(message_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trådede kommentarer -- flat lagring (parent_comment_id), frontend bygger
|
||||||
|
# tre-strukturen. Kronologisk (eldst først), ulikt meldingslisten selv.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments",
|
||||||
|
response_model=list[CommentOut],
|
||||||
|
)
|
||||||
|
async def list_tournament_round_message_comments(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
message_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[CommentOut]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
await _get_message_or_404(conn, round_id, message_id)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"SELECT {_COMMENT_COLUMNS} FROM tournament_round_message_comment "
|
||||||
|
"WHERE tournament_round_message_id = $1 ORDER BY created_at ASC",
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
return [_row_to_comment(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments",
|
||||||
|
response_model=CommentOut,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def post_tournament_round_message_comment(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
message_id: str,
|
||||||
|
payload: CommentIn,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CommentOut:
|
||||||
|
body = payload.body.strip()
|
||||||
|
if not body:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
await _get_message_or_404(conn, round_id, message_id)
|
||||||
|
if payload.parent_comment_id is not None:
|
||||||
|
parent_exists = await conn.fetchval(
|
||||||
|
"SELECT id FROM tournament_round_message_comment "
|
||||||
|
"WHERE id = $1 AND tournament_round_message_id = $2",
|
||||||
|
payload.parent_comment_id, message_id,
|
||||||
|
)
|
||||||
|
if parent_exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
|
||||||
|
|
||||||
|
display_name = await _resolve_author_name(conn, user.user_id)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO tournament_round_message_comment
|
||||||
|
(organization_id, tournament_round_message_id, parent_comment_id, author_user_id, author_display_name, body)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING {_COMMENT_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id, message_id, payload.parent_comment_id, user.user_id, display_name, body,
|
||||||
|
)
|
||||||
|
return _row_to_comment(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments/{comment_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
async def delete_tournament_round_message_comment(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
message_id: str,
|
||||||
|
comment_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
"""Forfatteren selv, ELLER org-eier/admin -- samme modell som selve
|
||||||
|
innlegget. Kaskade-sletter eventuelle svar under (FK ON DELETE CASCADE)."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
await _get_tournament_round_or_404(conn, tournament_id, round_id)
|
||||||
|
await _get_message_or_404(conn, round_id, message_id)
|
||||||
|
author_id = await conn.fetchval(
|
||||||
|
"SELECT author_user_id::text FROM tournament_round_message_comment "
|
||||||
|
"WHERE id = $1 AND tournament_round_message_id = $2",
|
||||||
|
comment_id, message_id,
|
||||||
|
)
|
||||||
|
if author_id is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
||||||
|
if user.user_id != author_id and not await is_org_admin(conn, organization_id, user.user_id):
|
||||||
|
raise app_error(
|
||||||
|
403, "NOT_COMMENT_AUTHOR",
|
||||||
|
"Du kan kun slette dine egne kommentarer (eller som organisasjonsadministrator).",
|
||||||
|
)
|
||||||
|
await conn.execute("DELETE FROM tournament_round_message_comment WHERE id = $1", comment_id)
|
||||||
|
|
@ -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" && (
|
||||||
|
|
|
||||||
561
frontend/components/tournament-round-messages.tsx
Normal file
561
frontend/components/tournament-round-messages.tsx
Normal 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 på 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"} på 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 nå
|
||||||
|
</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 på 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
|
||||||
316
tests/test_tournament_round_messages.py
Normal file
316
tests/test_tournament_round_messages.py
Normal 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 == []
|
||||||
Loading…
Reference in a new issue