Før koordinater-grensesnitt.

This commit is contained in:
Erol Haagenrud 2026-09-01 05:11:26 +02:00
parent 1da3e32a8a
commit 8df889cc00
7 changed files with 154 additions and 1 deletions

View file

@ -9,7 +9,7 @@ LOV: Aldri trunker eller slett logikk for "effektivitet".
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
""" """
from fastapi import FastAPI, HTTPException, Response, Request, Query from fastapi import FastAPI, HTTPException, Response, Request, Query, Header, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -96,6 +96,16 @@ FRONTEND_REVALIDATE_URL = os.getenv(
).strip() ).strip()
FRONTEND_REVALIDATE_SECRET = os.getenv("FRONTEND_REVALIDATE_SECRET", PUBLIC_SESSION_SECRET).strip() FRONTEND_REVALIDATE_SECRET = os.getenv("FRONTEND_REVALIDATE_SECRET", PUBLIC_SESSION_SECRET).strip()
# Dedikert delt nøkkel for TeeCups server-til-server-tilgang til
# hole-points-API-et (ingen fallback til andre secrets -- egen
# sikkerhetsdomene, se docs/oppgaver.md om secret-gjenbruk).
TEECUP_API_KEY = os.getenv("TEECUP_API_KEY", "").strip()
async def require_teecup_api_key(x_api_key: str = Header(default="")) -> None:
if not TEECUP_API_KEY or not secrets.compare_digest(x_api_key, TEECUP_API_KEY):
raise HTTPException(status_code=401, detail="Ugyldig eller manglende API-nøkkel")
PUBLIC_FACILITIES_CACHE_TTLS = { PUBLIC_FACILITIES_CACHE_TTLS = {
"search": 900, "search": 900,
"home": 900, "home": 900,
@ -5006,6 +5016,84 @@ async def get_facility(slug: str, response: Response):
return payload return payload
@app.get("/api/hole-points/poi-types", dependencies=[Depends(require_teecup_api_key)])
async def get_hole_points_poi_types():
"""
Levende (ikke statisk) liste over alle poi_type-verdier som faktisk finnes
i hole_points akkurat , med antall forekomster. Til bruk for eksterne
konsumenter (f.eks. TeeCup) som vil kunne oppdage nye kategorier automatisk
i stedet for at en oversettelse knekker stille når en ny type dukker opp.
"""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT poi_type, count(*) AS count FROM hole_points GROUP BY poi_type ORDER BY poi_type"
)
return [{"poi_type": row["poi_type"], "count": int(row["count"])} for row in rows]
@app.get("/api/facilities/{slug}/courses/{course_id}/hole-points", dependencies=[Depends(require_teecup_api_key)])
async def get_course_hole_points(slug: str, course_id: int):
"""
Flat liste med koordinatpunkter per hull (green/bunker/vann/tre/tee osv.)
for én bane, til bruk for eksterne prosjekter som slår opp banen via
facility-slug (f.eks. TeeCup sin GPS-/avstandsfunksjon). Taksonomien er
dokumentert i migrations/2026-08-05_add_hole_points.sql og
migrations/2026-08-06_add_hole_points_distance_meters.sql.
"""
normalized_slug = str(slug or "").strip().lower()
async with app.state.pool.acquire() as conn:
course_row = await conn.fetchrow(
"""
SELECT c.id
FROM courses c
JOIN facilities f ON f.id = c.facility_id
WHERE f.slug = $1
AND f.is_published IS DISTINCT FROM FALSE
AND c.id = $2
AND COALESCE(c.is_visible, TRUE) = TRUE
""",
normalized_slug,
course_id,
)
if not course_row:
raise HTTPException(status_code=404, detail="Banen ble ikke funnet på dette anlegget")
point_rows = await conn.fetch(
"""
SELECT
h.hole_number,
hp.poi_type,
hp.location,
hp.side_fairway,
ST_Y(hp.geom::geometry) AS latitude,
ST_X(hp.geom::geometry) AS longitude,
hp.feature_group,
hp.distance_meters,
hp.note
FROM hole_points hp
JOIN holes h ON h.id = hp.hole_id
WHERE h.course_id = $1
ORDER BY h.hole_number ASC, hp.id ASC
""",
course_id,
)
return [
{
"hole_number": int(row["hole_number"]),
"poi_type": row["poi_type"],
"location": row["location"],
"side_fairway": row["side_fairway"],
"latitude": row["latitude"],
"longitude": row["longitude"],
"feature_group": row["feature_group"],
"distance_meters": row["distance_meters"],
"note": row["note"],
}
for row in point_rows
]
@app.get("/api/place-pages/{slug}") @app.get("/api/place-pages/{slug}")
async def get_place_page(slug: str, response: Response): async def get_place_page(slug: str, response: Response):
normalized_slug = str(slug or "").strip().lower() normalized_slug = str(slug or "").strip().lower()

View file

@ -21,6 +21,26 @@ teecup.golf, www.teecup.golf {
format console format console
} }
# Sikkerhetshoder (sikkerhetsgjennomgang 2026-08-09, se
# ARCHITECTURE_DECISIONS.md-tillegg). CSP-en er bevisst IKKE en streng
# nonce-basert policy -- appen bruker inline `style`-attributter mange
# steder (React `style={{...}}`, bl.a. den "clubhouse"-pallet-tonede
# UI-en) og Next.js sin egen hydrerings-oppstart kan trenge inline
# script, så 'unsafe-inline' er beholdt for script-src/style-src for å
# unngå å knekke appen blindt. Verdien ligger likevel i det som ER
# strammet inn: object-src/frame-ancestors/base-uri blokkerer hele
# klasser av angrep, og connect-src/img-src er begrenset til KUN de
# eksterne vertene appen faktisk bruker (Mapbox for slagmåling-kartet,
# ADR-048) -- en fremtidig XSS-bug kan dermed ikke enkelt eksfiltrere
# data til en vilkårlig tredjeparts-vert.
header {
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.mapbox.com; connect-src 'self' https://api.mapbox.com https://events.mapbox.com wss://teecup.golf; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
}
# Offentlig, anonym lesing av opplastede bilder (MinIO, public-read # Offentlig, anonym lesing av opplastede bilder (MinIO, public-read
# bucket-policy) -- bucket-navnet ligger i selve stien (path-style # bucket-policy) -- bucket-navnet ligger i selve stien (path-style
# adressering), derav /teecup-media/* som prefiks, ikke et eget # adressering), derav /teecup-media/* som prefiks, ikke et eget

