Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103)

Ny tournament_round_shot-tabell (org-scopet RLS, FK-et til
tournament_round_participant + hole_number siden tournament_round_hole
kanskje ikke finnes ennå). ShotMeasurementSheet/MapPointPicker gjenbrukt
uendret; ShotMeasurementEntry generalisert fra en lokal round-detail.tsx-
funksjon til en delt, URL-parameterisert komponent. Deling går til den
rundespesifikke kommentartråden (ADR-102) i stedet for Banter Board.
Autorisasjon speiler update_hole (self/org-admin), strengere enn ADR-102s
kommentar-nivå.

Migrasjon 091 IKKE kjørt mot ekte teecup_db ennå -- venter på
brukerbekreftelse før utrulling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-22 07:16:14 +02:00
parent bd965ba2ed
commit 885dcac4e5
10 changed files with 1273 additions and 414 deletions

View file

@ -0,0 +1,75 @@
-- =====================================================================
-- TeeCup — Slag-for-slag GPS-avstandsmåling for org-turneringer,
-- migrasjon 091 (ADR-103). Speiler 060_round_shot.sql/
-- 061_round_shot_end_method.sql (ADR-048, frittstående runder)
-- strukturelt, men org-scopet (RLS) og med en annen eierskaps-nøkkel.
--
-- Eierskap: FK direkte til (organization_id, tournament_round_
-- participant_id) + hole_number -- IKKE tournament_round_hole_id.
-- `tournament_round_hole` opprettes (ulikt frittstående round_hole,
-- som pre-oppretter alle 18 ved rundestart) FØRST ved første
-- score-innsending (update_hole sin INSERT ... ON CONFLICT,
-- individual_tournaments.py) -- en spiller skal kunne måle et slag
-- FØR de fullfører hullets score, så en FK mot en hull-rad som kanskje
-- ikke finnes ennå ville vært feil. Samme LEFT JOIN-tolerante mønster
-- som get_tournament_round_hole_target_points/list_round_participant_
-- holes allerede bruker for nøyaktig dette problemet.
--
-- Turneringsmodellen har ALDRI en side/lag-XOR (kun individuelt) --
-- ingen tilsvarende round_side-gren trengs her, ulikt round_shot.
--
-- Koordinater: samme rene double precision lat/lng-par som round_shot
-- (ADR-048 Beslutning D) -- ingen PostGIS.
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE tournament_round_shot (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_participant_id uuid NOT NULL,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
-- Hvem som MÅLTE slaget -- ikke nødvendigvis samme som deltakeren selv,
-- samme "en lenket medspiller/org-admin kan føre" som update_hole.
recorded_by_user_id uuid NOT NULL REFERENCES app_user(id),
-- Eksplisitt, hull-tolerant rekkefølge (IKKE ORDER BY captured_at) --
-- samme begrunnelse som round_shot.shot_number.
shot_number integer NOT NULL CHECK (shot_number > 0),
club text NOT NULL CHECK (char_length(club) <= 50),
distance_meters numeric(6,1) NOT NULL CHECK (distance_meters > 0 AND distance_meters < 500),
start_method text NOT NULL CHECK (start_method IN ('gps', 'map_tap')),
start_lat double precision NOT NULL CHECK (start_lat BETWEEN -90 AND 90),
start_lng double precision NOT NULL CHECK (start_lng BETWEEN -180 AND 180),
end_method text NOT NULL CHECK (end_method IN ('gps', 'map_tap')) DEFAULT 'gps',
end_lat double precision NOT NULL CHECK (end_lat BETWEEN -90 AND 90),
end_lng double precision NOT NULL CHECK (end_lng BETWEEN -180 AND 180),
-- Deling til den rundespesifikke kommentartråden (ADR-102) -- lenke,
-- ikke bare et bool-flagg, samme prinsipp som round_shot.
shared_tournament_round_message_id uuid REFERENCES tournament_round_message(id) ON DELETE SET NULL,
captured_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_round_participant_id)
REFERENCES tournament_round_participant(organization_id, id) ON DELETE CASCADE,
UNIQUE (tournament_round_participant_id, hole_number, shot_number)
);
CREATE INDEX ON tournament_round_shot (organization_id, tournament_round_participant_id, hole_number, shot_number);
DO $$
BEGIN
ALTER TABLE tournament_round_shot ENABLE ROW LEVEL SECURITY;
ALTER TABLE tournament_round_shot FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON tournament_round_shot
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
END $$;
-- Samme "slett og opprett på nytt, aldri rediger"-prinsipp som round_shot
-- -- ett smalt UPDATE-unntak for delings-lenken.
GRANT SELECT, INSERT, DELETE ON tournament_round_shot TO teecup_app;
GRANT UPDATE (shared_tournament_round_message_id) ON tournament_round_shot TO teecup_app;

View file

