diff --git a/090_tournament_round_message.sql b/090_tournament_round_message.sql new file mode 100644 index 0000000..ccf7798 --- /dev/null +++ b/090_tournament_round_message.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index eaf980b..b9e28b8 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c8045..0e48672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14403,3 +14403,56 @@ Neste steg: frittstående runde vs. org-turnering") -- erstatter den tidligere muntlige listen. Ingen prioritering/bygging startet -- venter på at 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`). diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 1912b5e..a9d6f1f 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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 **middels/stor** -- `round_shot`s eierskap er i dag kablet til frittstående-runde-XOR-en (ADR-048 Beslutning C), ekte backend-arbeid. -7. **Kommentarfelt/tråd på runden finnes ikke for turneringsrunder.** - `` (ADR-044, med bilde-vedlegg + offline-kø + push) er - kun koblet til `round_id`. Ingen tilsvarende i den autentiserte - turneringsvisningen. Antatt **middels** -- UI-komponenten er stort - sett gjenbrukbar, men trenger enten en ny `tournament_round_message`- - tabell eller en generalisering av eierskapsmodellen. +7. ✅ **Kommentarfelt/tråd på runden -- FERDIG BYGGET 2026-08-22 (ADR-102), + se CHANGELOG punkt 160.** Ny `tournament_round_message`-tabell + (org-scopet, RLS), én tråd per turneringsrunde (hele feltet, ikke + per flight -- bevisst valg). Reaksjoner + trådede kommentarer med. + Bevisst utenfor v1: @-tagging, sanntid/WebSocket. Verifisert i + scratch, **ikke rullet ut mot ekte containere ennå** -- venter på + migrasjon 090 + deploy-bekreftelse. 8. **Tilskuer-metrikkveksling (brutto/netto/Stableford/poeng) finnes kun for frittstående runder.** `round-leaderboard.tsx` sin `MetricToggle` 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 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). + +## 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. diff --git a/app/main.py b/app/main.py index d623f7f..e5b737b 100644 --- a/app/main.py +++ b/app/main.py @@ -32,6 +32,7 @@ from .routers import ( round_messages, rounds, scoring, + tournament_round_messages, tournaments, ) from . import storage @@ -66,6 +67,7 @@ app.include_router(messaging.router) app.include_router(messaging.public_router) app.include_router(rounds.router) app.include_router(round_messages.router) +app.include_router(tournament_round_messages.router) app.include_router(friends.router) app.include_router(notifications.router) app.include_router(print_pdf.router) diff --git a/app/routers/tournament_round_messages.py b/app/routers/tournament_round_messages.py new file mode 100644 index 0000000..6f7e6f5 --- /dev/null +++ b/app/routers/tournament_round_messages.py @@ -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) diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index dfc3a7b..43f0f9b 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -72,6 +72,7 @@ import { TournamentPresentationPanel } from "@/components/tournament-presentatio import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { CourseTemplatePicker } from "@/components/course-template-editor" +import { TournamentRoundMessages } from "@/components/tournament-round-messages" import { cn } from "@/lib/utils" // --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) -- @@ -410,6 +411,9 @@ export function IndividualTournamentDetail({ // sperre/begrunnelse som tournament-detail.tsx (lagformatet). const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null) 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(null) const base = `/orgs/${organizationId}/tournaments/${tournamentId}` @@ -417,10 +421,18 @@ export function IndividualTournamentDetail({ let cancelled = false fetch("/auth/me", { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) - .then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => { - if (cancelled || !me) return - setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null) - }) + .then( + ( + 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(() => {}) return () => { cancelled = true @@ -1102,6 +1114,8 @@ export function IndividualTournamentDetail({ rounds={rounds} scoringMethod={tournament.scoring_method} participants={participants} + currentUserId={currentUserId} + isOrgAdmin={isOrgAdmin} onError={setError} /> )} @@ -2963,12 +2977,16 @@ function ScoreTab({ rounds, scoringMethod, participants, + currentUserId, + isOrgAdmin, onError, }: { base: string rounds: ApiRound[] scoringMethod: string | null participants: ApiParticipant[] + currentUserId: string | null + isOrgAdmin: boolean onError: (message: string) => void }) { const [roundId, setRoundId] = useState(rounds[0]?.id ?? "") @@ -3115,6 +3133,9 @@ function ScoreTab({ // samme fiks som round-detail.tsx sin PlayerHoleCards/ScorecardGrid- // splitt. 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 hasNextPlayer = wizardIdx !== -1 && wizardIdx < chain.length - 1 const nextPlayerName = hasNextPlayer ? chain[wizardIdx + 1].player_name : null @@ -3415,6 +3436,34 @@ function ScoreTab({ )} + {/* 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 && ( +
+ + {showComments && ( +
+ +
+ )} +
+ )} + {/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon, uavhengig av gross_strokes-registreringen over. */} {round && scoringMethod === "bingo_bango_bongo" && ( diff --git a/frontend/components/tournament-round-messages.tsx b/frontend/components/tournament-round-messages.tsx new file mode 100644 index 0000000..3b1a2a9 --- /dev/null +++ b/frontend/components/tournament-round-messages.tsx @@ -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 { + 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([]) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + + const [body, setBody] = useState("") + const [imageFile, setImageFile] = useState(null) + const [imagePreview, setImagePreview] = useState(null) + const [posting, setPosting] = useState(false) + const [postError, setPostError] = useState(null) + const [deletingId, setDeletingId] = useState(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([]) + + const fileInputRef = useRef(null) + const cameraInputRef = useRef(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 + 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) { + 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, 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 = { 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 ( +
+
+

Kommentarer

+ +
+ + {/* Composer */} +
+ +