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)
|
||||
|
||||
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
|
||||
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/<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(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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
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 { 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
|
|||
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
|
||||
</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">
|
||||
{saving ? "Lagrer…" : "Lagre"}
|
||||
</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 }) {
|
||||
return (
|
||||
<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_team,
|
||||
delete_order_of_merit_team,
|
||||
get_public_order_of_merit,
|
||||
link_tournament_to_order_of_merit,
|
||||
list_order_of_merit_teams,
|
||||
order_of_merit_leaderboard,
|
||||
|
|
@ -41,6 +42,7 @@ from tests.conftest import (
|
|||
create_tournament_round,
|
||||
create_tournament_round_participant,
|
||||
create_user,
|
||||
new_id,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
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