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:
parent
bd965ba2ed
commit
885dcac4e5
10 changed files with 1273 additions and 414 deletions
75
091_tournament_round_shot.sql
Normal file
75
091_tournament_round_shot.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
52
CHANGELOG.md
52
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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
327
app/routers/tournament_round_shots.py
Normal file
327
app/routers/tournament_round_shots.py
Normal 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), 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)
|
||||
|
|
@ -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. */}
|
||||
<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">
|
||||
{hole.par !== 3 && (
|
||||
<DirectionCross
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import {
|
|||
MapPin,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Search,
|
||||
Settings2,
|
||||
Trophy,
|
||||
|
|
@ -56,11 +55,9 @@ import {
|
|||
Stepper,
|
||||
WizardSection,
|
||||
} 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 { 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"
|
||||
|
||||
// --- Types -----------------------------------------------------------------
|
||||
|
|
@ -1805,9 +1802,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
{round.sides.map((s, i) => (
|
||||
<ShotMeasurementEntry
|
||||
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}
|
||||
owner={{ kind: "side", id: s.id }}
|
||||
ownBagClubs={ownBagClubs}
|
||||
readOnly={readOnly}
|
||||
variant="badge"
|
||||
|
|
@ -2285,9 +2283,10 @@ function ScoringWizard({
|
|||
/>
|
||||
|
||||
<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}
|
||||
owner={{ kind: "participant", id: player.id }}
|
||||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||||
readOnly={readOnly}
|
||||
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 ------------------------------------------------------
|
||||
|
||||
function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) {
|
||||
|
|
@ -7198,9 +6800,10 @@ function PlayerHoleCards({
|
|||
measureShot={
|
||||
player.isSelf && !readOnly ? (
|
||||
<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}
|
||||
owner={{ kind: "participant", id: player.id }}
|
||||
ownBagClubs={ownBagClubs}
|
||||
readOnly={readOnly}
|
||||
variant="badge"
|
||||
|
|
|
|||
424
frontend/components/shot/shot-measurement-entry.tsx
Normal file
424
frontend/components/shot/shot-measurement-entry.tsx
Normal 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 nå 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 på 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>
|
||||
)
|
||||
}
|
||||
264
tests/test_tournament_round_shots.py
Normal file
264
tests/test_tournament_round_shots.py
Normal 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 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
|
||||
Loading…
Reference in a new issue