View file

@ -28,6 +28,7 @@ services:
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
INDEXNOW_KEY: ${INDEXNOW_KEY} INDEXNOW_KEY: ${INDEXNOW_KEY}
TEECUP_API_KEY: ${TEECUP_API_KEY}
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./frontend/src/content:/shared/frontend-content:ro - ./frontend/src/content:/shared/frontend-content:ro

View file

@ -30,6 +30,7 @@ services:
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
INDEXNOW_KEY: ${INDEXNOW_KEY} INDEXNOW_KEY: ${INDEXNOW_KEY}
TEECUP_API_KEY: ${TEECUP_API_KEY}
ports: ports:
- "8001:8000" - "8001:8000"
volumes: volumes:

View file

@ -0,0 +1,27 @@
-- Koordinat-punkter per hull (green/bunker/vann/tre/tee osv.), for GPS-/
-- avstandsfunksjoner og for å kunne eksporteres til andre prosjekter
-- (f.eks. TeeCup) som slår opp anlegg via facility-slug.
--
-- Taksonomi (poi_type / location / side_fairway) er bevisst holdt som fri
-- tekst, ikke en Postgres ENUM, i tråd med resten av kodebasens mønster
-- for kategori-felt (f.eks. banetype) -- listen med forventede verdier
-- dokumenteres her, ikke håndheves i databasen:
--
-- poi_type: green, bunker_fairway, bunker_green, vann, trær, stein,
-- landemerke, vei, dogleg, tee, avstandsmarkør
-- location: front, midt, bak (nullable -- ikke alle poi_type har dette)
-- side_fairway: venstre, senter, høyre (nullable -- ikke alle poi_type har dette)
CREATE TABLE IF NOT EXISTS hole_points (
id SERIAL PRIMARY KEY,
hole_id INTEGER NOT NULL REFERENCES holes(id) ON DELETE CASCADE,
poi_type VARCHAR(50) NOT NULL,
location VARCHAR(20),
side_fairway VARCHAR(20),
geom GEOGRAPHY(Point, 4326) NOT NULL,
note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS hole_points_hole_id_idx ON hole_points (hole_id);
CREATE INDEX IF NOT EXISTS hole_points_geom_gix ON hole_points USING GIST (geom);

View file

@ -0,0 +1,7 @@
-- Valgfri avstandsverdi for punkter som representerer en fysisk avstandsmarkør
-- (f.eks. en 100/150/200-meter-plate). NULL for alle andre poi_type, og NULL
-- for eksisterende avstandsmarkør-punkter -- disse representerer i dag "start
-- av fairway (landingssone)", ikke en tallmerket avstandsplate, så det finnes
-- ingen kildedata å fylle inn ennå.
ALTER TABLE hole_points ADD COLUMN IF NOT EXISTS distance_meters INTEGER;

View file

@ -0,0 +1,9 @@
-- Grupperer forkant/bakkant-punkt (og andre par) som hører til samme
-- fysiske objekt, f.eks. hvilken "forkant" og "bakkant" som utgjør én og
-- samme fairwaybunker når et hull har flere bunkere av samme type/side.
-- Tallverdien er meningsfull kun innenfor ett hull (hole_id), ikke globalt
-- -- kildedata er kildearkets egen "ID"-kolonne per hull, videreført as-is.
-- NULL betyr at punktet er entydig alene på hullet (f.eks. Tee, Green,
-- Dogleg) og ikke trenger gruppering.
ALTER TABLE hole_points ADD COLUMN IF NOT EXISTS feature_group INTEGER;