@ -10336,6 +10336,102 @@ containere ennå.
--- ---
## ADR-103: Slag-for-slag GPS-avstandsmåling for org-turneringer (2026-08-22)
**Kontekst:** punkt 6 i "Scoring-paritet: frittstående runde vs.
org-turnering"-gap-listen (FEATURE_BACKLOG.md), bevisst valgt ETTER
punkt 7 (rundespesifikk kommentartråd, ADR-102) fordi delings-
funksjonen (ADR-048 Beslutning E) forutsetter et sted å dele til.
Bruker stilte et avklarende spørsmål før planen ble godkjent: er
avstand-til-green + hindringer-underveis (rangefinderen) dekket? Svar:
JA, allerede portert og live identisk i begge flyter siden ADR-064/083
(`HoleTargetDistance`, delt komponent, allerede montert i
`TournamentScoringWizard`) -- IKKE forveksle med denne ADR-en, som
dekker den ATSKILTE, frie slag-for-slag-målingen (utslag→ball,
ADR-048), ikke faste banepunkter.
**Beslutning A -- ny eierskaps-nøkkel: `tournament_round_participant_id`
+ `hole_number`, IKKE `tournament_round_hole_id`.** `tournament_round_
hole` opprettes (ulikt frittstående `round_hole`, som pre-oppretter
alle 18 ved rundestart) FØRST ved `update_hole`s første score-
innsending -- en spiller må kunne måle et slag FØR hullets score er
registrert. Ny migrasjon `091_tournament_round_shot.sql` speiler
`round_shot` (060/061) strukturelt, med `organization_id` + full RLS
(turneringer ER org-scopet) og FK direkte til `tournament_round_
participant`, ikke via en hull-rad. Samme LEFT JOIN-tolerante
oppløsning som `get_tournament_round_hole_target_points`/
`list_round_participant_holes` allerede bruker for nøyaktig dette.
Ingen side/lag-XOR (ulikt `round_shot`) -- turneringsmodellen er alltid
individuell.
**Beslutning B -- autorisasjon speiler `update_hole` (self ELLER
org-admin), IKKE ADR-102s "org-medlem"-nivå.** Å måle/slette et slag er
samme sensitivitetsklasse som selve SCORE-registreringen (en handling
FOR en bestemt deltaker), ikke oppsett-/kommentar-klassen. Lesing
krever kun org-medlemskap, samme mønster som `list_round_participant_
holes`.
**Beslutning C -- deling reuser den NYE rundespesifikke kommentartråden
(ADR-102, `tournament_round_message`), ikke Banter Board.** Ny fil
`app/routers/tournament_round_shots.py` speiler `rounds.py::share_shot`
sitt Mapbox Static Images-snippet-mønster (`_mapbox_static_snippet_url`/
`_encode_polyline`) som en bevisst LOKAL kopi -- org-turnering-
subsystemet importerer aldri fra det frittstående-runde-subsystemet
(samme presedens som ADR-102s `_resolve_author_name`). Grasiøs
degradering til ren tekst uten satellittutsnitt ved Mapbox-feil,
identisk med den frittstående sidens.
**Beslutning D -- `ShotMeasurementEntry` generalisert til en delt fil,
IKKE duplisert.** `ShotMeasurementSheet`/`MapPointPicker` (Mapbox GL JS
+ GPS-flyten) var allerede 100 % generiske (ingen `roundId`-kobling i
det hele tatt) -- gjenbrukt UENDRET. `ShotMeasurementEntry` selv (lokal
funksjon i `round-detail.tsx`, hardkodet `/rounds/${roundId}/...`-URL-
bygging) ble flyttet til ny `frontend/components/shot/shot-measurement-
entry.tsx` og generalisert til å ta eksplisitte URL-er
(`shotsUrl`/`shotsRoundBaseUrl`/`targetPointsUrl`) i stedet for
`roundId`+`owner`. De to backendenes ulike delings-felt-navn
(`shared_round_message_id` vs. `shared_tournament_round_message_id`)
normaliseres til ett felles `shared: boolean`-flagg i komponentens egen
`normalizeShot()`. `round-detail.tsx`s tre eksisterende kallsteder
(ScoringWizard-steg, PlayerHoleCards-merkelapp, SideScorecardGrid-
merkelapp) oppdatert til å bygge de samme URL-strengene eksplisitt --
atferd UENDRET der, kun en signaturomskrivning.
**Beslutning E -- mount-punkt: KUN i `TournamentScoringWizard`s
"direction"-steg, ingen egen badge i flight-kortlisten.** Speiler
nøyaktig `round-detail.tsx` sin plassering (rett under `<ClubPicker>`).
Siden BÅDE den kjedede flerspiller-wizarden OG `HoleGrid` sitt enkelt-
spiller-kall (ADR-100) gjenbruker SAMME `TournamentScoringWizard`,
dekker dette ene mount-punktet begge overflatene automatisk -- ulikt
frittstående siden, som trengte 3 separate mount-punkter for samme
funksjon fordi den har 3 separate komponenter. Samme "kun i wizarden"-
presedens som rangefinderen (`HoleTargetDistance`) allerede har i denne
filen.
**Status:** 188/188 pytest (11 nye -- opprett/liste, shot_number-
inkrementering per hull, måling FØR tournament_round_hole finnes,
self-only-avvisning, org-admin-unntak, self-only-sletting, RLS feil-org
404, feil tournament_id 404, deling oppretter ekte tournament_round_
message, deling nektet for ikke-eier, ugyldig avstand avvist),
`tsc --noEmit` rent. Full-stack scratch (egen db/API-/MinIO-container
MED de ekte Mapbox-tokenene, ekte innlogging, ingen fetch-mocking) --
GPS-flyten testet med en injisert `navigator.geolocation`-overstyring
(samme "headless Chrome nekter ekte geolokasjon"-arbeidsomgåelse som
ADR-083 selv dokumenterte -- CDP sin native geolocation-emulering alene
holdt IKKE), kart-velg-punkt-flyten testet med et EKTE Mapbox-kart
(satellittfliser lastet, WebGL rendret) -- bekreftet: slag målt (GPS OG
kart-tap), kølle registrert, avstand riktig beregnet, deling til
kommentartråden (ADR-102) opprettet en EKTE `tournament_round_message`
med et EKTE satellittbilde-utsnitt (14 444 byte AVIF, verifisert
direkte mot scratch-MinIO -- ikke antatt), "Delt"-merkelapp korrekt,
sletting av et målt slag fjernet slaget men lot den allerede delte
kommentaren stå (samme `ON DELETE SET NULL`-oppførsel som frittstående
siden), mørk modus + smal mobilskjerm. Alle scratch-ressurser (to
runder, inkl. en omstart av API-containeren for å rette en glemt
Mapbox-token-miljøvariabel underveis) ryddet opp etter verifisering.
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -14459,3 +14459,55 @@ Neste steg:
compose build teecup_api teecup_frontend && up -d`, begge compose build teecup_api teecup_frontend && up -d`, begge
containere friske, rene logger. `https://teecup.golf/health` 200 containere friske, rene logger. `https://teecup.golf/health` 200
etter utrulling. etter utrulling.
161. **Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103),
2026-08-22 -- punkt 6 i gap-listen, bygget etter punkt 7 (ADR-102)
fordi delingsfunksjonen forutsatte kommentartråden.** Bruker
bekreftet før planen ble godkjent at avstand-til-green/hindringer
(rangefinderen) allerede er dekket (ADR-064/083, delt komponent,
live siden 2026-08-17) -- denne runden dekker den atskilte, frie
slag-for-slag-målingen (utslag→ball, ADR-048), ikke faste
banepunkter.
**Ny migrasjon `091_tournament_round_shot.sql`:** speiler
`round_shot` (060/061) strukturelt, org-scopet (RLS), FK-et direkte
til `tournament_round_participant` + `hole_number` (IKKE
`tournament_round_hole_id` -- den raden finnes kanskje ikke ennå,
siden `tournament_round_hole` først opprettes ved første
score-innsending, ulikt frittstående `round_hole`).
**Backend:** ny fil `app/routers/tournament_round_shots.py`.
Autorisasjon speiler `update_hole` (self ELLER org-admin) for
skriving -- strengere enn ADR-102s "org-medlem"-nivå, siden dette
er samme sensitivitetsklasse som selve scoringen. Deling reuser den
NYE rundespesifikke kommentartråden (ADR-102), ikke Banter Board --
lokal kopi av `rounds.py::share_shot` sitt Mapbox-snippet-mønster
(samme "ingen kryssimport mellom subsystemene"-presedens).
**Frontend:** `ShotMeasurementSheet`/`MapPointPicker` (Mapbox GL
JS + GPS) var allerede 100 % generiske -- gjenbrukt UENDRET.
`ShotMeasurementEntry` generalisert fra en lokal `round-detail.tsx`-
funksjon til ny delt `frontend/components/shot/shot-measurement-
entry.tsx` (eksplisitte URL-props i stedet for `roundId`+`owner`) --
de tre eksisterende frittstående-kallstedene fikk kun en
signaturomskrivning, atferd uendret. Ny bruk i
`TournamentScoringWizard`s "direction"-steg (samme sted som
round-detail.tsx sin plassering) -- dekker BÅDE kjedet flerspiller-
scoring OG `HoleGrid` sitt enkelt-spiller-kall siden begge deler
samme wizard-komponent.
**Verifisert:** 188/188 pytest (11 nye), `tsc --noEmit` rent.
Full-stack scratch MED ekte Mapbox-tokens (ekte innlogging, ingen
fetch-mocking): GPS-flyten testet via injisert
`navigator.geolocation`-overstyring (samme arbeidsomgåelse som
ADR-083 for headless Chrome), kart-velg-punkt testet med et EKTE
Mapbox-kart. Slag målt (begge metoder), kølle+avstand riktig,
deling opprettet en ekte `tournament_round_message` med et ekte
satellittbilde (14 444 byte AVIF, verifisert direkte mot
scratch-MinIO), "Delt"-merkelapp korrekt, sletting av et slag lot
den allerede delte kommentaren stå urørt, mørk modus + smal
mobilskjerm. Alle scratch-ressurser ryddet opp.
**IKKE rullet ut ennå** -- venter på brukerbekreftelse (ny
migrasjon 091 mot ekte `teecup_db` + `docker compose build
teecup_api teecup_frontend && up -d`).

