Order of Merit: offentlig/delt visning (ADR-076)
Siste av de tre gjenstående OOM-punktene fra ADR-043 (lag-OOM ADR-074,
eclectic på tvers av turneringer ADR-075, offentlig visning her).
Migrasjon 076: SECURITY DEFINER-bro public_order_of_merit_by_id, samme
anti-enumerering som public_org_by_slug/public_tournament_by_code. Ny
/public/order-of-merits/{id}-endepunkt (gjenbruker den eksisterende
leaderboard-beregningen direkte). Ny uautentisert frontend-side
modellert på public-club.tsx, "Del offentlig lenke" i innstillinger.
83/83 backend-tester, 45/45 vitest, scratch-verifisert i egen inkognito-
nettleserkontekst (ingen session-cookie). Migrasjon vist og bekreftet
før kjøring mot ekte teecup_db. Rullet ut.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fe13a82009
commit
f740814833
9 changed files with 578 additions and 2 deletions
30
076_public_order_of_merit.sql
Normal file
30
076_public_order_of_merit.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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/<ukjent-id>` bekreftet 404 og `GET
|
||||||
|
/order-of-merit/<ukjent-id>` 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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -11619,3 +11619,47 @@ Neste steg:
|
||||||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||||||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||||||
bruker. Begge containere startet rent.
|
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/<ukjent>`
|
||||||
|
ga 404, `/order-of-merit/<ukjent>` ga 200 (feil-tilstanden i siden).
|
||||||
|
|
||||||
|
Med dette er alle tre punktene fra ADR-043 sin opprinnelige
|
||||||
|
"gjenstår"-liste fullført.
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ app.include_router(courses.router)
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
app.include_router(individual_tournaments.router)
|
app.include_router(individual_tournaments.router)
|
||||||
app.include_router(order_of_merit.router)
|
app.include_router(order_of_merit.router)
|
||||||
|
app.include_router(order_of_merit.public_router)
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
app.include_router(scoring.router)
|
app.include_router(scoring.router)
|
||||||
app.include_router(registration.router)
|
app.include_router(registration.router)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import get_authorized_org
|
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 ..errors import app_error, translate_db_errors
|
||||||
from handicap_engine import (
|
from handicap_engine import (
|
||||||
allocate_strokes_by_index,
|
allocate_strokes_by_index,
|
||||||
|
|
@ -979,3 +979,50 @@ def _contribution_for_entry(
|
||||||
if result_type == "net":
|
if result_type == "net":
|
||||||
return float(entry.net_total) if entry.net_total is not None else 0.0
|
return float(entry.net_total) if entry.net_total is not None else 0.0
|
||||||
return 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,
|
||||||
|
)
|
||||||
|
|
|
||||||
44
frontend/app/order-of-merit/[id]/page.tsx
Normal file
44
frontend/app/order-of-merit/[id]/page.tsx
Normal file
|
|
@ -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<Metadata> {
|
||||||
|
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 <PublicOrderOfMerit oomId={id} />
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -736,6 +736,12 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
|
||||||
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
|
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{oom.public_visible ? (
|
||||||
|
<PublicLinkChip oomId={oom.id} />
|
||||||
|
) : publicVisible ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Lenken blir aktiv når du lagrer.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
|
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
|
||||||
{saving ? "Lagrer…" : "Lagre"}
|
{saving ? "Lagrer…" : "Lagre"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2.5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check aria-hidden="true" className="size-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Link2 aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{copied ? "Lenke kopiert" : "Del offentlig lenke"}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
|
function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
220
frontend/components/public-order-of-merit.tsx
Normal file
220
frontend/components/public-order-of-merit.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Medal, ShieldAlert, Users } from "lucide-react"
|
||||||
|
import { Wordmark } from "@/components/wordmark"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// --- API-typer (matcher app/routers/order_of_merit.py sin
|
||||||
|
// get_public_order_of_merit/PublicOrderOfMeritOut) ---------------------
|
||||||
|
|
||||||
|
type ApiKind = "player" | "team"
|
||||||
|
type ApiResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||||
|
|
||||||
|
type ApiLeaderboardEntry = {
|
||||||
|
player_id: string
|
||||||
|
player_name: string
|
||||||
|
results_counted: number
|
||||||
|
results_available: number
|
||||||
|
value: number | null
|
||||||
|
position: string | null
|
||||||
|
eligible: boolean
|
||||||
|
ineligible_reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiPublicOom = {
|
||||||
|
name: string
|
||||||
|
kind: ApiKind
|
||||||
|
result_type: ApiResultType
|
||||||
|
aggregation_mode: "sum" | "average" | "eclectic"
|
||||||
|
entries: ApiLeaderboardEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESULT_TYPE_LABELS: Record<ApiResultType, string> = {
|
||||||
|
points: "Poeng etter plassering",
|
||||||
|
stableford: "Stableford-sum",
|
||||||
|
gross: "Bruttoscore-sum",
|
||||||
|
net: "Nettoscore-sum",
|
||||||
|
money: "Pengeliste",
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number | null, resultType: ApiResultType): string {
|
||||||
|
if (value === null) return "–"
|
||||||
|
if (resultType === "money") return `${Math.round(value).toLocaleString("nb-NO")} kr`
|
||||||
|
return value.toLocaleString("nb-NO", { maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Root --------------------------------------------------------------
|
||||||
|
|
||||||
|
export function PublicOrderOfMerit({ oomId }: { oomId: string }) {
|
||||||
|
const [oom, setOom] = useState<ApiPublicOom | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/public/order-of-merits/${oomId}`, { credentials: "include" })
|
||||||
|
if (res.status === 404) {
|
||||||
|
if (!cancelled) setError("Order of Merit finnes ikke.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`oom: ${res.status}`)
|
||||||
|
const data: ApiPublicOom = await res.json()
|
||||||
|
if (!cancelled) setOom(data)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å laste resultatlisten. Prøv å laste siden på nytt.")
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [oomId])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !oom) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<ShieldAlert aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
{error ?? "Order of Merit finnes ikke."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl items-center justify-center px-5 py-4">
|
||||||
|
<Wordmark compact />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Banner name={oom.name} kind={oom.kind} resultType={oom.result_type} />
|
||||||
|
|
||||||
|
<section aria-label="Resultatliste" className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-bold tracking-tight text-foreground">Resultatliste</h2>
|
||||||
|
<LeaderboardTable entries={oom.entries} resultType={oom.result_type} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Banner --------------------------------------------------------------
|
||||||
|
|
||||||
|
function Banner({ name, kind, resultType }: { name: string; kind: ApiKind; resultType: ApiResultType }) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-primary to-primary/80 px-6 py-10 shadow-lg shadow-primary/20 sm:px-8 sm:py-12">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full border-[12px] border-primary-foreground/10"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -bottom-20 -left-10 size-48 rounded-full border-[12px] border-primary-foreground/10"
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col gap-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wide text-primary-foreground/80">
|
||||||
|
<Users aria-hidden="true" className="size-4" />
|
||||||
|
Order of Merit
|
||||||
|
</span>
|
||||||
|
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-primary-foreground text-balance sm:text-4xl">
|
||||||
|
{name}
|
||||||
|
</h1>
|
||||||
|
<span className="text-sm font-semibold text-primary-foreground/80">
|
||||||
|
{kind === "team" ? "Lag" : "Spiller"} · {RESULT_TYPE_LABELS[resultType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resultatliste ---------------------------------------------------------
|
||||||
|
// Samme visuelle mønster som order-of-merit-detail.tsx sin LeaderboardTable
|
||||||
|
// (gull-fremhevet førsteplass, "ikke kvalifisert"-seksjon) -- bevisst
|
||||||
|
// duplisert (ikke delt) i tråd med hvordan list/detail-komponentene i
|
||||||
|
// dette området allerede holder egne lokale kopier av samme mønster.
|
||||||
|
|
||||||
|
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: ApiResultType }) {
|
||||||
|
const eligible = entries.filter((e) => e.eligible)
|
||||||
|
const ineligible = entries.filter((e) => !e.eligible)
|
||||||
|
|
||||||
|
if (eligible.length === 0 && ineligible.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-3xl border border-dashed border-border bg-card px-6 py-12 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<Medal aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">Ingen resultater ennå.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||||||
|
{eligible.map((e) => {
|
||||||
|
const isLeader = e.position === "1"
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={e.player_id}
|
||||||
|
className={cn("flex items-center gap-3 px-4 py-3", isLeader ? "bg-gold text-gold-foreground" : "bg-card")}
|
||||||
|
>
|
||||||
|
<span className={cn("w-9 shrink-0 text-center text-sm font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
|
||||||
|
{e.position}
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className={cn("truncate text-sm font-bold", isLeader ? "text-gold-foreground" : "text-foreground")}>{e.player_name}</span>
|
||||||
|
{e.results_counted !== e.results_available && (
|
||||||
|
<span className={cn("text-xs", isLeader ? "text-gold-foreground/80" : "text-muted-foreground")}>
|
||||||
|
{e.results_counted} av {e.results_available} resultater talt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={cn("shrink-0 text-base font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
|
||||||
|
{formatValue(e.value, resultType)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{ineligible.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Ikke kvalifisert</h3>
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border opacity-70">
|
||||||
|
{ineligible.map((e) => (
|
||||||
|
<li key={e.player_id} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-semibold text-muted-foreground">{e.player_name}</span>
|
||||||
|
{e.ineligible_reason && <span className="text-xs text-muted-foreground">{e.ineligible_reason}</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ from app.routers.order_of_merit import (
|
||||||
create_order_of_merit,
|
create_order_of_merit,
|
||||||
create_order_of_merit_team,
|
create_order_of_merit_team,
|
||||||
delete_order_of_merit_team,
|
delete_order_of_merit_team,
|
||||||
|
get_public_order_of_merit,
|
||||||
link_tournament_to_order_of_merit,
|
link_tournament_to_order_of_merit,
|
||||||
list_order_of_merit_teams,
|
list_order_of_merit_teams,
|
||||||
order_of_merit_leaderboard,
|
order_of_merit_leaderboard,
|
||||||
|
|
@ -41,6 +42,7 @@ from tests.conftest import (
|
||||||
create_tournament_round,
|
create_tournament_round,
|
||||||
create_tournament_round_participant,
|
create_tournament_round_participant,
|
||||||
create_user,
|
create_user,
|
||||||
|
new_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
import app.db as app_db
|
import app.db as app_db
|
||||||
|
|
@ -388,3 +390,57 @@ async def test_eclectic_oom_rejects_mode_switch_with_existing_different_course_l
|
||||||
oom.id, OrderOfMeritUpdate(aggregation_mode="eclectic"), organization_id=org_id
|
oom.id, OrderOfMeritUpdate(aggregation_mode="eclectic"), organization_id=org_id
|
||||||
)
|
)
|
||||||
assert exc_info.value.status_code == 400
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Steg 3 (ADR-076): offentlig/delt visning -- migrasjon 076
|
||||||
|
# (public_order_of_merit_by_id) + /public/order-of-merits/{id}.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_public_order_of_merit_returns_404_for_nonexistent_id(pool):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_public_order_of_merit(new_id())
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_public_order_of_merit_returns_404_for_private_oom(pool):
|
||||||
|
"""public_visible=false (default) -- samme anti-enumerering som
|
||||||
|
public_org_by_slug/public_tournament_by_code: en privat OOM skal gi
|
||||||
|
NØYAKTIG samme 404 som en ikke-eksisterende id, ikke en 403 som ville
|
||||||
|
røpet at id-en faktisk finnes."""
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
oom = await create_order_of_merit(
|
||||||
|
OrderOfMeritCreate(name="Privat OOM", kind="player", result_type="gross", public_visible=False),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_public_order_of_merit(oom.id)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_public_order_of_merit_returns_leaderboard_when_visible(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
user_id = await create_user()
|
||||||
|
player_id = await create_player(org_id, user_id=user_id, display_name="Offentlig Spiller")
|
||||||
|
|
||||||
|
tournament_id = await _setup_stroke_gross_tournament(org_id, "Offentlig turnering", [(player_id, 4)])
|
||||||
|
|
||||||
|
oom = await create_order_of_merit(
|
||||||
|
OrderOfMeritCreate(name="Offentlig OOM", kind="player", result_type="gross", public_visible=True),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
await link_tournament_to_order_of_merit(
|
||||||
|
oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_id), organization_id=org_id
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await get_public_order_of_merit(oom.id)
|
||||||
|
assert result.name == "Offentlig OOM"
|
||||||
|
assert result.kind == "player"
|
||||||
|
assert result.result_type == "gross"
|
||||||
|
assert len(result.entries) == 1
|
||||||
|
assert result.entries[0].player_name == "Offentlig Spiller"
|
||||||
|
assert result.entries[0].value == 72.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue