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:
Erol Haagenrud 2026-08-15 21:20:23 +02:00
parent fe13a82009
commit f740814833
9 changed files with 578 additions and 2 deletions

View 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;

View file

@ -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)

View file

@ -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.

View file

@ -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)

View file

@ -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,
)

View 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} />
}

View file

@ -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

View 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>
)
}

View file

@ -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