View file

@ -4984,13 +4984,15 @@ runde HAR, org-turnering MANGLER, om ikke annet er sagt):**
har ingen tilsvarende kolonne, `TournamentScoringWizard` ingen har ingen tilsvarende kolonne, `TournamentScoringWizard` ingen
tilsvarende knapp. Antatt **middels** -- ny migrasjon + backend-speil tilsvarende knapp. Antatt **middels** -- ny migrasjon + backend-speil
av eksisterende capping-logikk (`max_hole_score_for_handicap`). av eksisterende capping-logikk (`max_hole_score_for_handicap`).
6. **Slag-for-slag GPS-avstandsmåling (skuddlengde + kølle) kun i 6. ✅ **Slag-for-slag GPS-avstandsmåling -- FERDIG BYGGET 2026-08-22
frittstående runder.** `round_shot`-tabellen/`ShotMeasurementEntry` (ADR-103), se CHANGELOG punkt 161.** Ny `tournament_round_shot`-
(ADR-048) har ingen tilsvarende i `individual_tournaments.py`/ tabell (org-scopet, RLS, FK-et til tournament_round_participant +
`individual-tournament-detail.tsx`. IKKE forveksle med rangefinder hole_number). `ShotMeasurementSheet`/`MapPointPicker` gjenbrukt
eller Flagg-GPS (begge allerede bygget begge steder, se over). Antatt uendret; `ShotMeasurementEntry` generalisert til delt fil. Deling
**middels/stor** -- `round_shot`s eierskap er i dag kablet til går til den nye kommentartråden (punkt 7/ADR-102). Verifisert i
frittstående-runde-XOR-en (ADR-048 Beslutning C), ekte backend-arbeid. scratch med ekte Mapbox-tokens (GPS + kart-valgt punkt begge
testet), **ikke rullet ut mot ekte containere ennå** -- venter på
migrasjon 091 + deploy-bekreftelse.
7. ✅ **Kommentarfelt/tråd på runden -- FERDIG BYGGET OG RULLET UT 7. ✅ **Kommentarfelt/tråd på runden -- FERDIG BYGGET OG RULLET UT
2026-08-22 (ADR-102), se CHANGELOG punkt 160.** Ny 2026-08-22 (ADR-102), se CHANGELOG punkt 160.** Ny
`tournament_round_message`-tabell (org-scopet, RLS), én tråd per `tournament_round_message`-tabell (org-scopet, RLS), én tråd per

View file

@ -33,6 +33,7 @@ from .routers import (
rounds, rounds,
scoring, scoring,
tournament_round_messages, tournament_round_messages,
tournament_round_shots,
tournaments, tournaments,
) )
from . import storage from . import storage
@ -68,6 +69,7 @@ app.include_router(messaging.public_router)
app.include_router(rounds.router) app.include_router(rounds.router)
app.include_router(round_messages.router) app.include_router(round_messages.router)
app.include_router(tournament_round_messages.router) app.include_router(tournament_round_messages.router)
app.include_router(tournament_round_shots.router)
app.include_router(friends.router) app.include_router(friends.router)
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(print_pdf.router) app.include_router(print_pdf.router)

View file

