diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c1f4714..c84c774 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -401,7 +401,10 @@ "Bash(python3 test_oom_backend.py)", "Bash(grep -n \"Dine organisasjoner\\\\|organizations/\\\\[id\\\\]\\\\|/organizations/\\\\${\" /opt/teecup/frontend/components/dashboard.tsx)", "Bash(python3 -c \"import fastapi\")", - "Bash(openssl rand *)" + "Bash(openssl rand *)", + "Bash(curl -s -X POST http://localhost:18100/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"scratch-official-e2e@testmailbox.golf\"}')", + "Bash(docker rm -f teecup_frontend_scratch6_run teecup_api_scratch6_run teecup-minio-scratch6 2>&1 *)", + "Bash(grep -n \"^[0-9]\\\\{1,2\\\\}\\\\. \\\\*\\\\*\" /opt/teecup/CHANGELOG.md)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/058_round_messages.sql b/058_round_messages.sql new file mode 100644 index 0000000..c2f00aa --- /dev/null +++ b/058_round_messages.sql @@ -0,0 +1,43 @@ +-- ===================================================================== +-- TeeCup — Kommentarer/bilder på frittstående runder, migrasjon 058 +-- (ADR-044). +-- +-- Samme idé som org-turneringers "Banter Board" (message-tabellen, +-- migrasjon 013, ADR-025) -- forfatter-snapshot, valgfri tekst, valgfritt +-- bilde -- men EGEN tabell, ikke en utvidelse av `message`. `round` er +-- IKKE org-scopet og har INGEN RLS (ADR-033 Beslutning A), så den delte +-- message-tabellens organization_id NOT NULL + RLS-policy passer ikke +-- uendret her. +-- +-- Autorisasjon (ADR-044 Beslutning A): ALLE som kan SE runden +-- (_can_view_round i app/routers/rounds.py -- eier, lenket medspiller, +-- eller en venn/offentlighet med visibility_mode-tilgang) kan også +-- POSTE. Samme "kan se = kan bidra"-modell som org-feeden, håndhevet i +-- app-laget (app/routers/round_messages.py), ikke her i skjemaet. +-- ===================================================================== +\set ON_ERROR_STOP on + +CREATE TABLE round_message ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + round_id uuid NOT NULL REFERENCES round(id) ON DELETE CASCADE, + author_user_id uuid NOT NULL REFERENCES app_user(id), + -- Frosset ved skrivetidspunkt, samme prinsipp som message.author_display_name + -- -- her FULLT NAVN (ikke fornavn alene, dette er en forfatter-liste, + -- ikke direkte adressering av mottakeren -- CLAUDE.md navneformat-regel). + author_display_name text NOT NULL, + body text, + image_key text, + created_at timestamptz NOT NULL DEFAULT now(), + + CHECK (body IS NOT NULL OR image_key IS NOT NULL) +); + +CREATE INDEX round_message_round_idx ON round_message (round_id, created_at); +CREATE INDEX round_message_author_idx ON round_message (author_user_id); + +-- INGEN RLS -- samme plain_connection()-mønster som round/round_participant/ +-- round_hole (ADR-033 Beslutning A). Autorisasjon håndheves eksplisitt i +-- app-laget via round_id-oppslag + _can_view_round, ikke database-policy. +GRANT SELECT, INSERT, DELETE ON round_message TO teecup_app; +-- Ingen UPDATE-grant -- meldinger redigeres ikke, kun slettes, samme som +-- den org-scopede message-tabellen. diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 9d2abe7..10f0d40 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -4069,6 +4069,118 @@ seksjon). Full detalj i CHANGELOG.md 2026-08-04. --- +## ADR-044: Kommentarer/bilder på frittstående runder + samlet feed + +Reist av brukeren 2026-08-06: samme "Banter Board"-mulighet (bilde+tekst) +som org-turneringer allerede har (ADR-025), men for en enkelt, frittstående +runde — pluss en sentral feed-side som samler dette på tvers av runder. +Underveis avdekket research at rundevisibilitet (ADR-036 fase 2 — +`round.visibility_mode`/`round_visible_category`) allerede var bygget og +live siden 2026-07-28 (en eldre, aldri oppdatert CHANGELOG-seksjon sa +fortsatt "ikke bygget") — denne ADR-en gjenbruker den infrastrukturen +uendret, den handler kun om selve kommentarene og feed-aggregeringen. + +**Beslutning A — Skriverett: kan-se-kan-poste.** Alle som kan SE en runde +(eier, lenket medspiller, `public`-synlig, eller riktig-kategorisert +`friends`-venn — samme `_can_view_round` som ADR-036) kan også POSTE et +bilde/kommentar til den. Samme "kan se = kan bidra"-modell som org-feeden, +IKKE begrenset til eier+medspillere. Avklart eksplisitt med bruker +(AskUserQuestion) — overstyrer et tidligere, snevrere "eier+medspillere"- +utkast notert i FEATURE_BACKLOG.md 2026-08-06. + +**Beslutning B — Moderering: forfatter ELLER rundeeier.** Samme +"forfatter-eller-admin"-modell som org-feedens `delete_feed_message` +(ADR-025 Beslutning C) — rundens eier er her analogen til org-admin. Ulikt +lag-chattens strengere "kun forfatter, ingen unntak" (den er ekte privat, +en runde-kommentarseksjon åpen for tredjeparter trenger en reell +moderasjonsvei). + +**Beslutning C — Egen `round_message`-tabell (migrasjon 058), ikke +gjenbruk av org sin `message`-tabell.** `round` har verken +`organization_id` eller RLS (ADR-033 Beslutning A) — en delt tabell ville +krevd enten en kunstig organisasjon rundt hver frittstående runde eller et +RLS-unntak. Samme feltform (forfatter-snapshot, valgfri tekst, valgfri +bilde-nøkkel) og samme MinIO+AVIF-pipeline (`app/storage.py`, ADR-018) +gjenbrukt uendret, egen `prefix="round_messages"`. Autorisasjon håndheves +utelukkende i app-laget (`app/routers/round_messages.py`, +`plain_connection()`), ikke database-policy — samme mønster som +`round`/`round_participant`. + +**Beslutning D — Sanntid: ingen ny WebSocket-kanal.** Gjenbruker den +eksisterende `broadcast_round_update`/`/ws/rounds/{id}/live`-kringkastingen +(allerede trigget ved hver hull-endring) fremfor et nytt, payload-bærende +kanal-mønster (slik org-feeden bruker). `round-detail.tsx` sin eksisterende +WS-lytter fikk ett nytt tick-signal (`messagesRefreshTick`) som +`RoundMessages` reagerer på med en vanlig REST-refetch — ingen egen socket +for kommentarer. Bekreftet fungerende i ekte nettleser (se +CHANGELOG.md 2026-08-06) — MERK at dette KUN virker gjennom Caddy sin +dedikerte `handle /ws/* { reverse_proxy teecup_api:8000 }`-rute +(`/opt/teeoff/deploy/Caddyfile`), IKKE gjennom Next.js sitt eget +rewrite-lag (som ikke pålitelig proxyer WebSocket-oppgraderinger i +standalone-modus) — samme forutsetning som all annen runde-/turnering- +sanntid i appen. + +**Beslutning E — Ny, dedikert `/my-feed`-side, IKKE en dashbord-seksjon.** +Avklart eksplisitt med bruker. Aggregerer på tvers av egne runder (uansett +`visibility_mode`) + venners synlige runder, modellert på +`list_friends_on_course` (`GET /friends/on-course`) sin reverserte retning +(viewer → venners synlige runder), men uten "spiller nå/siste 24t"- +filteret — en all-time, paginert (keyset, `?before=`) liste av +`round_message`-rader. Nås fra dashbordets "Venner på banen"-seksjon +(`SeeAllLink`) og en ny lenke i `/more`. + +**KRITISK, funnet under full-stack-verifisering: rute-/rewrite-kollisjon, +samme klasse som `/rounds`→`/my-rounds` og `/friends`→`/my-friends` +(dokumentert i `frontend/next.config.mjs`).** Frontend-siden ble først lagt +på `/feed` — nøyaktig samme streng som det flate API-endepunktet `GET +/feed`. Uten en eksplisitt rewrite-regel vant Next.js sin egen side-rute +over ethvert forsøk på server-side proxy, så `fetch("/feed")` fra klienten +fikk appens egen HTML tilbake i stedet for JSON (stille feilet +JSON-parsing). Rettet ved (1) å flytte frontend-siden til `/my-feed` +(samme navnemønster som de tre presedensene over) OG (2) legge til en +manglende, eksplisitt `{ source: "/feed", destination: ... }`-regel i +`next.config.mjs` (det flate mønsteret, uten `:path*`, siden `/feed` ikke +har understier) — retting nummer to var nødvendig uavhengig av +sideflyttingen, siden regelen rett og slett manglet helt fra start. + +**Scratch-verifisert grundig** (isolert scratch-DB/rolle/MinIO/API-/ +frontend-container, PLUSS en minimal scratch-Caddy satt opp spesifikt for +å faktisk bevise WS-refetchen — ikke bare anta at den ville virke i +produksjon): 30/30 håndregnede API-sjekker (hele autorisasjonsmatrisen — +eier poster/sletter egen, medspiller poster, en annen ikke-eier kan IKKE +slette andres, eier KAN moderere andres, fremmed uten innsyn avvist på +GET+POST, `friends`-synlighet riktig inkludert/ekskludert per kategori, +feed-aggregering korrekt per bruker, paginering uten hopp/duplikat, anonym +`/feed`-tilgang korrekt 401). Ekte bildeopplasting bekreftet konvertert til +AVIF og lagret riktig. Ekte nettleser: kommentar+bilde postet og vist, +sletting fungerte, `/my-feed` viste riktig aggregert sett (egne + andres +offentlige runder), WS-refetch bekreftet fungerende GJENNOM Caddy (en +kommentar postet i én "økt" dukket opp automatisk i en åpen fane uten +interaksjon). Ekte typesjekket produksjonsbuild av frontend kjørt og +bekreftet (fanget rute-kollisjonen over ved kjøretid, ikke ved bygging — +typesjekk alene fanger ikke denne klassen feil). `test_isolation.sql` +uendret 12/12. Scratch-miljøet (DB/rolle/MinIO/API-/frontend-/Caddy- +containere/images, V0-eksport-zip-er) ryddet opp fullstendig etter bruk. + +**Frontend bygget via V0** (brukerens eksplisitte, stående arbeidsfordeling +— Claude skriver V0-prompten, bruker limer inn selv, Claude integrerer +eksporten): to prompter (kommentarseksjon + feed-side), begge levert med +eksakte data-kontrakter/design-tokens fra `DESIGN_SYSTEM.md` forhåndsutfylt +i prompten. Begge eksportene (zip 32/33) diffet ordrett mot hverandre og +mot ingenting-fra-før (nye filer) før integrering — ingen utilsiktet drift. +To små, bevisste integrasjonsjusteringer utover ren copy-inn: fjernet en +ubrukt `cn`-import, og lot `Feed`-komponenten selv hente `/auth/me` (samme +mønster som `round-detail.tsx`) i stedet for å kreve `currentUserId` som +ekstern prop, siden alle andre sider i appen er tynne wrappere som selv +henter egen brukeridentitet. + +**Bevisst utenfor omfang:** egen payload-bærende WS-kanal for kommentarer, +redigering av innlegg (kun slett, samme som org-feeden), liker/reaksjoner, +varsler ved ny kommentar, sanntid på selve `/my-feed`-siden, inline +slett-knapp på `/my-feed` (kun fra runde-siden). + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c789f..b9084b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7850,3 +7850,161 @@ Neste steg: **Rullet ut live 2026-08-05** (kun `teecup_frontend` bygget/ restartet -- ren frontend-endring, ingen skjema-/API-endring). `/my-rounds/new` → 200 over https etterpå. + + **Oppfølging samme kveld, 2026-08-06:** brukeren sendte skjermdump og + rapporterte at knappen FORTSATT dukket opp -- men i en ANNEN gren enn + den akkurat rettet: `CourseList` (offisielle baner, "Tjøme Golfklubb" + → "Hovedbanen") viste den for enhver offisiell bane, uansett om målet + var å spille eller å opprette. Punktet over rettet kun `OwnSearch` + (egne baner) -- samme feilmønster fantes uavhengig i den offisielle + grenen, siden begge alltid hadde vist knappen unconditionalt bortsett + fra en `templateHoles.length > 0`-sjekk. + + **Utvidet fiks:** erstattet den forrige, EGEN-bane-spesifikke + `"own-search-template"`-S1Sub-verdien med en generell boolsk + `templateMode`-state (løftet til toppnivå, ved siden av `s1Sub`). + `CourseList`s `onUseAsTemplate` gjort valgfri på samme måte som + `OwnSearch`s allerede var -- begge rendrer nå kun + `{onUseAsTemplate && ...}`, styrt av `templateMode`. Ny mellomskjerm + `"template-source"` (samme to `SourceCard`-valg som det aller første + Bane&tid-steget, men med egen tittel "Basér på en eksisterende bane") + lar brukeren velge OFFISIELL eller EGEN bane som malkilde -- nådd fra + samme "Basér på en eksisterende bane i stedet"-lenke i det blanke + opprett-skjemaet. `templateMode` settes eksplisitt `false` i det + aller første Bane&tid-steget (fresh start) og ved "Avbryt" fra + opprett-skjemaet, `true` kun fra `"template-source"`. + + **Scratch-verifisert på nytt** (sjette scratch-miljø): gjenskapte + brukerens eksakte scenario (offisiell-bane-søk → "Tjøme Golfklubb" → + "Hovedbanen") mot ekte teeoff-data, bekreftet at "Bruk som mal for + egen bane" IKKE lenger vises der; bekreftet at "template-source"- + mellomskjermen vises korrekt fra opprett-skjemaets lenke; valgte + "Offisiell bane" derfra, søkte samme klubb, bekreftet at knappen NÅ + vises (med justert beskrivelsestekst: "Velg hvilken bane du vil + bruke som mal..."); trykket den, bekreftet at opprett-skjemaet + forhåndsfylte navn/18 ekte hull/4 ekte utslag korrekt fra Hovedbanen. + Ingen konsoll-feil. Scratch-miljøet ryddet opp fullstendig. + + **Rullet ut live 2026-08-06** (kun `teecup_frontend`). + `/my-rounds/new` → 200 over https etterpå. + +25. **Kommentarer/bilder på frittstående runder + samlet `/my-feed`-side + (ADR-044, migrasjon 058) — BYGGET OG SCRATCH-VERIFISERT 2026-08-06, + venter på utrulling mot ekte `teecup_db`.** Brukeren ba om samme + "Banter Board"-mulighet (bilde+tekst) som org-turneringer allerede har + (ADR-025), for en enkelt frittstående runde, pluss en sentral feed-side + som samler dette på tvers av runder. Underveis avdekket research at + rundevisibilitet (ADR-036 fase 2 -- `round.visibility_mode`/ + `round_visible_category`) allerede var bygget og live siden 2026-07-28 + -- en gammel "Neste steg"-seksjon i denne loggen sa fortsatt "ikke + bygget", aldri rettet opp. Denne runden gjenbrukte den infrastrukturen + uendret; omfanget ble dermed smalere enn først antatt. + + **To beslutninger avklart eksplisitt med bruker (AskUserQuestion) FØR + planlegging:** (1) skriverett = alle som kan SE runden kan også POSTE + (ikke kun eier/medspillere -- overstyrer et snevrere utkast notert + tidligere samme dag i FEATURE_BACKLOG.md), (2) feed på en ny, dedikert + side (`/my-feed`), ikke en dashbord-seksjon. Fullstendig plan skrevet + og godkjent i Plan Mode før noe ble bygget (to Explore-runder + én + Plan-agent-syntese, grundig kodegrunnet mot eksisterende `_can_view_ + round`/`list_friends_on_course`/`messaging.py`-mønstre). + + **Datamodell:** ny, EGEN `round_message`-tabell (migrasjon 058), IKKE + en utvidelse av org sin `message`-tabell -- `round` har verken + `organization_id` eller RLS (ADR-033 Beslutning A). Samme feltform + (forfatter-snapshot, valgfri tekst, valgfri `image_key`) og samme + MinIO+AVIF-pipeline (`app/storage.py`) gjenbrukt uendret, egen + `prefix="round_messages"`. Scratch-verifisert alene først: skjema, + indekser, grants bekreftet, `test_isolation.sql` 12/12. + + **Backend:** ny fil `app/routers/round_messages.py` (`rounds.py` er + allerede 5500+ linjer) -- `GET`/`POST /rounds/{id}/messages`, `DELETE + /rounds/{id}/messages/{message_id}` (forfatter ELLER rundeeier kan + slette, samme "forfatter-eller-admin"-modell som org-feedens + `delete_feed_message`), og en ny `GET /feed`-aggregering modellert på + `list_friends_on_course` sin reverserte retning (viewer → synlige + runder), justert til å inkludere egne runder uansett `visibility_mode` + og uten "spiller nå"-filteret (all-time, keyset-paginert). Gjenbruker + `_can_view_round`/`_get_viewable_round_or_404` fra `rounds.py` uendret + som BÅDE lese- og post-sjekk. Ingen ny WebSocket-kanal -- gjenbruker + eksisterende `broadcast_round_update`, `RoundMessages`-komponenten + henter på nytt via et nytt `messagesRefreshTick`-signal i + `round-detail.tsx` sin allerede eksisterende WS-lytter. Liten + refaktor i samme runde: `messaging.py` sin lokale `_read_optional_ + image` flyttet til `app/storage.py` som delt `read_optional_image`, + brukt av begge routerne nå. + + **En reell bug funnet OG rettet FØR noen verifisering:** `GET /feed` + sin `before`-parameter krasjet med 500 (`asyncpg.exceptions.DataError`) + -- asyncpg godtar ikke en ren tekststreng mot en `timestamptz`-cast i + SQL-en, krever et faktisk `datetime`-objekt. Rettet ved å la Pydantic + parse query-parameteren som `datetime` direkte i stedet for `str`. + + **Håndregnet auth-testskript, 30/30 sjekker bestått:** eier poster/ + sletter egen (private) runde; lenket medspiller (ikke eier) poster, + en ANNEN ikke-eier-medspiller kan IKKE slette den (403); eieren KAN + slette andres innlegg på egen runde (moderasjon); en fremmed uten + innsyn avvist på både GET og POST (403/404) for en privat runde; en + `friends`-synlig runde med RIKTIG venne-kategori gir GET+POST; med + FEIL/manglende kategori avvises selv GET; `GET /feed` bekreftet riktig + sett per bruker (eier ser alle egne uansett visibility, en venn ser + kun offentlige + riktig-kategoriserte `friends`-runder, en fremmed ser + kun offentlige), paginering (`before`-cursor) uten hopp/duplikat. + Ekte bildeopplasting testet direkte mot scratch-MinIO (utenfor HTTP, + `storage._client.stat_object`) -- bekreftet 488 byte AVIF, + `content_type=image/avif`. + + **Frontend bygget via V0** (brukerens eksplisitte, stående + arbeidsfordeling -- se CLAUDE.md-notat): to prompter skrevet og + levert i chat (kommentarseksjon, feed-side), begge forankret i + `DESIGN_SYSTEM.md` sine fargetokens/tilgjengelighetsregler og eksakte + data-kontrakter fra det allerede bygde API-et. Zip 32 (`round- + messages.tsx`) og zip 33 (samme + ny `feed.tsx`) diffet ordrett mot + hverandre FØR integrering -- ingen utilsiktet drift utover den ene + nye filen. To små, bevisste integrasjonsjusteringer: fjernet en + ubrukt `cn`-import, og lot `Feed`-komponenten selv hente `/auth/me` + (samme mønster som `round-detail.tsx`) i stedet for å kreve + `currentUserId` som ekstern prop -- konsekvent med at alle andre sider + i appen er tynne wrappere som selv henter egen brukeridentitet. + `RoundMessages` montert som en ny seksjon NEDERST på `round-detail. + tsx` sin `pageTab === "score"` (ikke en ny rundefane, ikke inni + admin-"manage"-fanen -- se ADR-044 for begrunnelsen). Ny `/my-feed`- + side + inngangspunkter: `SeeAllLink` lagt til dashbordets "Venner på + banen"-seksjon, ny lenke i `/more`-menyen. + + **KRITISK rute-/rewrite-kollisjon funnet under full-stack- + verifisering, samme klasse som `/rounds`→`/my-rounds` og + `/friends`→`/my-friends`:** frontend-siden ble først lagt på `/feed` + -- nøyaktig samme streng som det flate API-endepunktet. Uten en + eksplisitt rewrite-regel vant Next.js sin egen side-rute, så + `fetch("/feed")` fra klienten fikk appens egen HTML tilbake (stille + feilet JSON-parsing, "Kunne ikke laste feeden"-feilmelding i UI-et). + Rettet ved å (1) flytte siden til `/my-feed` OG (2) legge til en + manglende, eksplisitt `/feed`-rewrite-regel i `next.config.mjs` (som + rett og slett ikke fantes fra før -- retting nummer to var nødvendig + uavhengig av sideflyttingen). Ekte typesjekket produksjonsbuild fanget + IKKE denne feilen (kun kjøretid avslørte den) -- funnet ved faktisk + nettleser-testing, ikke ved bygging. + + **En andre reell bug funnet under samme verifiseringsrunde:** + `RoundMessages` hentet meldinger KUN ved mount -- `round-detail.tsx` + sin eksisterende `/ws/rounds/{id}/live`-lytter (allerede der fra + tidligere, brukt av scorekort/format-resultat) var aldri koblet til + den nye kommentarseksjonen. Rettet med et nytt `messagesRefreshTick`- + signal, samme mønster som det eksisterende `formatResultRefreshTick`. + Verifisert med en EGEN, minimal scratch-Caddy (`handle /ws/* { + reverse_proxy ... }`, samme regel som den ekte `/opt/teeoff/deploy/ + Caddyfile`) satt opp spesifikt for denne testen -- uten den ville + WebSocket-oppgraderingen aldri nådd API-et i det hele tatt (Next.js + sitt eget rewrite-lag proxyer ikke WS pålitelig i standalone-modus, + dokumentert allerede i den ekte Caddyfilen). Bekreftet: en kommentar + postet via et separat API-kall dukket opp automatisk i en åpen + nettleserfane, ingen interaksjon eller reload nødvendig. + + **Scratch-miljøet ryddet opp fullstendig** etter bruk (DB/rolle/MinIO/ + API-/frontend-/Caddy-containere og -images, V0-eksport-zip-ene fjernet + fra prosjektroten uten å bli spurt, samme rutine som alltid for merget + V0-eksport). + + **IKKE rullet ut mot ekte `teecup_db` i denne runden** -- venter på + brukerens eksplisitte bekreftelse, samme rutine som alltid. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 1daf900..6af5e42 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1947,12 +1947,48 @@ forskjell mellom scramble og greensome i noen av domenene. | Video | 💤 | Fortsatt utsatt, egen ADR om/når etterspurt. | | 1-til-1 direktemeldinger | 💤 | Fortsatt utsatt, ikke etterspurt. | | Moderering (offentlig feed) | ✅ LIVE | Forfatteren selv, ELLER org-eier/admin, kan slette et innlegg. Lag-chatten har ingen moderering utover forfatteren (rommet er privat, org-admin har uansett ikke lesetilgang). | +| Bilder + kommentarer i frittstående (personlige) runder + samlet `/my-feed` | ✅ BYGGET, SCRATCH-VERIFISERT, venter på utrulling mot ekte `teecup_db` | ADR-044, migrasjon 058. Skriverett = kan-se-kan-poste (ikke kun eier/medspillere), se ADR-044 for full begrunnelse. | Se ARCHITECTURE_DECISIONS.md ADR-025 for alle fire hovedbeslutningene og CHANGELOG.md for full byggerunde (datamodell, autorisasjon, scratch- verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid, og utrulling). +### Bilder + kommentarer i frittstående runder + samlet feed — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-08-06 (ADR-044, migrasjon 058), venter på utrulling mot ekte `teecup_db` + +Brukeren: "I org-modulen har vi muligheter til bilder og kommentarer. Jeg +ønsker at dette også skal være mulig i single runder... Det må kanskje +også være en sentral feed-side?" — samme idé som `message`-tabellens +`tournament_feed`-scope (bilde + tekst), nå bygget for en vanlig +frittstående/personlig runde (`round`-tabellen, ADR-033), PLUSS en ny +`/my-feed`-side som aggregerer dette på tvers av runder. + +**Ny, egen `round_message`-tabell** (ikke en utvidelse av `message`) -- +`round` har verken `organization_id` eller RLS (ADR-033 Beslutning A), så +den delte org-tabellen passet ikke uendret. Se ADR-044 for full +begrunnelse og alle seks beslutningene (skriverett, moderering, datamodell, +sanntid, feed-plassering, rutenavngiving). + +**Rettelse av en tidligere antagelse i denne seksjonen:** utkastet notert +2026-08-06 tidligere samme dag foreslo "runde-eier + faktiske +medspillere" som autorisasjonskrets — brukeren avklarte eksplisitt (via +AskUserQuestion) en BREDERE modell: alle som kan SE runden (inkl. +`public`/riktig-kategorisert `friends`-synlighet, ADR-036 fase 2, som +viste seg å allerede være bygget og live siden 2026-07-28) kan også +POSTE, samme "kan se = kan bidra"-modell som org-feeden. + +**Verifisert:** 30/30 håndregnede API-sjekker (hele autorisasjonsmatrisen), +ekte bildeopplasting→AVIF bekreftet, ekte nettleser (kommentar+bilde +postet/vist/slettet, `/my-feed` viste riktig aggregert sett, WS-refetch +bekreftet fungerende GJENNOM en scratch-Caddy satt opp spesifikt for å +teste dette). Én kritisk rute-/rewrite-kollisjon (`/feed` vs. API-ets +`GET /feed`) funnet og rettet under verifisering — se ADR-044 for detalj. +`test_isolation.sql` uendret 12/12. + +**Ikke rullet ut mot ekte `teecup_db` ennå** — venter på brukerens +eksplisitte bekreftelse, samme rutine som alltid. Se CHANGELOG.md +2026-08-06 for full byggelogg. + --- ## Landingssider (turnering + organisasjon) — ADR-018 ✅ HELT FERDIG 2026-07-18 diff --git a/TeeCup logo.svg b/TeeCup logo.svg new file mode 100644 index 0000000..6896af5 --- /dev/null +++ b/TeeCup logo.svg @@ -0,0 +1,394 @@ + + + + + AABCHWp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAAEH3anVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo1ZTI5N2RiMi02NmU2LTQ1ZjAtYTNjMy1jMzE1MTZjZWE5NzMAAAAB5Gp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAO5qdW1iAAAAQWp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuYWN0aW9ucy52MgAAAAAYYzJzaKXkx8y3IMAJsMr8Ye/qsVoAAAClY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmxjMnBhLmNyZWF0ZWRtc29mdHdhcmVBZ2VudGhDYW52YSBBSXFkaWdpdGFsU291cmNlVHlwZXhTaHR0cDovL2N2LmlwdGMub3JnL25ld3Njb2Rlcy9kaWdpdGFsc291cmNldHlwZS9jb21wb3NpdGVXaXRoVHJhaW5lZEFsZ29yaXRobWljTWVkaWEAAADFanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaC58ECUaO7UOTEYs77pAvg4AAAB9Y2JvcqVqZXhjbHVzaW9uc4GiZXN0YXJ0GQESZmxlbmd0aBlYKGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggBCzizGdIaYuVMnolayoF9+WgI7Bw64+ppItmbumwGUpjcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAAB3GNib3Knamluc3RhbmNlSUR4LHhtcDppaWQ6NDliYTViNWQtNmVhNS00ZmJlLTgyYWMtMzg1YTcxZmUyZjY1dGNsYWltX2dlbmVyYXRvcl9pbmZvo2RuYW1lZ2MycGEtcnNndmVyc2lvbmUwLjAuMHdvcmcuY29udGVudGF1dGguYzJwYV9yc2UwLjAuMGlzaWduYXR1cmV4TXNlbGYjanVtYmY9L2MycGEvdXJuOmMycGE6NWUyOTdkYjItNjZlNi00NWYwLWEzYzMtYzMxNTE2Y2VhOTczL2MycGEuc2lnbmF0dXJlcmNyZWF0ZWRfYXNzZXJ0aW9u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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQIAlL7V4NQY7Jwjk2Y0dnoUfBHcfvIx0jMriJD8CXXcY12XQcsJCC/wCLWLZFzLKtCXH18SLeSFH9S+vwL5CCSrs+PeUE2woT/jQ1LgYIy8GiJw7P/YZiNcOFT5E6yc/aEZWd6nFW4YrYQ+MKuCainGazZQfOe0NZC26Ae4KuLVDo+eFf41uXcI64I+yPWVPxWoWiLoeUljQ26PvgJlIGqippcTiVKwFPlOcxub6pctmPR8Y4UfHSj5dCVpMvu6OeBmOVtzdQ8Nw+bOtPup4HOA5ohStm5z09/1BIb8Z3oiDNFtmeyMAXdB29BkndOUaLjy7RSZGZYSRDqz/m1dn/UmkiH6FJlqcmEl+0A4ndRiLP/xzNgdmGBTiC+90GuRNkasFENd+lpW49U1rM6WX06CV1JSjy4MrYxzbkpCYx/jMsmBNE0M9LA8Mu19Q8fhTT/wW7aMy7pPDWhZdvmmA123WYJNK/qJLIC+vDzAAP2QDHIv5ozxe/KaLOGSac1pLcSV+n4BHRxzmZhfaQwqPMesizsukxtta6r/1bnFVXs9RMOqTLx0m2hGd3Mq2grmUMcv/i/EQd98ZWUUmKF1c3fbiFjohYHgydHAe+cmHWfZxHhWsUJu90qSkrSgjEzjS0siPBtsrgM+xBtvUyTYQWqUgcqHvt/s3hQ6p6Vpv+99VVM= + Yes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/main.py b/app/main.py index 617779c..0f41a09 100644 --- a/app/main.py +++ b/app/main.py @@ -27,6 +27,7 @@ from .routers import ( organizations, players, registration, + round_messages, rounds, scoring, tournaments, @@ -60,6 +61,7 @@ app.include_router(registration.org_router) 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(friends.router) app.include_router(notifications.router) diff --git a/app/routers/messaging.py b/app/routers/messaging.py index f0c892d..d9d05b3 100644 --- a/app/routers/messaging.py +++ b/app/routers/messaging.py @@ -81,17 +81,6 @@ async def _resolve_author_display_name(conn, organization_id: str, user_id: str) return email.split("@")[0] if email else "Ukjent" -async def _read_optional_image(image: UploadFile | None) -> bytes | None: - if image is None: - return None - if image.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES: - raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.") - raw = await image.read(storage.MAX_UPLOAD_BYTES + 1) - if len(raw) > storage.MAX_UPLOAD_BYTES: - raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).") - return raw - - # --- Sanntid: in-memory tilkoblingsregister (se moduldocstring) ------------ _team_sockets: dict[str, set[WebSocket]] = defaultdict(set) @@ -163,7 +152,7 @@ async def send_team_message( ) -> MessageOut: if not body and image is None: raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.") - raw = await _read_optional_image(image) + raw = await storage.read_optional_image(image) async with org_connection(organization_id) as conn, translate_db_errors(): team = await conn.fetchrow( @@ -316,7 +305,7 @@ async def post_to_feed( ) -> MessageOut: if not body and image is None: raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.") - raw = await _read_optional_image(image) + raw = await storage.read_optional_image(image) organization_id = await resolve_org(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): diff --git a/app/routers/round_messages.py b/app/routers/round_messages.py new file mode 100644 index 0000000..7053ad6 --- /dev/null +++ b/app/routers/round_messages.py @@ -0,0 +1,269 @@ +""" +Kommentarer/bilder på frittstående runder (ADR-044) -- "Banter Board" for +en enkelt runde (`round`/migrasjon 020), pluss en samlet `/feed`-side som +aggregerer disse på tvers av runder brukeren har innsyn i. + +To bevisste forskjeller fra org-feeden (`messaging.py`, ADR-025): + +- Egen `round_message`-tabell (migrasjon 058), IKKE en utvidelse av den + org-scopede `message`-tabellen -- `round` har verken `organization_id` + eller RLS (ADR-033 Beslutning A), så all autorisasjon her skjer i + app-laget via `plain_connection()`, ikke database-policy. +- Skriverett = leserett: ALLE som kan SE runden (`_can_view_round`, + gjenbrukt uendret fra `rounds.py`, ADR-036 fase 2) kan også POSTE -- + samme "kan se = kan bidra"-modell som org-feeden, men her er + synlighetssjekken allerede bygget og trenger ingen egen + medlemskaps-/deltakelses-sjekk i tillegg. + +Sanntid: INGEN egen WebSocket-kanal. Gjenbruker `broadcast_round_update` +(app/realtime.py), som allerede kringkastes ved hver hull-endring og +allerede konsumeres av `/ws/rounds/{id}/live` / `/ws/public/rounds/{id}/ +live` -- klienten reagerer med å hente meldingslisten på nytt, samme +"noe endret seg"-mønster som resten av runde-sanntiden. +""" + +from datetime import datetime + +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile +from pydantic import BaseModel + +from .. import storage +from ..auth import CurrentUser, get_current_user, get_current_user_optional +from ..db import plain_connection +from ..errors import app_error, translate_db_errors +from ..realtime import broadcast_round_update +from .rounds import _get_viewable_round_or_404 + +router = APIRouter(tags=["round-messages"]) + +_ROUND_MESSAGE_COLUMNS = """ + id::text, round_id::text, author_user_id::text, author_display_name, body, image_key, created_at +""" + + +class RoundMessageOut(BaseModel): + id: str + round_id: str + author_user_id: str + author_display_name: str + body: str | None + image_url: str | None + created_at: str + + +def _row_to_round_message(row) -> RoundMessageOut: + return RoundMessageOut( + id=row["id"], + round_id=row["round_id"], + author_user_id=row["author_user_id"], + author_display_name=row["author_display_name"], + body=row["body"], + image_url=storage.public_url(row["image_key"]) if row["image_key"] else None, + created_at=row["created_at"].isoformat(), + ) + + +async def _resolve_round_message_author_name(conn, user_id: str) -> str: + """Frosset ved skrivetidspunkt, samme prinsipp som + `message.author_display_name` -- her FULLT navn (forfatter-liste, ikke + direkte adressering, CLAUDE.md navneformat-regel). Ingen org-scopet + `player.display_name` å falle tilbake på her (runder er ikke + org-scopet) -- `app_user.display_name` er NOT NULL og alltid til + stede, så ingen ekstra fallback trengs.""" + row = await conn.fetchrow( + "SELECT first_name, last_name, display_name FROM app_user WHERE id = $1", user_id + ) + if row["first_name"] and row["last_name"]: + return f"{row['first_name']} {row['last_name']}" + return row["display_name"] + + +# --------------------------------------------------------------------------- +# Runde-nivå kommentarer/bilder +# --------------------------------------------------------------------------- + + +@router.get("/rounds/{round_id}/messages", response_model=list[RoundMessageOut]) +async def list_round_messages( + round_id: str, + user: CurrentUser | None = Depends(get_current_user_optional), +) -> list[RoundMessageOut]: + async with plain_connection() as conn: + await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None) + rows = await conn.fetch( + f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at", + round_id, + ) + return [_row_to_round_message(r) for r in rows] + + +@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201) +async def post_round_message( + round_id: str, + body: str | None = Form(default=None), + image: UploadFile | None = File(default=None), + user: CurrentUser = Depends(get_current_user), +) -> RoundMessageOut: + if not body and image is None: + raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.") + raw = await storage.read_optional_image(image) + + async with plain_connection() as conn, translate_db_errors(): + # Selve synlighetssjekken ER post-sjekken -- "kan se = kan bidra", + # ingen snevrere gate i tillegg (ADR-044 Beslutning A). + await _get_viewable_round_or_404(conn, round_id, user.user_id) + + image_key = None + if raw is not None: + try: + image_key = await storage.upload_image("round_messages", round_id, raw) + except storage.InvalidImageError: + raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") + + display_name = await _resolve_round_message_author_name(conn, user.user_id) + + row = await conn.fetchrow( + f""" + INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key) + VALUES ($1, $2, $3, $4, $5) + RETURNING {_ROUND_MESSAGE_COLUMNS} + """, + round_id, + user.user_id, + display_name, + body, + image_key, + ) + message = _row_to_round_message(row) + await broadcast_round_update(round_id) + return message + + +@router.delete("/rounds/{round_id}/messages/{message_id}", status_code=204) +async def delete_round_message( + round_id: str, + message_id: str, + user: CurrentUser = Depends(get_current_user), +) -> None: + """Forfatteren selv, ELLER rundens eier (moderering -- rundens analog + til org-admin), samme modell som org-feedens `delete_feed_message`.""" + async with plain_connection() as conn: + row = await conn.fetchrow( + """ + SELECT rm.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id + FROM round_message rm JOIN round r ON r.id = rm.round_id + WHERE rm.id = $1 AND rm.round_id = $2 + """, + message_id, + round_id, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.") + if user.user_id not in (row["author_user_id"], row["owner_user_id"]): + raise app_error( + 403, + "NOT_MESSAGE_AUTHOR", + "Du kan kun slette dine egne innlegg (eller som rundeeier).", + ) + await conn.execute("DELETE FROM round_message WHERE id = $1", message_id) + await broadcast_round_update(round_id) + + +# --------------------------------------------------------------------------- +# Samlet feed -- egne runder + venners synlige runder (ADR-036 fase 2) +# --------------------------------------------------------------------------- + + +class FeedEntryOut(BaseModel): + id: str + round_id: str + round_owner_user_id: str + round_owner_display_name: str + course_name: str + played_at: str + author_user_id: str + author_display_name: str + body: str | None + image_url: str | None + created_at: str + + +@router.get("/feed", response_model=list[FeedEntryOut]) +async def get_feed( + before: datetime | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=50), + user: CurrentUser = Depends(get_current_user), +) -> list[FeedEntryOut]: + # Reverst av `_can_view_round` (starter fra VIEWEREN, ikke fra + # rundeeieren) -- samme retning og samme 'friends'-predikat som + # `list_friends_on_course` (rounds.py), kopiert inn her i stedet for + # faktorisert ut, samme bevisste "grei duplisering på denne skalaen" + # begrunnelse som der (se rounds.py sin kommentar over + # `list_friends_on_course`). Justeringer mot den funksjonen: egne + # runder er ALLTID med (uansett visibility_mode), ingen "spiller + # nå/siste 24t"-filter -- dette er en all-time, paginert liste av + # round_message-rader, ikke en "hvem er live nå"-widget. + async with plain_connection() as conn: + rows = await conn.fetch( + """ + SELECT rm.id::text AS id, rm.round_id::text AS round_id, + r.owner_user_id::text AS round_owner_user_id, + COALESCE(owner.first_name || ' ' || owner.last_name, owner.display_name) AS round_owner_display_name, + r.course_name_snapshot AS course_name, r.played_at, + rm.author_user_id::text AS author_user_id, rm.author_display_name, + rm.body, rm.image_key, rm.created_at + FROM round_message rm + JOIN round r ON r.id = rm.round_id + JOIN app_user owner ON owner.id = r.owner_user_id + WHERE ( + r.owner_user_id = $1 + OR EXISTS ( + SELECT 1 FROM round_participant rp + WHERE rp.round_id = r.id AND rp.user_id = $1 + ) + OR r.visibility_mode = 'public' + OR ( + r.visibility_mode = 'friends' + AND EXISTS ( + SELECT 1 FROM friendship f + WHERE f.status = 'accepted' + AND ((f.requester_user_id = $1 AND f.addressee_user_id = r.owner_user_id) + OR (f.requester_user_id = r.owner_user_id AND f.addressee_user_id = $1)) + ) + AND EXISTS ( + SELECT 1 FROM friend_categorization fc + WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1 + ) + AND NOT EXISTS ( + SELECT 1 FROM friend_categorization fc + WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1 + AND fc.category NOT IN ( + SELECT category FROM round_visible_category WHERE round_id = r.id + ) + ) + ) + ) + AND ($2::timestamptz IS NULL OR rm.created_at < $2::timestamptz) + ORDER BY rm.created_at DESC + LIMIT $3 + """, + user.user_id, + before, + limit, + ) + return [ + FeedEntryOut( + id=r["id"], + round_id=r["round_id"], + round_owner_user_id=r["round_owner_user_id"], + round_owner_display_name=r["round_owner_display_name"], + course_name=r["course_name"], + played_at=r["played_at"].isoformat(), + author_user_id=r["author_user_id"], + author_display_name=r["author_display_name"], + body=r["body"], + image_url=storage.public_url(r["image_key"]) if r["image_key"] else None, + created_at=r["created_at"].isoformat(), + ) + for r in rows + ] diff --git a/app/storage.py b/app/storage.py index 65777be..a6e691e 100644 --- a/app/storage.py +++ b/app/storage.py @@ -20,10 +20,12 @@ import io import time import pillow_avif # noqa: F401 -- registrerer AVIF-plugin i Pillow ved import +from fastapi import UploadFile from minio import Minio from PIL import Image, UnidentifiedImageError from .config import settings +from .errors import app_error ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB rå input, før konvertering @@ -56,6 +58,21 @@ class InvalidImageError(Exception): pass +async def read_optional_image(image: UploadFile | None) -> bytes | None: + """Delt valideringssteg for et valgfritt multipart-bildefelt (brukt av + både org-feeden/lag-chatten, messaging.py, og round_messages.py) -- + content-type-allowlist + størrelsesgrense, ingen faktisk dekoding her + (det gjør _convert_to_avif_sync, kalt separat av upload_image).""" + if image is None: + return None + if image.content_type not in ALLOWED_INPUT_CONTENT_TYPES: + raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.") + raw = await image.read(MAX_UPLOAD_BYTES + 1) + if len(raw) > MAX_UPLOAD_BYTES: + raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).") + return raw + + def _ensure_bucket_sync() -> None: if not _client.bucket_exists(BUCKET): _client.make_bucket(BUCKET) diff --git a/frontend/app/my-feed/page.tsx b/frontend/app/my-feed/page.tsx new file mode 100644 index 0000000..8f2a0d5 --- /dev/null +++ b/frontend/app/my-feed/page.tsx @@ -0,0 +1,5 @@ +import { Feed } from "@/components/feed" + +export default function FeedPage() { + return +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 5f418ee..d815ab5 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -977,7 +977,7 @@ function friendOnCourseInitials(name: string): string { function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) { return (
- + } /> {friends.length > 0 ? (
{friends.map((friend, i) => { diff --git a/frontend/components/feed.tsx b/frontend/components/feed.tsx new file mode 100644 index 0000000..d467f3c --- /dev/null +++ b/frontend/components/feed.tsx @@ -0,0 +1,232 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import Link from "next/link" + +import { cn } from "@/lib/utils" + +/** Rå feed-oppføring fra backend. */ +export type ApiFeedEntry = { + id: string + round_id: string + round_owner_user_id: string + round_owner_display_name: string + course_name: string + played_at: string // dato, "YYYY-MM-DD" + author_user_id: string + author_display_name: string + body: string | null + image_url: string | null + created_at: string // ISO 8601 +} + +const PAGE_SIZE = 20 +const MAX_IMAGE_HEIGHT = 300 + +/** + * Formaterer "YYYY-MM-DD" til kort norsk dato, f.eks. "3. aug". + * Parses som lokal dato (ikke UTC) for å unngå at datoen hopper en dag. + */ +function formatPlayedAt(ymd: string): string { + const [y, m, d] = ymd.split("-").map(Number) + if (!y || !m || !d) return ymd + const date = new Date(y, m - 1, d) + return date.toLocaleDateString("nb-NO", { day: "numeric", month: "short" }) +} + +export function Feed() { + const [currentUserId, setCurrentUserId] = useState(null) + const [entries, setEntries] = useState([]) + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading") + const [loadingMore, setLoadingMore] = useState(false) + const [moreError, setMoreError] = useState(null) + // Om det finnes flere sider å hente (forrige respons var full side). + const [hasMore, setHasMore] = useState(false) + + // Innlogget bruker (samme mønster som round-detail.tsx) -- kun brukt for + // å avgjøre "{navn} sin runde"-linjen per kort, ikke for selve + // autorisasjonen (den håndheves av /feed-endepunktet via sesjonscookien). + useEffect(() => { + let cancelled = false + fetch("/auth/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { id: string } | null) => { + if (!cancelled && data) setCurrentUserId(data.id) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + // Første innlasting ved mount. + useEffect(() => { + const controller = new AbortController() + + async function loadInitial() { + setStatus("loading") + try { + const res = await fetch(`/feed?limit=${PAGE_SIZE}`, { + credentials: "include", + signal: controller.signal, + }) + if (!res.ok) throw new Error(`Uventet svar (${res.status})`) + const data: ApiFeedEntry[] = await res.json() + setEntries(data) + setHasMore(data.length >= PAGE_SIZE) + setStatus("ready") + } catch (err) { + if (controller.signal.aborted) return + console.log("[v0] Feil ved lasting av feed:", err) + setStatus("error") + } + } + + loadInitial() + return () => controller.abort() + }, []) + + const loadMore = useCallback(async () => { + if (loadingMore || entries.length === 0) return + const oldest = entries[entries.length - 1]?.created_at + if (!oldest) return + + setLoadingMore(true) + setMoreError(null) + try { + const res = await fetch( + `/feed?limit=${PAGE_SIZE}&before=${encodeURIComponent(oldest)}`, + { credentials: "include" }, + ) + if (!res.ok) throw new Error(`Uventet svar (${res.status})`) + const data: ApiFeedEntry[] = await res.json() + setEntries((prev) => [...prev, ...data]) + setHasMore(data.length >= PAGE_SIZE) + } catch (err) { + console.log("[v0] Feil ved lasting av flere innlegg:", err) + setMoreError("Kunne ikke laste flere innlegg. Prøv igjen.") + } finally { + setLoadingMore(false) + } + }, [entries, loadingMore]) + + return ( +
+ {/* Sticky sideoverskrift */} +
+
+

Feed

+
+
+ +
+ {status === "loading" ? ( +
+
+ ) : status === "error" ? ( +

+ Kunne ikke laste feeden. Sjekk tilkoblingen og prøv igjen. +

+ ) : entries.length === 0 ? ( +
+

Ingen aktivitet ennå

+

+ Kommentarer og bilder fra dine runder og venners runder dukker opp her. +

+
+ ) : ( + <> +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+ + {hasMore && ( +
+ +
+ )} + + {moreError && ( +

+ {moreError} +

+ )} + + )} +
+
+ ) +} + +function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId: string | null }) { + const isOthersRound = entry.round_owner_user_id !== currentUserId + + return ( + + {/* Kontekstlinje */} +

+ {entry.author_display_name} + {" på "} + {entry.course_name} + {" · "} + {formatPlayedAt(entry.played_at)} +

+ + {/* Eierkontekst kun for andres runder */} + {isOthersRound && ( +

+ {entry.round_owner_display_name} sin runde +

+ )} + + {/* Bilde */} + {entry.image_url && ( + + )} + + {/* Tekst */} + {entry.body && ( +

+ {entry.body} +

+ )} + + ) +} + +export default Feed diff --git a/frontend/components/more-menu.tsx b/frontend/components/more-menu.tsx index d201864..7faecdf 100644 --- a/frontend/components/more-menu.tsx +++ b/frontend/components/more-menu.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { Bell, Building2, ChevronRight, LogOut, UserCircle, UserPlus } from "lucide-react" +import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react" import { BottomTabBar } from "@/components/dashboard" const C = { @@ -64,6 +64,7 @@ export function MoreMenu() { > + {me?.organizations.map((o, i) => ( ("own-search") + // Mal-modus (2026-08-05) -- true KUN når offisiell-/egen-bane-søket er + // nådd via "Basér på en eksisterende bane i stedet" (fra det blanke + // opprett-ny-bane-skjemaet). Styrer om "Bruk som mal for egen bane" + // vises i BÅDE CourseList (offisielle baner) og OwnSearch (egne baner) + // -- samme skjermer som brukes til vanlig banevalg, men knappen skal + // KUN vises når brukeren faktisk har sagt at målet er å opprette en ny + // bane, ikke når målet bare er å velge en bane å spille. + const [templateMode, setTemplateMode] = useState(false) const [courseSource, setCourseSource] = useState<"official" | "own">("official") const [selectedClub, setSelectedClub] = useState(null) const [course, setCourse] = useState(null) @@ -490,14 +498,14 @@ export function NewRound() { if (stepN === 1) { if (s1Sub === "source") { router.push("/my-rounds") + } else if (s1Sub === "template-source") { + setS1Sub("own-create") } else if (s1Sub === "official-search") { - setS1Sub("source") + setS1Sub(templateMode ? "template-source" : "source") } else if (s1Sub === "official-courses") { setS1Sub("official-search") } else if (s1Sub === "own-search") { - setS1Sub("source") - } else if (s1Sub === "own-search-template") { - setS1Sub("own-create") + setS1Sub(templateMode ? "template-source" : "source") } else if (s1Sub === "own-create") { setS1Sub(ownCreateOrigin) } else if (s1Sub === "fields") { @@ -778,6 +786,8 @@ export function NewRound() { s1Sub={s1Sub} setS1Sub={setS1Sub} setOwnCreateOrigin={setOwnCreateOrigin} + templateMode={templateMode} + setTemplateMode={setTemplateMode} selectedClub={selectedClub} setSelectedClub={setSelectedClub} setCourseSource={setCourseSource} @@ -1091,6 +1101,8 @@ function Step1(props: { s1Sub: S1Sub setS1Sub: (s: S1Sub) => void setOwnCreateOrigin: (s: S1Sub) => void + templateMode: boolean + setTemplateMode: (v: boolean) => void selectedClub: OfficialClub | null setSelectedClub: (c: OfficialClub | null) => void setCourseSource: (s: "official" | "own") => void @@ -1108,7 +1120,7 @@ function Step1(props: { startHole: string setStartHole: (v: string) => void }) { - const { s1Sub, setS1Sub, setOwnCreateOrigin, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props + const { s1Sub, setS1Sub, setOwnCreateOrigin, templateMode, setTemplateMode, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props const [resolvingClub, setResolvingClub] = useState(false) // "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create, // satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket. @@ -1124,6 +1136,7 @@ function Step1(props: { title="Offisiell bane" description="Søk opp banen i det offisielle baneregisteret." onClick={() => { + setTemplateMode(false) setCourseSource("official") setS1Sub("official-search") }} @@ -1133,6 +1146,43 @@ function Step1(props: { title="Egen bane" description="Søk opp eller opprett en personlig bane." onClick={() => { + setTemplateMode(false) + setCourseSource("own") + setS1Sub("own-search") + }} + /> +
+ + ) + } + + // Mal-kilde-velger (2026-08-05) -- nådd KUN fra "Basér på en eksisterende + // bane i stedet" i det blanke opprett-ny-bane-skjemaet. Samme to kilder + // som det aller første valget, men her setter vi templateMode(true) FØR + // vi går videre inn i official-search/own-search, slik at "Bruk som mal + // for egen bane" faktisk vises i de skjermene -- den vanlige "source"- + // skjermen over lar `templateMode` stå urørt (alltid false derfra). + if (s1Sub === "template-source") { + return ( +
+ +
+