From 885dcac4e52cbdb1b2754f10aea89ec8d7a5899e Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 22 Aug 2026 07:16:14 +0200 Subject: [PATCH] =?UTF-8?q?Slag-for-slag=20GPS-avstandsm=C3=A5ling=20for?= =?UTF-8?q?=20org-turneringer=20(ADR-103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 091_tournament_round_shot.sql | 75 ++++ ARCHITECTURE_DECISIONS.md | 96 ++++ CHANGELOG.md | 52 +++ FEATURE_BACKLOG.md | 16 +- app/main.py | 2 + app/routers/tournament_round_shots.py | 327 ++++++++++++++ .../individual-tournament-detail.tsx | 14 + frontend/components/round-detail.tsx | 417 +---------------- .../shot/shot-measurement-entry.tsx | 424 ++++++++++++++++++ tests/test_tournament_round_shots.py | 264 +++++++++++ 10 files changed, 1273 insertions(+), 414 deletions(-) create mode 100644 091_tournament_round_shot.sql create mode 100644 app/routers/tournament_round_shots.py create mode 100644 frontend/components/shot/shot-measurement-entry.tsx create mode 100644 tests/test_tournament_round_shots.py diff --git a/091_tournament_round_shot.sql b/091_tournament_round_shot.sql new file mode 100644 index 0000000..b6ad7a8 --- /dev/null +++ b/091_tournament_round_shot.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index b9e28b8..c814837 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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 ``). +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) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4f93e..2fb61b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14459,3 +14459,55 @@ Neste steg: compose build teecup_api teecup_frontend && up -d`, begge containere friske, rene logger. `https://teecup.golf/health` 200 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`). diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index cb70e3a..37ae243 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -4984,13 +4984,15 @@ runde HAR, org-turnering MANGLER, om ikke annet er sagt):** har ingen tilsvarende kolonne, `TournamentScoringWizard` ingen tilsvarende knapp. Antatt **middels** -- ny migrasjon + backend-speil av eksisterende capping-logikk (`max_hole_score_for_handicap`). -6. **Slag-for-slag GPS-avstandsmåling (skuddlengde + kølle) kun i - frittstående runder.** `round_shot`-tabellen/`ShotMeasurementEntry` - (ADR-048) har ingen tilsvarende i `individual_tournaments.py`/ - `individual-tournament-detail.tsx`. IKKE forveksle med rangefinder - eller Flagg-GPS (begge allerede bygget begge steder, se over). Antatt - **middels/stor** -- `round_shot`s eierskap er i dag kablet til - frittstående-runde-XOR-en (ADR-048 Beslutning C), ekte backend-arbeid. +6. ✅ **Slag-for-slag GPS-avstandsmåling -- FERDIG BYGGET 2026-08-22 + (ADR-103), se CHANGELOG punkt 161.** Ny `tournament_round_shot`- + tabell (org-scopet, RLS, FK-et til tournament_round_participant + + hole_number). `ShotMeasurementSheet`/`MapPointPicker` gjenbrukt + uendret; `ShotMeasurementEntry` generalisert til delt fil. Deling + går til den nye kommentartråden (punkt 7/ADR-102). Verifisert i + 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 2026-08-22 (ADR-102), se CHANGELOG punkt 160.** Ny `tournament_round_message`-tabell (org-scopet, RLS), én tråd per diff --git a/app/main.py b/app/main.py index e5b737b..a6e2b7d 100644 --- a/app/main.py +++ b/app/main.py @@ -33,6 +33,7 @@ from .routers import ( rounds, scoring, tournament_round_messages, + tournament_round_shots, tournaments, ) from . import storage @@ -68,6 +69,7 @@ app.include_router(messaging.public_router) app.include_router(rounds.router) app.include_router(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(notifications.router) app.include_router(print_pdf.router) diff --git a/app/routers/tournament_round_shots.py b/app/routers/tournament_round_shots.py new file mode 100644 index 0000000..6ec4368 --- /dev/null +++ b/app/routers/tournament_round_shots.py @@ -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), så 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`), så en spiller må 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) diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 43f0f9b..b3effaf 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -67,6 +67,7 @@ import { } from "@/components/hole-stat-inputs" import { HoleHistoryDetail } from "@/components/hole-history-detail" import { HoleTargetDistance } from "@/components/hole-target-distance" +import { ShotMeasurementEntry } from "@/components/shot/shot-measurement-entry" import { ClubPicker } from "@/components/teecup/club-picker" import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" @@ -4783,6 +4784,19 @@ function TournamentScoringWizard({ readOnly={false} /> + {/* Slag-for-slag GPS-avstandsmåling (ADR-103) -- samme + plassering som round-detail.tsx sitt "direction"-steg, + rett under kølle-valget. */} + + {hole.par !== 3 && ( ( ([]) - const [listOpen, setListOpen] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [submitError, setSubmitError] = useState(null) - const [shareError, setShareError] = useState(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(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 ( -
- - {listOpen && } -
- ) - } - - const addLabel = label ? `${label}: Mål et slag` : "Mål et slag" - - return ( -
-
- {count > 0 && ( - - )} - -
- - {shareError && ( -

- {shareError} -

- )} - - {listOpen && count > 0 && } - - {open && ( - setOpen(false)} - /> - )} -
- ) -} - -// 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 ( -
    - {shots.map((shot) => { - const thumbUrl = shotThumbnailUrl(shot) - return ( -
  • - {thumbUrl ? ( - // eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt - {`Satellittutsnitt - ) : null} - - {shot.club} — {shot.distance_meters} m - - {shot.shared_round_message_id ? ( - Delt - ) : onShare ? ( - - ) : null} - {onDelete && ( - - )} -
  • - ) - })} -
- ) -} - // --- Completed banner ------------------------------------------------------ function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) { @@ -7198,9 +6800,10 @@ function PlayerHoleCards({ measureShot={ player.isSelf && !readOnly ? ( ): 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([]) + const [listOpen, setListOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [shareError, setShareError] = useState(null) + useEffect(() => { + let cancelled = false + fetch(shotsUrl, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: Record[]) => { + 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(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 ( +
+ + {listOpen && } +
+ ) + } + + const addLabel = label ? `${label}: Mål et slag` : "Mål et slag" + + return ( +
+
+ {count > 0 && ( + + )} + +
+ + {shareError && ( +

+ {shareError} +

+ )} + + {listOpen && count > 0 && } + + {open && ( + setOpen(false)} + /> + )} +
+ ) +} + +// 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 ( +
    + {shots.map((shot) => { + const thumbUrl = shotThumbnailUrl(shot) + return ( +
  • + {thumbUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt + {`Satellittutsnitt + ) : null} + + {shot.club} — {shot.distance_meters} m + + {shot.shared ? ( + Delt + ) : onShare ? ( + + ) : null} + {onDelete && ( + + )} +
  • + ) + })} +
+ ) +} diff --git a/tests/test_tournament_round_shots.py b/tests/test_tournament_round_shots.py new file mode 100644 index 0000000..c1106a0 --- /dev/null +++ b/tests/test_tournament_round_shots.py @@ -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 må 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