@ -0,0 +1,327 @@
"""
Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103) --
speiler `rounds.py` sin slag-seksjon (ADR-048) for frittstående runder,
men org-scopet (RLS) og forenklet: turneringsmodellen har ALDRI en
side/lag-XOR (kun individuelt), kun participant-varianten trengs
(ingen `/sides/`-speilet endepunktsett).
Eierskap: `tournament_round_shot` (migrasjon 091) kjenner
`tournament_round_participant_id` + `hole_number` DIREKTE -- IKKE
`tournament_round_hole_id`. `tournament_round_hole` opprettes (ulikt
frittstående `round_hole`) først ved FØRSTE score-innsending
(`update_hole`), en spiller kunne måle et slag FØR hullets score
er registrert -- en FK mot en hull-rad som kanskje ikke finnes ennå
ville vært feil. Samme LEFT JOIN-tolerante oppløsning som
`get_tournament_round_hole_target_points`/`list_round_participant_holes`
allerede bruker.
Autorisasjon, bevisst STRENGERE enn ADR-102s kommentartråd: skriving
(POST/DELETE) krever `user_is_own_tournament_participant` (self ELLER
org-admin) -- samme sensitivitetsklasse som selve SCORE-registreringen
(`update_hole`), ikke oppsett-klassen kommentarer hører til. Lesing
(GET) krever kun org-medlemskap, samme mønster som
`list_round_participant_holes`.
Deling: reuser den rundespesifikke kommentartråden (ADR-102,
`tournament_round_message`) i stedet for Banter Board, speiler
`rounds.py::share_shot` sitt Mapbox Static Images-snippet-mønster.
`_mapbox_static_snippet_url`/`_encode_polyline` er en bevisst LOKAL kopi
(ikke importert fra rounds.py) -- org-turnering-subsystemet importerer
aldri fra det frittstående-runde-subsystemet (samme presedens ADR-102
selv fulgte for `_resolve_author_name`).
"""
import urllib.parse
from typing import Literal
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from .. import storage
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..config import settings
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..team_authz import user_is_own_tournament_participant
from .tournament_round_messages import _resolve_author_name
router = APIRouter(tags=["tournament-round-shots"])
_SHOT_SELECT = """
SELECT trs.id::text AS id, trs.shot_number, trs.club, trs.distance_meters, trs.start_method,
trs.start_lat, trs.start_lng, trs.end_method, trs.end_lat, trs.end_lng,
trs.shared_tournament_round_message_id::text AS shared_tournament_round_message_id,
trs.captured_at, trm.image_key AS shared_image_key
FROM tournament_round_shot trs
LEFT JOIN tournament_round_message trm ON trm.id = trs.shared_tournament_round_message_id
"""
class ShotIn(BaseModel):
club: str = Field(max_length=50)
distance_meters: float = Field(gt=0, lt=500)
start_method: Literal["gps", "map_tap"]
start_lat: float = Field(ge=-90, le=90)
start_lng: float = Field(ge=-180, le=180)
end_method: Literal["gps", "map_tap"] = "gps"
end_lat: float = Field(ge=-90, le=90)
end_lng: float = Field(ge=-180, le=180)
class ShotOut(BaseModel):
id: str
shot_number: int
club: str
distance_meters: float
start_method: str
start_lat: float
start_lng: float
end_method: str
end_lat: float
end_lng: float
shared_tournament_round_message_id: str | None
shared_image_url: str | None
captured_at: str
def _shot_out(row) -> ShotOut:
return ShotOut(
id=row["id"],
shot_number=row["shot_number"],
club=row["club"],
distance_meters=float(row["distance_meters"]),
start_method=row["start_method"],
start_lat=row["start_lat"],
start_lng=row["start_lng"],
end_method=row["end_method"],
end_lat=row["end_lat"],
end_lng=row["end_lng"],
shared_tournament_round_message_id=row["shared_tournament_round_message_id"],
shared_image_url=storage.public_url(row["shared_image_key"]) if row["shared_image_key"] else None,
captured_at=row["captured_at"].isoformat(),
)
async def _resolve_participant_hole(
conn, tournament_id: str, round_id: str, round_participant_id: str, hole_number: int
) -> str:
"""LEFT JOIN-tolerant: bekrefter at deltakeren/runden/turneringen
faktisk henger sammen, men krever IKKE at tournament_round_hole
finnes ennå (spilleren kan måle et slag før de fullfører hullets
score). Returnerer tournament_participant_id (for autorisasjon)."""
row = await conn.fetchrow(
"""
SELECT trp.tournament_participant_id::text AS tournament_participant_id, tr.tournament_id::text AS tournament_id
FROM tournament_round_participant trp
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
WHERE trp.id = $1 AND trp.tournament_round_id = $2
""",
round_participant_id, round_id,
)
if row is None or row["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
if hole_number < 1 or hole_number > 18:
raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.")
return row["tournament_participant_id"]
async def _list_shots(conn, round_participant_id: str, hole_number: int) -> list[ShotOut]:
rows = await conn.fetch(
_SHOT_SELECT + " WHERE trs.tournament_round_participant_id = $1 AND trs.hole_number = $2 ORDER BY trs.shot_number",
round_participant_id, hole_number,
)
return [_shot_out(r) for r in rows]
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
"/participants/{round_participant_id}/holes/{hole_number}/shots",
response_model=list[ShotOut],
)
async def list_tournament_round_shots(
tournament_id: str, round_id: str, round_participant_id: str, hole_number: int,
organization_id: str = Depends(get_authorized_org),
) -> list[ShotOut]:
async with org_connection(organization_id) as conn:
await _resolve_participant_hole(conn, tournament_id, round_id, round_participant_id, hole_number)
return await _list_shots(conn, round_participant_id, hole_number)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
"/participants/{round_participant_id}/holes/{hole_number}/shots",
response_model=ShotOut,
status_code=201,
)
async def create_tournament_round_shot(
tournament_id: str, round_id: str, round_participant_id: str, hole_number: int, body: ShotIn,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> ShotOut:
async with org_connection(organization_id) as conn, translate_db_errors():
tournament_participant_id = await _resolve_participant_hole(
conn, tournament_id, round_id, round_participant_id, hole_number
)
if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user.user_id):
raise app_error(
403, "NOT_TOURNAMENT_PARTICIPANT",
"Du kan kun måle slag for deg selv (eller være organisasjonsadministrator).",
)
row = await conn.fetchrow(
"""
INSERT INTO tournament_round_shot (
organization_id, tournament_round_participant_id, hole_number, recorded_by_user_id,
shot_number, club, distance_meters, start_method, start_lat, start_lng, end_method, end_lat, end_lng
)
VALUES (
$1, $2, $3, $4,
COALESCE((SELECT MAX(shot_number) FROM tournament_round_shot
WHERE tournament_round_participant_id = $2 AND hole_number = $3), 0) + 1,
$5, $6, $7, $8, $9, $10, $11, $12
)
RETURNING id::text AS id
""",
organization_id, round_participant_id, hole_number, user.user_id,
body.club, body.distance_meters, body.start_method, body.start_lat, body.start_lng,
body.end_method, body.end_lat, body.end_lng,
)
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE trs.id = $1", row["id"])
return _shot_out(full_row)
@router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/shots/{shot_id}",
status_code=204,
)
async def delete_tournament_round_shot(
tournament_id: str, round_id: str, shot_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"""
SELECT trs.tournament_round_participant_id::text AS round_participant_id,
trp.tournament_participant_id::text AS tournament_participant_id
FROM tournament_round_shot trs
JOIN tournament_round_participant trp ON trp.id = trs.tournament_round_participant_id
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
WHERE trs.id = $1 AND tr.id = $2 AND tr.tournament_id = $3
""",
shot_id, round_id, tournament_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
if not await user_is_own_tournament_participant(
conn, organization_id, row["tournament_participant_id"], user.user_id
):
raise app_error(
403, "NOT_TOURNAMENT_PARTICIPANT",
"Du kan kun slette dine egne målte slag (eller være organisasjonsadministrator).",
)
await conn.execute("DELETE FROM tournament_round_shot WHERE id = $1", shot_id)
def _encode_polyline(points: list[tuple[float, float]]) -> str:
"""Google sin polyline-algoritme (5 desimaler) -- lokal kopi av
rounds.py sin, se modulens docstring for hvorfor den ikke importeres."""
result: list[str] = []
prev_lat = prev_lng = 0
for lat, lng in points:
lat_i = round(lat * 1e5)
lng_i = round(lng * 1e5)
for value, prev in ((lat_i, prev_lat), (lng_i, prev_lng)):
delta = value - prev
shifted = ~(delta << 1) if delta < 0 else (delta << 1)
chunk = ""
while shifted >= 0x20:
chunk += chr((0x20 | (shifted & 0x1F)) + 63)
shifted >>= 5
chunk += chr(shifted + 63)
result.append(chunk)
prev_lat, prev_lng = lat_i, lng_i
return "".join(result)
def _mapbox_static_snippet_url(start_lat: float, start_lng: float, end_lat: float, end_lng: float) -> str:
encoded = urllib.parse.quote(_encode_polyline([(start_lat, start_lng), (end_lat, end_lng)]), safe="")
overlay = (
f"pin-s-a+ff5a1f({start_lng},{start_lat}),"
f"pin-s-b+2f7a3f({end_lng},{end_lat}),"
f"path-4+ff5a1f-0.9({encoded})"
)
return (
f"https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/{overlay}/auto/600x400@2x"
f"?padding=60&access_token={settings.MAPBOX_SECRET_TOKEN}"
)
class ShotShareIn(BaseModel):
body: str = Field(max_length=2000)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/shots/{shot_id}/share",
response_model=ShotOut,
)
async def share_tournament_round_shot(
tournament_id: str, round_id: str, shot_id: str, body: ShotShareIn,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> ShotOut:
async with org_connection(organization_id) as conn:
shot_row = await conn.fetchrow(
"""
SELECT trs.id::text AS id, trs.start_lat, trs.start_lng, trs.end_lat, trs.end_lng,
trp.tournament_participant_id::text AS tournament_participant_id
FROM tournament_round_shot trs
JOIN tournament_round_participant trp ON trp.id = trs.tournament_round_participant_id
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
WHERE trs.id = $1 AND tr.id = $2 AND tr.tournament_id = $3
""",
shot_id, round_id, tournament_id,
)
if shot_row is None:
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
if not await user_is_own_tournament_participant(
conn, organization_id, shot_row["tournament_participant_id"], user.user_id
):
raise app_error(
403, "NOT_TOURNAMENT_PARTICIPANT",
"Du kan kun dele dine egne målte slag (eller være organisasjonsadministrator).",
)
author_display_name = await _resolve_author_name(conn, user.user_id)
image_key = None
if settings.MAPBOX_SECRET_TOKEN:
url = _mapbox_static_snippet_url(
shot_row["start_lat"], shot_row["start_lng"], shot_row["end_lat"], shot_row["end_lng"]
)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
resp = await client.get(url)
resp.raise_for_status()
image_key = await storage.upload_image("tournament_round_messages", round_id, resp.content)
except httpx.HTTPError:
# Grasiøs degradering, samme prinsipp som rounds.py::share_shot
# -- ren tekst uten satellittutsnitt fremfor å blokkere delingen.
image_key = None
async with translate_db_errors():
message_row = await conn.fetchrow(
"""
INSERT INTO tournament_round_message
(organization_id, tournament_round_id, author_user_id, author_display_name, body, image_key)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id::text AS id
""",
organization_id, round_id, user.user_id, author_display_name, body.body, image_key,
)
await conn.execute(
"UPDATE tournament_round_shot SET shared_tournament_round_message_id = $1 WHERE id = $2",
message_row["id"], shot_id,
)
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE trs.id = $1", shot_id)
return _shot_out(full_row)

View file

@ -67,6 +67,7 @@ import {
} from "@/components/hole-stat-inputs" } from "@/components/hole-stat-inputs"
import { HoleHistoryDetail } from "@/components/hole-history-detail" import { HoleHistoryDetail } from "@/components/hole-history-detail"
import { HoleTargetDistance } from "@/components/hole-target-distance" import { HoleTargetDistance } from "@/components/hole-target-distance"
import { ShotMeasurementEntry } from "@/components/shot/shot-measurement-entry"
import { ClubPicker } from "@/components/teecup/club-picker" import { ClubPicker } from "@/components/teecup/club-picker"
import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { TournamentPresentationPanel } from "@/components/tournament-presentation"
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
@ -4783,6 +4784,19 @@ function TournamentScoringWizard({
readOnly={false} readOnly={false}
/> />
{/* Slag-for-slag GPS-avstandsmåling (ADR-103) -- samme
plassering som round-detail.tsx sitt "direction"-steg,
rett under kølle-valget. */}
<ShotMeasurementEntry
shotsUrl={`${targetPointsBaseUrl}/participants/${activePlayerId}/holes/${hole.hole_number}/shots`}
shotsRoundBaseUrl={`${targetPointsBaseUrl}/shots`}
targetPointsUrl={`${targetPointsBaseUrl}/holes/${hole.hole_number}/target-points`}
holeNumber={hole.hole_number}
ownBagClubs={ownBagClubs}
readOnly={false}
variant="inline"
/>
<WizardSection title="Retning"> <WizardSection title="Retning">
{hole.par !== 3 && ( {hole.par !== 3 && (
<DirectionCross <DirectionCross

View file

@ -31,7 +31,6 @@ import {
MapPin, MapPin,
Plus, Plus,
RefreshCw, RefreshCw,
Ruler,
Search, Search,
Settings2, Settings2,
Trophy, Trophy,
@ -56,11 +55,9 @@ import {
Stepper, Stepper,
WizardSection, WizardSection,
} from "@/components/hole-stat-inputs" } from "@/components/hole-stat-inputs"
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet" import { ShotMeasurementEntry } from "@/components/shot/shot-measurement-entry"
import { HoleTargetDistance } from "@/components/hole-target-distance" import { HoleTargetDistance } from "@/components/hole-target-distance"
import { HoleHistoryDetail } from "@/components/hole-history-detail" import { HoleHistoryDetail } from "@/components/hole-history-detail"
import { bearingDegrees } from "@/lib/geo"
import { midpoint } from "@/lib/hole-geometry"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types ----------------------------------------------------------------- // --- Types -----------------------------------------------------------------
@ -1805,9 +1802,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
{round.sides.map((s, i) => ( {round.sides.map((s, i) => (
<ShotMeasurementEntry <ShotMeasurementEntry
key={s.id} key={s.id}
roundId={roundId} shotsUrl={`/rounds/${roundId}/sides/${s.id}/holes/${activeHole}/shots`}
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
targetPointsUrl={`/rounds/${roundId}/holes/${activeHole}/target-points`}
holeNumber={activeHole} holeNumber={activeHole}
owner={{ kind: "side", id: s.id }}
ownBagClubs={ownBagClubs} ownBagClubs={ownBagClubs}
readOnly={readOnly} readOnly={readOnly}
variant="badge" variant="badge"
@ -2285,9 +2283,10 @@ function ScoringWizard({
/> />
<ShotMeasurementEntry <ShotMeasurementEntry
roundId={roundId} shotsUrl={`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`}
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
targetPointsUrl={`/rounds/${roundId}/holes/${hole.holeNumber}/target-points`}
holeNumber={hole.holeNumber} holeNumber={hole.holeNumber}
owner={{ kind: "participant", id: player.id }}
ownBagClubs={player.isSelf ? ownBagClubs : []} ownBagClubs={player.isSelf ? ownBagClubs : []}
readOnly={readOnly} readOnly={readOnly}
variant="inline" variant="inline"
@ -2368,403 +2367,6 @@ function ScoringWizard({
) )
} }
// Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- delt mellom
// tre bruksteder: ScoringWizard sitt detalj-steg (inline-variant,
// deltaker-eid hull), PlayerHoleCards sin alltid-synlige merkelapp
// (badge-variant, deltaker-eid, retroaktiv måling), og SideScorecardGrid
// sin tilsvarende merkelapp per side (badge-variant, side-eid hull --
// dekker delt-ball-formatene som ScoringWizard aldri når siden de bruker
// en egen, enklere side-veiviser uten dette steget). Samme
// urlBase-konstruksjon dekker begge hull-eiertyper (ADR-048 sine speilede
// deltaker-/side-endepunkt), ingen egen gren trengs utover selve URL-en.
type ShotRecord = {
id: string
club: string
distance_meters: number
shared_round_message_id: string | null
start_lat: number
start_lng: number
end_lat: number
end_lng: number
}
function ShotMeasurementEntry({
roundId,
holeNumber,
owner,
ownBagClubs,
readOnly,
variant,
label,
}: {
roundId: string
holeNumber: number
owner: { kind: "participant" | "side"; id: string }
ownBagClubs: string[]
readOnly: boolean
variant: "inline" | "badge"
label?: string
}) {
const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots`
const [open, setOpen] = useState(false)
// Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
// det ikke er delt" -- merkelappen viste tidligere KUN et antall, ingen
// måte å se kølle/avstand for egne, private slag. Lagrer nå hele listen
// (ikke bare .length), viser den i en utvidbar liste, med sletting av et
// feilmålt slag.
const [shots, setShots] = useState<ShotRecord[]>([])
const [listOpen, setListOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [shareError, setShareError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(urlBase, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ShotRecord[]) => {
if (!cancelled) setShots(data)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [urlBase])
const count = shots.length
// Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
// utslags- OG green-koordinater kjent (samme datakilde som
// HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
// følge utslag->green, ikke gjettes fra spillerens forrige slag (som
// kan avvike, f.eks. en slice/hook). Egen, liten fetch her (i stedet
// for å prop-drille et tall gjennom RoundDetail/PlayerHoleCards/
// ScoringWizard -- ShotMeasurementEntry brukes fra tre atskilte steder
// i denne filen, kun roundId+holeNumber er felles for alle) -- ett
// ekstra, lett GET-kall per spillerkort er en akseptert kostnad for at
// ALLE tre inngangene til kartet får riktig retning, ikke bare én.
const [courseBearing, setCourseBearing] = useState<number | undefined>(undefined)
useEffect(() => {
let cancelled = false
setCourseBearing(undefined)
fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => {
if (cancelled) return
const middle = points.find((p) => p.poi_type === "green" && p.location === "middle")
const teeFront = points.find((p) => p.poi_type === "tee_front")
const teeBack = points.find((p) => p.poi_type === "tee_back")
const teeRef =
teeFront && teeBack
? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude })
: teeFront
? { lat: teeFront.latitude, lng: teeFront.longitude }
: teeBack
? { lat: teeBack.latitude, lng: teeBack.longitude }
: null
setCourseBearing(
teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined,
)
})
.catch(() => {
if (!cancelled) setCourseBearing(undefined)
})
return () => {
cancelled = true
}
}, [roundId, holeNumber])
// Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet
// 2026-08-21): "opp på skjermen" følger banens EGEN spillretning
// (utslag->green, courseBearing over) når den er kjent -- ellers
// faller tilbake til spillerens EGEN gangretning, regnet ut fra det
// SISTE tidligere målte slaget på dette hullet (start→slutt-retningen)
// -- `shots` er allerede sortert stigende på shot_number av GET-
// endepunktet (se _list_shots_for_hole i rounds.py), så siste element
// ER forrige slag. `undefined` (ingen rotasjon regnet ut ennå) for det
// aller første slaget på et hull uten kjent banegeometri -- MapPoint-
// Picker faller da selv tilbake til enhetens GPS-heading/nord, se den
// komponentens dokumentasjon. Bevisst en STATISK retning, satt idet
// arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav
// gangfart), se map-point-picker.tsx.
const initialBearing = useMemo(() => {
if (typeof courseBearing === "number") return courseBearing
const previous = shots[shots.length - 1]
if (!previous) return undefined
return bearingDegrees(
{ lat: previous.start_lat, lng: previous.start_lng },
{ lat: previous.end_lat, lng: previous.end_lng },
)
}, [shots, courseBearing])
async function deleteShot(shotId: string) {
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
if (res.ok) {
setShots((prev) => prev.filter((s) => s.id !== shotId))
}
}
// Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
// fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så
// aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og
// holder arket åpent i stedet for å anta suksess.
async function submitShot(result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endMethod: "gps" | "map_tap"
endLat: number
endLng: number
share: boolean
shareText: string
}) {
setSubmitError(null)
setSubmitting(true)
try {
const res = await fetch(urlBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
club: result.club,
distance_meters: result.distanceMeters,
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_method: result.endMethod,
end_lat: result.endLat,
end_lng: result.endLng,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const detail = body?.detail
const message =
typeof detail === "string"
? detail
: typeof detail?.message === "string"
? detail.message
: "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen."
setSubmitError(message)
return
}
let shot = await res.json()
setShareError(null)
if (result.share) {
const shareRes = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
// Reell bug funnet 2026-08-08: dette svaret ble aldri sjekket -- en
// avvist deling (f.eks. en driftsfeil) lot slaget lagres som om alt
// gikk bra, ingen feilmelding, ingen melding i feeden. Slaget er
// uansett lagret på dette tidspunktet -- arket lukkes derfor alltid
// her (unngår en duplikat-innsending ved gjentatt "Lagre slag"), og
// en mislykket deling vises separat ved siden av merkelappen i
// stedet for å holde arket åpent.
if (shareRes.ok) {
shot = await shareRes.json()
} else {
setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.")
}
}
setShots((prev) => [...prev, shot])
setOpen(false)
} catch {
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function shareExisting(shot: ShotRecord) {
const res = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: `${shot.club}, ${shot.distance_meters} m hull ${holeNumber}` }),
})
if (res.ok) {
const updated = await res.json()
setShots((prev) => prev.map((s) => (s.id === shot.id ? updated : s)))
setShareError(null)
} else {
setShareError("Delingen feilet. Prøv igjen om litt.")
}
}
if (readOnly) {
// Skrivebeskyttet visning (fullført/eldre runde): fortsatt nyttig å SE
// egne målte slag, bare uten å kunne måle/dele/slette nye.
if (count === 0) return null
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Ruler className="size-3.5 shrink-0" aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
{listOpen && <ShotList shots={shots} onDelete={undefined} onShare={undefined} />}
</div>
)
}
const addLabel = label ? `${label}: Mål et slag` : "Mål et slag"
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<div className="flex items-center gap-1.5">
{count > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSubmitError(null)
setOpen(true)
}}
aria-label={addLabel}
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
count > 0
? "size-11 shrink-0 border-border bg-card text-foreground hover:bg-accent/50"
: variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Plus className={cn(count > 0 ? "size-5" : variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0")} aria-hidden="true" />
{count === 0 && addLabel}
</button>
</div>
{shareError && (
<p role="alert" className="text-xs font-medium text-destructive">
{shareError}
</p>
)}
{listOpen && count > 0 && <ShotList shots={shots} onDelete={deleteShot} onShare={shareExisting} />}
{open && (
<ShotMeasurementSheet
holeNumber={holeNumber}
existingShotCount={count}
ownBagClubs={ownBagClubs}
initialBearing={initialBearing}
submitError={submitError}
submitting={submitting}
onSubmit={submitShot}
onCancel={() => setOpen(false)}
/>
)}
</div>
)
}
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
function shotThumbnailUrl(shot: ShotRecord): string | null {
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
}
function ShotList({
shots,
onDelete,
onShare,
}: {
shots: ShotRecord[]
onDelete?: (shotId: string) => void
onShare?: (shot: ShotRecord) => void
}) {
return (
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
{shots.map((shot) => {
const thumbUrl = shotThumbnailUrl(shot)
return (
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
{thumbUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<img
src={thumbUrl || "/placeholder.svg"}
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
className="size-14 shrink-0 rounded-lg border border-border object-cover"
/>
) : null}
<span className="flex-1 truncate font-medium text-foreground">
{shot.club} <span className="tabular-nums text-muted-foreground"> {shot.distance_meters} m</span>
</span>
{shot.shared_round_message_id ? (
<span className="text-[11px] font-semibold text-primary">Delt</span>
) : onShare ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onShare(shot)
}}
className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
>
Del
</button>
) : null}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(shot.id)
}}
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="size-4" aria-hidden="true" />
</button>
)}
</li>
)
})}
</ul>
)
}
// --- Completed banner ------------------------------------------------------ // --- Completed banner ------------------------------------------------------
function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) { function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) {
@ -7198,9 +6800,10 @@ function PlayerHoleCards({
measureShot={ measureShot={
player.isSelf && !readOnly ? ( player.isSelf && !readOnly ? (
<ShotMeasurementEntry <ShotMeasurementEntry
roundId={roundId} shotsUrl={`/rounds/${roundId}/participants/${player.id}/holes/${currentHole}/shots`}
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
targetPointsUrl={`/rounds/${roundId}/holes/${currentHole}/target-points`}
holeNumber={currentHole} holeNumber={currentHole}
owner={{ kind: "participant", id: player.id }}
ownBagClubs={ownBagClubs} ownBagClubs={ownBagClubs}
readOnly={readOnly} readOnly={readOnly}
variant="badge" variant="badge"

View file

@ -0,0 +1,424 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ChevronDown, Plus, Ruler, Trash2 } from "lucide-react"
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { bearingDegrees } from "@/lib/geo"
import { midpoint } from "@/lib/hole-geometry"
import { cn } from "@/lib/utils"
/**
* Slag-for-slag GPS-avstandsmåling (ADR-048), UI-laget som binder
* `ShotMeasurementSheet`/`MapPointPicker` (begge helt generiske, ingen
* endring) mot et faktisk backend-endepunkt. Generalisert 2026-08-22
* (ADR-103) fra en lokal funksjon i round-detail.tsx til denne delte
* filen -- kalleren bygger URL-ene selv i stedet for at komponenten
* antar `/rounds/{roundId}/...`-formen, slik at BÅDE frittstående
* runder OG org-turneringer (helt ulike REST-stier) kan gjenbruke den
* uendret.
*/
export type ShotRecord = {
id: string
club: string
distance_meters: number
shared: boolean
start_lat: number
start_lng: number
end_lat: number
end_lng: number
}
/** De to backendene navngir delings-lenken ulikt (`shared_round_message_id`
* vs. `shared_tournament_round_message_id`, ulike måltabeller) --
* normaliseres her til ett felles `shared`-flagg, resten av feltene er
* identiske tvers av begge API-kontraktene. */
function normalizeShot(raw: Record<string, unknown>): ShotRecord {
return {
id: raw.id as string,
club: raw.club as string,
distance_meters: raw.distance_meters as number,
shared: Boolean(raw.shared_round_message_id ?? raw.shared_tournament_round_message_id),
start_lat: raw.start_lat as number,
start_lng: raw.start_lng as number,
end_lat: raw.end_lat as number,
end_lng: raw.end_lng as number,
}
}
export type ShotMeasurementEntryProps = {
/** GET (liste) + POST (opprett) -- fullt sti, f.eks.
* `/rounds/{roundId}/participants/{id}/holes/{n}/shots`. */
shotsUrl: string
/** Rundescopet base for DELETE `${x}/${id}` og POST `${x}/${id}/share`
* -- f.eks. `/rounds/{roundId}/shots`. */
shotsRoundBaseUrl: string
/** Banegeometri (utslag->green) for kartretning -- utelates helt for
* en bane uten kjent geometri, faller da tilbake til forrige-slag-
* retning (uendret atferd). */
targetPointsUrl?: string
holeNumber: number
ownBagClubs: string[]
readOnly: boolean
variant: "inline" | "badge"
label?: string
}
export function ShotMeasurementEntry({
shotsUrl,
shotsRoundBaseUrl,
targetPointsUrl,
holeNumber,
ownBagClubs,
readOnly,
variant,
label,
}: ShotMeasurementEntryProps) {
const [open, setOpen] = useState(false)
// Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
// det ikke er delt" -- merkelappen viste tidligere KUN et antall, ingen
// måte å se kølle/avstand for egne, private slag. Lagrer nå hele listen
// (ikke bare .length), viser den i en utvidbar liste, med sletting av et
// feilmålt slag.
const [shots, setShots] = useState<ShotRecord[]>([])
const [listOpen, setListOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [shareError, setShareError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(shotsUrl, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: Record<string, unknown>[]) => {
if (!cancelled) setShots(data.map(normalizeShot))
})
.catch(() => {})
return () => {
cancelled = true
}
}, [shotsUrl])
const count = shots.length
// Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
// utslags- OG green-koordinater kjent (samme datakilde som
// HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
// følge utslag->green, ikke gjettes fra spillerens forrige slag (som
// kan avvike, f.eks. en slice/hook).
const [courseBearing, setCourseBearing] = useState<number | undefined>(undefined)
useEffect(() => {
let cancelled = false
setCourseBearing(undefined)
if (!targetPointsUrl) return
fetch(targetPointsUrl, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => {
if (cancelled) return
const middle = points.find((p) => p.poi_type === "green" && p.location === "middle")
const teeFront = points.find((p) => p.poi_type === "tee_front")
const teeBack = points.find((p) => p.poi_type === "tee_back")
const teeRef =
teeFront && teeBack
? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude })
: teeFront
? { lat: teeFront.latitude, lng: teeFront.longitude }
: teeBack
? { lat: teeBack.latitude, lng: teeBack.longitude }
: null
setCourseBearing(
teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined,
)
})
.catch(() => {
if (!cancelled) setCourseBearing(undefined)
})
return () => {
cancelled = true
}
}, [targetPointsUrl])
// Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet
// 2026-08-21): "opp på skjermen" følger banens EGEN spillretning
// (utslag->green, courseBearing over) når den er kjent -- ellers
// faller tilbake til spillerens EGEN gangretning, regnet ut fra det
// SISTE tidligere målte slaget på dette hullet (start→slutt-retningen).
// `undefined` (ingen rotasjon regnet ut ennå) for det aller første
// slaget på et hull uten kjent banegeometri -- MapPointPicker faller
// da selv tilbake til enhetens GPS-heading/nord.
const initialBearing = useMemo(() => {
if (typeof courseBearing === "number") return courseBearing
const previous = shots[shots.length - 1]
if (!previous) return undefined
return bearingDegrees(
{ lat: previous.start_lat, lng: previous.start_lng },
{ lat: previous.end_lat, lng: previous.end_lng },
)
}, [shots, courseBearing])
async function deleteShot(shotId: string) {
const res = await fetch(`${shotsRoundBaseUrl}/${shotId}`, { method: "DELETE", credentials: "include" })
if (res.ok) {
setShots((prev) => prev.filter((s) => s.id !== shotId))
}
}
// Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
// fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så
// aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og
// holder arket åpent i stedet for å anta suksess.
async function submitShot(result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endMethod: "gps" | "map_tap"
endLat: number
endLng: number
share: boolean
shareText: string
}) {
setSubmitError(null)
setSubmitting(true)
try {
const res = await fetch(shotsUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
club: result.club,
distance_meters: result.distanceMeters,
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_method: result.endMethod,
end_lat: result.endLat,
end_lng: result.endLng,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const detail = body?.detail
const message =
typeof detail === "string"
? detail
: typeof detail?.message === "string"
? detail.message
: "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen."
setSubmitError(message)
return
}
let shot = normalizeShot(await res.json())
setShareError(null)
if (result.share) {
const shareRes = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
// Reell bug funnet 2026-08-08: dette svaret ble aldri sjekket -- en
// avvist deling (f.eks. en driftsfeil) lot slaget lagres som om alt
// gikk bra, ingen feilmelding, ingen melding i feeden. Slaget er
// uansett lagret på dette tidspunktet -- arket lukkes derfor alltid
// her (unngår en duplikat-innsending ved gjentatt "Lagre slag"), og
// en mislykket deling vises separat ved siden av merkelappen i
// stedet for å holde arket åpent.
if (shareRes.ok) {
shot = normalizeShot(await shareRes.json())
} else {
setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.")
}
}
setShots((prev) => [...prev, shot])
setOpen(false)
} catch {
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function shareExisting(shot: ShotRecord) {
const res = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: `${shot.club}, ${shot.distance_meters} m hull ${holeNumber}` }),
})
if (res.ok) {
const updated = normalizeShot(await res.json())
setShots((prev) => prev.map((s) => (s.id === shot.id ? updated : s)))
setShareError(null)
} else {
setShareError("Delingen feilet. Prøv igjen om litt.")
}
}
if (readOnly) {
// Skrivebeskyttet visning (fullført/eldre runde): fortsatt nyttig å SE
// egne målte slag, bare uten å kunne måle/dele/slette nye.
if (count === 0) return null
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Ruler className="size-3.5 shrink-0" aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
{listOpen && <ShotList shots={shots} onDelete={undefined} onShare={undefined} />}
</div>
)
}
const addLabel = label ? `${label}: Mål et slag` : "Mål et slag"
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<div className="flex items-center gap-1.5">
{count > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSubmitError(null)
setOpen(true)
}}
aria-label={addLabel}
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
count > 0
? "size-11 shrink-0 border-border bg-card text-foreground hover:bg-accent/50"
: variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Plus className={cn(count > 0 ? "size-5" : variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0")} aria-hidden="true" />
{count === 0 && addLabel}
</button>
</div>
{shareError && (
<p role="alert" className="text-xs font-medium text-destructive">
{shareError}
</p>
)}
{listOpen && count > 0 && <ShotList shots={shots} onDelete={deleteShot} onShare={shareExisting} />}
{open && (
<ShotMeasurementSheet
holeNumber={holeNumber}
existingShotCount={count}
ownBagClubs={ownBagClubs}
initialBearing={initialBearing}
submitError={submitError}
submitting={submitting}
onSubmit={submitShot}
onCancel={() => setOpen(false)}
/>
)}
</div>
)
}
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
function shotThumbnailUrl(shot: ShotRecord): string | null {
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
}
function ShotList({
shots,
onDelete,
onShare,
}: {
shots: ShotRecord[]
onDelete?: (shotId: string) => void
onShare?: (shot: ShotRecord) => void
}) {
return (
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
{shots.map((shot) => {
const thumbUrl = shotThumbnailUrl(shot)
return (
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
{thumbUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<img
src={thumbUrl || "/placeholder.svg"}
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
className="size-14 shrink-0 rounded-lg border border-border object-cover"
/>
) : null}
<span className="flex-1 truncate font-medium text-foreground">
{shot.club} <span className="tabular-nums text-muted-foreground"> {shot.distance_meters} m</span>
</span>
{shot.shared ? (
<span className="text-[11px] font-semibold text-primary">Delt</span>
) : onShare ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onShare(shot)
}}
className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
>
Del
</button>
) : null}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(shot.id)
}}
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="size-4" aria-hidden="true" />
</button>
)}
</li>
)
})}
</ul>
)
}

View file

@ -0,0 +1,264 @@
"""
Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103). Speiler
ADR-048s round_shot-autorisasjon (self ELLER org-admin, samme klasse som
update_hole), men org-scopet (RLS) og FK-et til
tournament_round_participant + hole_number (ikke tournament_round_hole_id
-- den raden finnes kanskje ikke ennå).
"""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.tournament_round_shots import (
ShotIn,
ShotShareIn,
create_tournament_round_shot,
delete_tournament_round_shot,
list_tournament_round_shots,
share_tournament_round_shot,
)
from tests.conftest import (
add_membership,
create_course,
create_org,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
_SHOT = ShotIn(
club="Driver",
distance_meters=210.5,
start_method="gps",
start_lat=59.1,
start_lng=10.5,
end_method="gps",
end_lat=59.101,
end_lng=10.501,
)
async def _setup(org_id):
tournament_id = await create_tournament(org_id, name="Slagmåling-turnering")
course_id = await create_course(org_id, name="Slagmålingsbanen")
tee_id = await create_tee(org_id, course_id, name="Gul")
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, round_id, tee_id
async def _make_participant(org_id, tournament_id, round_id, tee_id, user_id):
player_id = await create_player(org_id, display_name="Slag Testesen", user_id=user_id)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
return rp_id
async def test_create_and_list_shot(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT,
organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert created.shot_number == 1
assert created.club == "Driver"
assert created.shared_tournament_round_message_id is None
listed = await list_tournament_round_shots(
tournament_id, round_id, rp_id, 1, organization_id=org_id,
)
assert [s.id for s in listed] == [created.id]
async def test_shot_number_increments_per_hole(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
first = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
second = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert first.shot_number == 1
assert second.shot_number == 2
# Et annet hull starter på nytt fra 1.
other_hole = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 2, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert other_hole.shot_number == 1
async def test_can_measure_before_hole_score_exists(pool):
"""Kjernebegrunnelsen for datamodellen: tournament_round_hole
opprettes FØRST ved score-innsending -- måling fungere FØR det."""
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
# Ingen tournament_round_hole-rad finnes -- måling skal likevel virke.
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 5, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert created.shot_number == 1
async def test_non_self_non_admin_rejected(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
other_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, other_id, role="member")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
with pytest.raises(HTTPException) as exc_info:
await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=other_id),
)
assert exc_info.value.status_code == 403
async def test_org_admin_can_measure_for_others(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
admin_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, admin_id, role="admin")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=admin_id),
)
assert created.shot_number == 1
async def test_delete_self_only(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
other_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, other_id, role="member")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=owner_id),
)
with pytest.raises(HTTPException) as exc_info:
await delete_tournament_round_shot(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=other_id),
)
assert exc_info.value.status_code == 403
await delete_tournament_round_shot(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=owner_id),
)
listed = await list_tournament_round_shots(tournament_id, round_id, rp_id, 1, organization_id=org_id)
assert listed == []
def test_invalid_distance_rejected():
with pytest.raises(Exception):
ShotIn(
club="Driver", distance_meters=0, start_method="gps",
start_lat=59.1, start_lng=10.5, end_lat=59.101, end_lng=10.501,
)
async def test_wrong_tournament_id_404(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
other_tournament_id = await create_tournament(org_id, name="En annen turnering")
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
with pytest.raises(HTTPException) as exc_info:
await create_tournament_round_shot(
other_tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert exc_info.value.status_code == 404
async def test_rls_blocks_cross_org_access(pool):
org_a = await create_org()
org_b = await create_org()
tournament_id, round_id, tee_id = await _setup(org_a)
user_id = await create_user()
await add_membership(org_a, user_id)
await add_membership(org_b, user_id)
rp_id = await _make_participant(org_a, tournament_id, round_id, tee_id, user_id)
await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_a, user=CurrentUser(user_id=user_id),
)
with pytest.raises(HTTPException) as exc_info:
await list_tournament_round_shots(tournament_id, round_id, rp_id, 1, organization_id=org_b)
assert exc_info.value.status_code == 404
async def test_share_creates_tournament_round_message(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert created.shared_tournament_round_message_id is None
shared = await share_tournament_round_shot(
tournament_id, round_id, created.id, ShotShareIn(body="⛳ Driver, 210.5 m hull 1"),
organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert shared.shared_tournament_round_message_id is not None
# Kommentartråden (ADR-102) skal nå ha en ekte melding.
from app.routers.tournament_round_messages import list_tournament_round_messages
messages = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert any(m.id == shared.shared_tournament_round_message_id for m in messages)
assert any(m.body == "⛳ Driver, 210.5 m hull 1" for m in messages)
async def test_share_rejected_for_non_owner(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
other_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, other_id, role="member")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=owner_id),
)
with pytest.raises(HTTPException) as exc_info:
await share_tournament_round_shot(
tournament_id, round_id, created.id, ShotShareIn(body="Test"),
organization_id=org_id, user=CurrentUser(user_id=other_id),
)
assert exc_info.value.status_code == 403