diff --git a/076_public_order_of_merit.sql b/076_public_order_of_merit.sql new file mode 100644 index 0000000..ede1aa2 --- /dev/null +++ b/076_public_order_of_merit.sql @@ -0,0 +1,30 @@ +-- ===================================================================== +-- TeeCup — migrasjon 076 +-- Offentlig/delt Order of Merit-visning (ADR-076, "Steg 3" av OOM sin +-- gjenstår-liste fra ADR-043/074/075) +-- ===================================================================== +-- Kun én ny bro-funksjon -- order_of_merit.public_visible finnes +-- allerede fra migrasjon 055, aldri brukt til noe før nå. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- --------------------------------------------------------------------- +-- public_order_of_merit_by_id() -- samme mønster som public_org_by_slug +-- (009)/public_tournament_by_code (011): SECURITY DEFINER-bro, eneste +-- måte et /public/...-endepunkt får vite riktig org-kontekst FØR RLS +-- kan håndheve noe. Returnerer NULL for BÅDE "finnes ikke" og "finnes, +-- men er ikke offentlig" -- samme anti-enumerering som resten. +-- --------------------------------------------------------------------- + +CREATE FUNCTION public_order_of_merit_by_id(p_id uuid) +RETURNS uuid +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +STABLE +AS $$ + SELECT organization_id FROM order_of_merit WHERE id = p_id AND public_visible = true; +$$; + +GRANT EXECUTE ON FUNCTION public_order_of_merit_by_id(uuid) TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 5c7aa8e..57fc02e 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -7505,6 +7505,106 @@ bruker. Begge containere startet rent. --- +## ADR-076: Order of Merit -- offentlig/delt visning ("Steg 3" av gjenstår-listen, sluttfører ADR-043) (2026-08-15) + +Foranledning: siste av de tre gjenstående OOM-punktene fra ADR-043/074/ +075, se ADR-074 sin innledning for hele bakgrunnen/bekreftelsen. +`order_of_merit.public_visible` fantes som redigerbart felt helt fra +migrasjon 055 (2026-08-04), men ingen uautentisert side leste det -- +feltet stod ubrukt i praksis frem til nå. + +**Migrasjon 076** (ny SECURITY DEFINER-bro, fjerde i rekken etter +`public_org_by_slug` 009/`public_tournament_by_code` 011/`link_player_ +by_email` 008): `public_order_of_merit_by_id(p_id uuid) RETURNS uuid` +(organization_id). Samme NULL-for-begge-tilfeller-mønster som de tre +andre broene -- returnerer NULL for BÅDE "finnes ikke" OG "finnes, men +`public_visible=false`", slik at et 404-svar aldri røper at en gitt +id faktisk eksisterer (anti-enumerering). `GRANT EXECUTE ... TO +teecup_app`. + +**Backend**: ny `public_router` (prefix `/public/order-of-merits`) i +`order_of_merit.py` selv -- IKKE lagt til `registration.py` (som +rommer resten av `/public/...`-endepunktene) fordi den gjenbruker +`order_of_merit_leaderboard()` direkte i stedet for å duplisere hele +beregningen for en uautentisert variant. Samme triks som testsuiten +allerede brukte: kaller router-funksjonen med `organization_id` som et +vanlig Python-argument, utenom FastAPI sin `Depends`-injeksjon (gyldig +fordi `get_authorized_org` kun er en `Depends`-default, ikke en +signatur-begrensning). `GET /public/order-of-merits/{oom_id}` slår opp +org via broen, henter OOM-metadata (`name`/`kind`/`result_type`/ +`aggregation_mode`) fra `org_connection`, kaller leaderboard-funksjonen, +returnerer alt samlet. Registrert i `main.py` som +`order_of_merit.public_router`. + +**Frontend**: ny `app/order-of-merit/[id]/page.tsx` (server component, +`generateMetadata` mot den nye public-ruten -- OG-tags/tittel satt til +OOM-ens faktiske navn) + `components/public-order-of-merit.tsx` +(`"use client"`, modellert direkte på `public-club.tsx` sitt hente-/ +laste-/feil-tilstand-mønster -- samme banner-stil, samme loading- +spinner/feil-tilstand). `LeaderboardTable` inni denne komponenten er en +BEVISST DUPLIKAT av `order-of-merit-detail.tsx` sin (gull-fremhevet +førsteplass, "ikke kvalifisert"-seksjon) -- i tråd med hvordan list/ +detail-komponentene i dette området allerede holder egne lokale kopier +av samme visuelle mønster, ikke en delt komponent. + +"Del offentlig lenke"-knapp lagt til i `order-of-merit-detail.tsx` sine +Innstillinger, rett under "Offentlig synlig"-bryteren -- samme kopier- +til-utklippstavle-mønster som `JoinCodeChip` i `individual-tournament- +detail.tsx`/`tournament-detail.tsx` (klikk kopierer, viser "Lenke +kopiert" i 1,5 sekund). Gated på `oom.public_visible` (den FAKTISK +LAGREDE verdien, ikke bryterens lokale, kanskje-ulagrede tilstand) -- +viser i stedet en hjelpetekst ("Lenken blir aktiv når du lagrer.") hvis +brukeren har slått på bryteren men ikke lagret ennå, slik at knappen +aldri tilbyr en lenke som faktisk ville gitt 404. + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (83/83 -- migrasjon 076 kjørt som del +av den vanlige scratch-migreringsløypen, 3 nye tester: 404 for +ikke-eksisterende id, 404 for en PRIVAT OOM -- eksplisitt bevist samme +statuskode som ikke-eksisterende, altså reell anti-enumerering, ikke +bare en påstand -- og 200 med korrekt navn/kind/result_type/leaderboard +for en offentlig OOM). `tsc --noEmit` rent + 45/45 vitest. + +Egen scratch-database (migrasjonene kjørt fra bunnen av, inkl. den nye +076) + scratch `teecup_api` (port 18103, live-montert kode) + lokal +`next dev` (port 13103): seedet en offentlig OOM og en privat OOM i +samme org. Offentlig side nådd i en EGEN, isolert nettleser-kontekst +UTEN innlogget økt/session-cookie (reelt inkognito-scenario, ikke bare +"ingen linke til innlogging i UI-et") -- viste navn, type/resultattype, +og korrekt resultatliste (72/90 for to spillere, håndregnet). +`generateMetadata` bekreftet virket server-side (fanetittel "Offentlig +sesong-OOM | TeeCup" satt FØR klient-JS kjørte). Privat OOM i samme +inkognito-kontekst ga identisk "Order of Merit finnes ikke"-feilside som +en helt oppdiktet id -- ingen synlig forskjell. "Del offentlig lenke" +verifisert i den autentiserte innstillinger-seksjonen: knapp med riktig +url-konstruksjon bekreftet (`window.location.origin + +/order-of-merit/{id}`, samme url faktisk besøkt og fungerte i +inkognito-konteksten over). Slo av "Offentlig synlig" og lagret -- +knappen forsvant fra UI-et OG endepunktet ga 404 umiddelbart etterpå, +bekreftet direkte mot API-et. Lys+mørk bekreftet. Scratch-stacken +fullstendig revet ned (database, rolle, container, MinIO-bøtte, begge +`next dev`-instanser) -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` +urørt. + +**Migrasjon 076 vist og bekreftet av bruker FØR kjøring mot ekte +`teecup_db`** (kun additiv -- én ny funksjon, ingen skjemaendring, +ingen datamigrering). + +**Rullet ut 2026-08-15** -- migrasjon 076 kjørt mot ekte `teecup_db` +etter eksplisitt bekreftelse fra bruker (`CREATE FUNCTION`/`GRANT`, +verifisert med `\df` + et direkte NULL-for-ukjent-id-kall FØR kode- +utrullingen), deretter `docker compose build teecup_api teecup_frontend +&& up -d`. Begge containere startet rent. Etter utrulling: `GET +/public/order-of-merits/` bekreftet 404 og `GET +/order-of-merit/` bekreftet 200 (feil-tilstanden i selve +siden) direkte mot `teecup.golf`. + +Med dette er alle tre punktene fra ADR-043 sin opprinnelige +"gjenstår"-liste fullført: lag-OOM (ADR-074), eclectic på tvers av +turneringer (ADR-075), offentlig visning (dette dokumentet). + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2526b33..6ad6e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11619,3 +11619,47 @@ Neste steg: **Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker. Begge containere startet rent. + +92. **Order of Merit: offentlig/delt visning (ADR-076) — 2026-08-15.** + Siste av de tre gjenstående OOM-punktene fra ADR-043/074/075. + `order_of_merit.public_visible` fantes fra migrasjon 055, aldri brukt + til noe før nå. + + Migrasjon 076: ny SECURITY DEFINER-bro `public_order_of_merit_by_id` + (fjerde i rekken etter public_org_by_slug/public_tournament_by_code/ + link_player_by_email), samme NULL-for-begge-tilfeller-anti- + enumerering. Backend: ny `/public/order-of-merits/{id}`-router i + `order_of_merit.py` selv, gjenbruker `order_of_merit_leaderboard()` + direkte i stedet for å duplisere beregningen. Frontend: ny + `app/order-of-merit/[id]/page.tsx` + `components/public-order-of- + merit.tsx`, modellert på `public-club.tsx`. "Del offentlig lenke"- + knapp lagt til i innstillinger (samme kopier-lenke-mønster som + `JoinCodeChip`), gated på den faktisk lagrede `public_visible`- + verdien. + + **Verifisert:** full `./scripts/run_backend_tests.sh` (83/83 -- 3 nye + tester: 404 for ikke-eksisterende id, 404 for privat OOM -- bevist + IDENTISK statuskode, reell anti-enumerering -- og 200 med korrekt + resultatliste for offentlig OOM). `tsc --noEmit` rent + 45/45 vitest. + Scratch-database (migrert fra bunnen, inkl. 076) + scratch + `teecup_api` (port 18103) + lokal `next dev` (port 13103): offentlig + side nådd i en EGEN isolert nettleser-kontekst UTEN session-cookie + (reelt inkognito-scenario) -- viste korrekt navn/resultatliste, + `generateMetadata` satte riktig fanetittel server-side. Privat OOM ga + identisk feilside som en oppdiktet id. Av-toggling av "Offentlig + synlig" fjernet lenke-knappen i UI-et OG ga umiddelbar 404 fra API-et + (bekreftet direkte). Lys+mørk bekreftet. Scratch-stacken fullstendig + revet ned -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt. + + **Migrasjon 076 vist og bekreftet av bruker FØR kjøring mot ekte + teecup_db** (additiv -- én ny funksjon, ingen skjemaendring). + + **Rullet ut 2026-08-15** -- migrasjon 076 kjørt mot ekte `teecup_db` + etter eksplisitt bekreftelse (verifisert med `\df` + et direkte NULL- + kall før kode-utrullingen), deretter `docker compose build teecup_api + teecup_frontend && up -d`. Begge containere startet rent. Bekreftet + direkte mot `teecup.golf` etterpå: `/public/order-of-merits/` + ga 404, `/order-of-merit/` ga 200 (feil-tilstanden i siden). + + Med dette er alle tre punktene fra ADR-043 sin opprinnelige + "gjenstår"-liste fullført. diff --git a/app/main.py b/app/main.py index 0f41a09..61e297e 100644 --- a/app/main.py +++ b/app/main.py @@ -54,6 +54,7 @@ app.include_router(courses.router) app.include_router(tournaments.router) app.include_router(individual_tournaments.router) app.include_router(order_of_merit.router) +app.include_router(order_of_merit.public_router) app.include_router(matches.router) app.include_router(scoring.router) app.include_router(registration.router) diff --git a/app/routers/order_of_merit.py b/app/routers/order_of_merit.py index 59a3eee..9fde729 100644 --- a/app/routers/order_of_merit.py +++ b/app/routers/order_of_merit.py @@ -33,7 +33,7 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from ..auth import get_authorized_org -from ..db import org_connection +from ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors from handicap_engine import ( allocate_strokes_by_index, @@ -979,3 +979,50 @@ def _contribution_for_entry( if result_type == "net": return float(entry.net_total) if entry.net_total is not None else 0.0 return 0.0 + + +# --------------------------------------------------------------------------- +# Offentlig/delt visning (ADR-076, migrasjon 076) -- uautentisert, samme +# SECURITY DEFINER-bro-mønster som public_org_by_slug/public_tournament_ +# by_code (registration.py): NULL for BÅDE "finnes ikke" og "finnes, men +# er ikke offentlig" -- anti-enumerering. Egen APIRouter i DENNE filen +# (ikke registration.py) fordi den gjenbruker order_of_merit_leaderboard +# direkte -- unngår å duplisere hele beregningen for en uautentisert +# variant, samme triks som testene i test_order_of_merit.py (kaller +# router-funksjonen med organization_id som et vanlig argument, utenom +# FastAPI sin Depends-injeksjon). +# --------------------------------------------------------------------------- + +public_router = APIRouter(prefix="/public/order-of-merits", tags=["public-order-of-merit"]) + + +class PublicOrderOfMeritOut(BaseModel): + name: str + kind: str + result_type: str + aggregation_mode: str + entries: list[OrderOfMeritLeaderboardEntry] + + +@public_router.get("/{oom_id}", response_model=PublicOrderOfMeritOut) +async def get_public_order_of_merit(oom_id: str) -> PublicOrderOfMeritOut: + async with plain_connection() as conn: + organization_id = await conn.fetchval("SELECT public_order_of_merit_by_id($1)", oom_id) + if organization_id is None: + raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") + organization_id = str(organization_id) + + async with org_connection(organization_id) as conn: + oom = await conn.fetchrow( + "SELECT name, kind, result_type, aggregation_mode FROM order_of_merit WHERE id = $1", + oom_id, + ) + + entries = await order_of_merit_leaderboard(oom_id, organization_id=organization_id) + return PublicOrderOfMeritOut( + name=oom["name"], + kind=oom["kind"], + result_type=oom["result_type"], + aggregation_mode=oom["aggregation_mode"], + entries=entries, + ) diff --git a/frontend/app/order-of-merit/[id]/page.tsx b/frontend/app/order-of-merit/[id]/page.tsx new file mode 100644 index 0000000..b16d78f --- /dev/null +++ b/frontend/app/order-of-merit/[id]/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next" +import { PublicOrderOfMerit } from "@/components/public-order-of-merit" + +// Server-side -- går IKKE gjennom next.config.mjs sin rewrites() (kun for +// nettleser-trafikk inn til Next.js-serveren). Peker direkte på API-et. +const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000" + +type PublicOomInfo = { + name: string +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const { id } = await params + try { + const res = await fetch(`${API_ORIGIN}/public/order-of-merits/${id}`, { cache: "no-store" }) + if (!res.ok) { + return { title: "Order of Merit | TeeCup" } + } + const data: PublicOomInfo = await res.json() + const title = `${data.name} | TeeCup` + const description = `Se resultatlisten for ${data.name} på TeeCup.` + return { + title, + description, + openGraph: { title, description, siteName: "TeeCup", type: "website" }, + twitter: { card: "summary", title, description }, + } + } catch { + return { title: "TeeCup" } + } +} + +export default async function PublicOrderOfMeritPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + return +} diff --git a/frontend/components/order-of-merit-detail.tsx b/frontend/components/order-of-merit-detail.tsx index f3eaa85..d5e36bc 100644 --- a/frontend/components/order-of-merit-detail.tsx +++ b/frontend/components/order-of-merit-detail.tsx @@ -9,7 +9,7 @@ import type React from "react" import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ChevronDown, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react" +import { ArrowLeft, Check, ChevronDown, Link2, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -736,6 +736,12 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch + {oom.public_visible ? ( + + ) : publicVisible ? ( +

Lenken blir aktiv når du lagrer.

+ ) : null} + @@ -745,6 +751,34 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch ) } +function PublicLinkChip({ oomId }: { oomId: string }) { + const [copied, setCopied] = useState(false) + async function handleCopy() { + try { + const url = `${window.location.origin}/order-of-merit/${oomId}` + await navigator.clipboard.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // stille -- ikke kritisk + } + } + return ( + + ) +} + function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) { return (