Før fase to i effektiviseringen
This commit is contained in:
parent
dc7ed19f02
commit
a508db6071
15 changed files with 548 additions and 259 deletions
599
backend/main.py
599
backend/main.py
|
|
@ -22,6 +22,7 @@ import re
|
|||
import secrets
|
||||
import hashlib
|
||||
import smtplib
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime, date, timedelta
|
||||
from email.message import EmailMessage
|
||||
|
|
@ -86,6 +87,20 @@ INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip()
|
|||
INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip()
|
||||
INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").strip()
|
||||
|
||||
PUBLIC_FACILITIES_CACHE_TTLS = {
|
||||
"search": 900,
|
||||
"home": 900,
|
||||
"place": 3600,
|
||||
"membership": 1800,
|
||||
"vtg": 1800,
|
||||
"clubnumbers": 3600,
|
||||
"sitemap": 3600,
|
||||
"aliases": 3600,
|
||||
"default": 300,
|
||||
}
|
||||
PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS = 900
|
||||
PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
|
||||
|
|
@ -127,6 +142,62 @@ def get_public_auth_config() -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def initialize_public_api_caches() -> None:
|
||||
app.state.public_facilities_cache = {}
|
||||
app.state.public_facility_detail_cache = {}
|
||||
app.state.public_place_page_cache = {}
|
||||
|
||||
|
||||
def get_public_facilities_cache_ttl(view: str | None) -> int:
|
||||
normalized_view = (view or "").strip().lower()
|
||||
if normalized_view in PUBLIC_FACILITIES_CACHE_TTLS:
|
||||
return PUBLIC_FACILITIES_CACHE_TTLS[normalized_view]
|
||||
return PUBLIC_FACILITIES_CACHE_TTLS["default"]
|
||||
|
||||
|
||||
def read_public_cache_entry(cache_store: dict[str, tuple[float, Any]], cache_key: str) -> Any | None:
|
||||
entry = cache_store.get(cache_key)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
expires_at, payload = entry
|
||||
if expires_at <= time.monotonic():
|
||||
cache_store.pop(cache_key, None)
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def write_public_cache_entry(
|
||||
cache_store: dict[str, tuple[float, Any]],
|
||||
cache_key: str,
|
||||
payload: Any,
|
||||
ttl_seconds: int,
|
||||
) -> Any:
|
||||
cache_store[cache_key] = (time.monotonic() + max(1, ttl_seconds), payload)
|
||||
return payload
|
||||
|
||||
|
||||
def apply_public_cache_headers(response: Response, ttl_seconds: int) -> None:
|
||||
ttl = max(60, int(ttl_seconds))
|
||||
response.headers["Cache-Control"] = f"public, max-age=60, s-maxage={ttl}, stale-while-revalidate=60"
|
||||
|
||||
|
||||
def invalidate_public_api_caches(*, include_place_pages: bool = False) -> None:
|
||||
facilities_cache = getattr(app.state, "public_facilities_cache", None)
|
||||
if isinstance(facilities_cache, dict):
|
||||
facilities_cache.clear()
|
||||
|
||||
detail_cache = getattr(app.state, "public_facility_detail_cache", None)
|
||||
if isinstance(detail_cache, dict):
|
||||
detail_cache.clear()
|
||||
|
||||
if include_place_pages:
|
||||
place_page_cache = getattr(app.state, "public_place_page_cache", None)
|
||||
if isinstance(place_page_cache, dict):
|
||||
place_page_cache.clear()
|
||||
|
||||
|
||||
def get_configured_public_base_url() -> str:
|
||||
for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"):
|
||||
configured = os.getenv(env_name, "").strip().rstrip("/")
|
||||
|
|
@ -723,6 +794,43 @@ FACILITY_VTG_FIELDS = FACILITY_VTG_CONTENT_FIELDS | FACILITY_VTG_COURSE_FIELDS |
|
|||
'vtg_content_updated_at',
|
||||
'vtg_courses_updated_at',
|
||||
}
|
||||
NON_PUBLIC_FACILITY_FIELDS = {
|
||||
'membership_draft',
|
||||
'greenfee_draft',
|
||||
'vtg_draft',
|
||||
'vtg_content_draft',
|
||||
'vtg_courses_draft',
|
||||
'golfpakker_draft',
|
||||
'scrape_method',
|
||||
'scrape_status_url',
|
||||
'scrape_status_selector',
|
||||
'ai_instruction',
|
||||
}
|
||||
|
||||
FACILITY_VIEW_SEARCH_FIELDS = {
|
||||
'id', 'slug', 'name', 'architect', 'description', 'city', 'county', 'banetype',
|
||||
'image_url', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
|
||||
'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker',
|
||||
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'footnote', 'footnote_updated_at',
|
||||
'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer',
|
||||
'course_statuses', 'weather_forecast',
|
||||
}
|
||||
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
|
||||
'greenfee', 'standard_medlemskap', 'total_hole_count', 'hole_par_counts',
|
||||
'shortest_hole_meters', 'longest_hole_meters',
|
||||
}
|
||||
FACILITY_VIEW_MEMBERSHIP_FIELDS = {
|
||||
'id', 'slug', 'name', 'city', 'county', 'medlemskap_url', 'membership_updated_at',
|
||||
'standard_medlemskap_kommentarer', 'navn_standard_medlemskap', 'standard_medlemskap',
|
||||
'navn_rimeligste_alternativ', 'rimeligste_alternativ',
|
||||
}
|
||||
FACILITY_VIEW_VTG_FIELDS = {
|
||||
'id', 'slug', 'name', 'city', 'county', 'lat', 'lng', 'status_updated_at',
|
||||
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'vtg_datoer', 'vtg_updated_at',
|
||||
}
|
||||
FACILITY_VIEW_CLUBNUMBERS_FIELDS = {'id', 'slug', 'name', 'city', 'county', 'ngf_number'}
|
||||
FACILITY_VIEW_SITEMAP_FIELDS = {'slug', 'status_updated_at', 'vtg_updated_at'}
|
||||
FACILITY_VIEW_ALIASES_FIELDS = {'slug', 'name'}
|
||||
# --- FUNKSJONER ---
|
||||
def format_row(row):
|
||||
"""
|
||||
|
|
@ -784,6 +892,24 @@ def format_row(row):
|
|||
return d
|
||||
|
||||
|
||||
def sanitize_public_facility_row(row: Any, *, fields: set[str] | None = None) -> dict[str, Any] | None:
|
||||
formatted = format_row(row)
|
||||
if formatted is None:
|
||||
return None
|
||||
|
||||
for field in NON_PUBLIC_FACILITY_FIELDS:
|
||||
formatted.pop(field, None)
|
||||
|
||||
if fields is not None:
|
||||
formatted = {
|
||||
key: value
|
||||
for key, value in formatted.items()
|
||||
if key in fields
|
||||
}
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
def prepare_vtg_content_draft_payload(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
|
|
@ -2097,6 +2223,7 @@ async def lifespan(app: FastAPI):
|
|||
weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event)
|
||||
)
|
||||
app.state.contact_submission_tracker = {}
|
||||
initialize_public_api_caches()
|
||||
print("✅ Database tilkoblet og pool opprettet")
|
||||
except Exception as e:
|
||||
print(f"❌ Databasefeil under oppstart: {e}")
|
||||
|
|
@ -2768,12 +2895,108 @@ async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsert
|
|||
|
||||
# --- DATA ENDPOINTS ---
|
||||
|
||||
@app.get("/api/facilities")
|
||||
async def get_facilities(summary: bool = False):
|
||||
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
if summary:
|
||||
rows = await conn.fetch("""
|
||||
def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]:
|
||||
normalized_view = (view or "").strip().lower()
|
||||
|
||||
course_statuses_sql = """
|
||||
(
|
||||
SELECT jsonb_agg(cs) FROM (
|
||||
SELECT id, name, status FROM courses
|
||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
||||
ORDER BY is_main_course DESC, id ASC
|
||||
) cs
|
||||
) as course_statuses
|
||||
"""
|
||||
weather_compact_sql = """
|
||||
(
|
||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||
SELECT
|
||||
day_offset,
|
||||
dry_daylight
|
||||
FROM facility_weather_forecast
|
||||
WHERE facility_id = f.id
|
||||
ORDER BY day_offset ASC
|
||||
) w_data
|
||||
) as weather_forecast
|
||||
"""
|
||||
weather_full_sql = """
|
||||
(
|
||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||
SELECT
|
||||
forecast_date,
|
||||
day_offset,
|
||||
dry_all_day,
|
||||
dry_daylight,
|
||||
precip_mm,
|
||||
precip_probability_max,
|
||||
daylight_precip_mm,
|
||||
daylight_precip_probability_max,
|
||||
confidence,
|
||||
source_updated_at,
|
||||
source_expires_at,
|
||||
calculated_at
|
||||
FROM facility_weather_forecast
|
||||
WHERE facility_id = f.id
|
||||
ORDER BY day_offset ASC
|
||||
) w_data
|
||||
) as weather_forecast
|
||||
"""
|
||||
has_golfpakker_sql = """
|
||||
CASE
|
||||
WHEN jsonb_typeof(f.golfpakker) = 'array' AND jsonb_array_length(f.golfpakker) > 0 THEN TRUE
|
||||
WHEN NULLIF(BTRIM(COALESCE(f.golfpakker_url, '')), '') IS NOT NULL THEN TRUE
|
||||
ELSE FALSE
|
||||
END as has_golfpakker
|
||||
"""
|
||||
total_hole_count_sql = """
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as total_hole_count
|
||||
"""
|
||||
hole_par_counts_sql = """
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
||||
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
||||
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
||||
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
||||
)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as hole_par_counts
|
||||
"""
|
||||
shortest_hole_sql = """
|
||||
(
|
||||
SELECT MIN((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as shortest_hole_meters
|
||||
"""
|
||||
longest_hole_sql = """
|
||||
(
|
||||
SELECT MAX((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as longest_hole_meters
|
||||
"""
|
||||
|
||||
if normalized_view in {"search", "home"}:
|
||||
return (
|
||||
f"""
|
||||
SELECT
|
||||
f.id,
|
||||
f.slug,
|
||||
|
|
@ -2794,6 +3017,50 @@ async def get_facilities(summary: bool = False):
|
|||
f.golfamore,
|
||||
f.golfamore_url,
|
||||
f.nsg_url,
|
||||
{has_golfpakker_sql},
|
||||
f.vtg_pris,
|
||||
f.vtg_lenke,
|
||||
f.vtg_beskrivelse,
|
||||
f.amenities,
|
||||
f.golfamore_data,
|
||||
f.nsg_data,
|
||||
f.vtg_datoer,
|
||||
f.footnote,
|
||||
f.footnote_updated_at,
|
||||
f.status_updated_at,
|
||||
{course_statuses_sql},
|
||||
{weather_compact_sql}
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""",
|
||||
FACILITY_VIEW_SEARCH_FIELDS,
|
||||
)
|
||||
|
||||
if normalized_view == "place":
|
||||
return (
|
||||
f"""
|
||||
SELECT
|
||||
f.id,
|
||||
f.slug,
|
||||
f.name,
|
||||
f.architect,
|
||||
f.description,
|
||||
f.city,
|
||||
f.county,
|
||||
f.banetype,
|
||||
f.image_url,
|
||||
f.phone,
|
||||
f.website_url,
|
||||
f.golfbox_booking_url,
|
||||
f.golfbox_tournament_url,
|
||||
f.weather_url,
|
||||
f.lat,
|
||||
f.lng,
|
||||
f.golfamore,
|
||||
f.golfamore_url,
|
||||
f.nsg_url,
|
||||
{has_golfpakker_sql},
|
||||
f.greenfee,
|
||||
f.standard_medlemskap,
|
||||
f.vtg_pris,
|
||||
|
|
@ -2806,144 +3073,164 @@ async def get_facilities(summary: bool = False):
|
|||
f.footnote,
|
||||
f.footnote_updated_at,
|
||||
f.status_updated_at,
|
||||
(
|
||||
SELECT jsonb_agg(cs) FROM (
|
||||
SELECT id, name, status FROM courses
|
||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
||||
ORDER BY is_main_course DESC, id ASC
|
||||
) cs
|
||||
) as course_statuses,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as total_hole_count,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
||||
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
||||
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
||||
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
||||
)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as hole_par_counts,
|
||||
(
|
||||
SELECT MIN((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as shortest_hole_meters,
|
||||
(
|
||||
SELECT MAX((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as longest_hole_meters,
|
||||
(
|
||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||
SELECT
|
||||
forecast_date,
|
||||
day_offset,
|
||||
dry_all_day,
|
||||
dry_daylight,
|
||||
precip_mm,
|
||||
precip_probability_max,
|
||||
daylight_precip_mm,
|
||||
daylight_precip_probability_max,
|
||||
confidence,
|
||||
source_updated_at,
|
||||
source_expires_at,
|
||||
calculated_at
|
||||
FROM facility_weather_forecast
|
||||
WHERE facility_id = f.id
|
||||
ORDER BY day_offset ASC
|
||||
) w_data
|
||||
) as weather_forecast
|
||||
{course_statuses_sql},
|
||||
{total_hole_count_sql},
|
||||
{hole_par_counts_sql},
|
||||
{shortest_hole_sql},
|
||||
{longest_hole_sql},
|
||||
{weather_compact_sql}
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""")
|
||||
else:
|
||||
rows = await conn.fetch("""
|
||||
SELECT f.*, (
|
||||
SELECT jsonb_agg(cs) FROM (
|
||||
SELECT id, name, status FROM courses
|
||||
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
|
||||
ORDER BY is_main_course DESC, id ASC
|
||||
) cs
|
||||
) as course_statuses, (
|
||||
SELECT COUNT(*)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as total_hole_count, (
|
||||
SELECT jsonb_build_object(
|
||||
'3', COUNT(*) FILTER (WHERE h.par = 3),
|
||||
'4', COUNT(*) FILTER (WHERE h.par = 4),
|
||||
'5', COUNT(*) FILTER (WHERE h.par = 5),
|
||||
'6', COUNT(*) FILTER (WHERE h.par = 6)
|
||||
)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
WHERE c.facility_id = f.id
|
||||
) as hole_par_counts, (
|
||||
SELECT MIN((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as shortest_hole_meters, (
|
||||
SELECT MAX((length_value.value)::int)
|
||||
FROM holes h
|
||||
JOIN courses c ON c.id = h.course_id
|
||||
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
|
||||
WHERE c.facility_id = f.id
|
||||
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
|
||||
AND length_value.value ~ '^[0-9]+$'
|
||||
AND (length_value.value)::int BETWEEN 30 AND 900
|
||||
) as longest_hole_meters, (
|
||||
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
|
||||
SELECT
|
||||
forecast_date,
|
||||
day_offset,
|
||||
dry_all_day,
|
||||
dry_daylight,
|
||||
precip_mm,
|
||||
precip_probability_max,
|
||||
daylight_precip_mm,
|
||||
daylight_precip_probability_max,
|
||||
confidence,
|
||||
source_updated_at,
|
||||
source_expires_at,
|
||||
calculated_at
|
||||
FROM facility_weather_forecast
|
||||
WHERE facility_id = f.id
|
||||
ORDER BY day_offset ASC
|
||||
) w_data
|
||||
) as weather_forecast
|
||||
""",
|
||||
FACILITY_VIEW_PLACE_FIELDS,
|
||||
)
|
||||
|
||||
if normalized_view == "membership":
|
||||
return (
|
||||
"""
|
||||
SELECT
|
||||
f.id,
|
||||
f.slug,
|
||||
f.name,
|
||||
f.city,
|
||||
f.county,
|
||||
f.medlemskap_url,
|
||||
f.membership_updated_at,
|
||||
f.standard_medlemskap_kommentarer,
|
||||
f.navn_standard_medlemskap,
|
||||
f.standard_medlemskap,
|
||||
f.navn_rimeligste_alternativ,
|
||||
f.rimeligste_alternativ
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""")
|
||||
return [format_row(row) for row in rows]
|
||||
""",
|
||||
FACILITY_VIEW_MEMBERSHIP_FIELDS,
|
||||
)
|
||||
|
||||
if normalized_view == "vtg":
|
||||
return (
|
||||
"""
|
||||
SELECT
|
||||
f.id,
|
||||
f.slug,
|
||||
f.name,
|
||||
f.city,
|
||||
f.county,
|
||||
f.lat,
|
||||
f.lng,
|
||||
f.status_updated_at,
|
||||
f.vtg_pris,
|
||||
f.vtg_lenke,
|
||||
f.vtg_beskrivelse,
|
||||
f.vtg_datoer,
|
||||
f.vtg_updated_at
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""",
|
||||
FACILITY_VIEW_VTG_FIELDS,
|
||||
)
|
||||
|
||||
if normalized_view == "clubnumbers":
|
||||
return (
|
||||
"""
|
||||
SELECT
|
||||
f.id,
|
||||
f.slug,
|
||||
f.name,
|
||||
f.city,
|
||||
f.county,
|
||||
f.ngf_number
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""",
|
||||
FACILITY_VIEW_CLUBNUMBERS_FIELDS,
|
||||
)
|
||||
|
||||
if normalized_view == "sitemap":
|
||||
return (
|
||||
"""
|
||||
SELECT
|
||||
f.slug,
|
||||
f.status_updated_at,
|
||||
f.vtg_updated_at
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""",
|
||||
FACILITY_VIEW_SITEMAP_FIELDS,
|
||||
)
|
||||
|
||||
if normalized_view == "aliases":
|
||||
return (
|
||||
"""
|
||||
SELECT
|
||||
f.slug,
|
||||
f.name
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""",
|
||||
FACILITY_VIEW_ALIASES_FIELDS,
|
||||
)
|
||||
|
||||
return (
|
||||
f"""
|
||||
SELECT
|
||||
f.*,
|
||||
{course_statuses_sql},
|
||||
{total_hole_count_sql},
|
||||
{hole_par_counts_sql},
|
||||
{shortest_hole_sql},
|
||||
{longest_hole_sql},
|
||||
{weather_full_sql}
|
||||
FROM facilities f
|
||||
WHERE COALESCE(f.is_published, TRUE) = TRUE
|
||||
ORDER BY f.name ASC
|
||||
""",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/facilities")
|
||||
async def get_facilities(
|
||||
response: Response,
|
||||
summary: bool = False,
|
||||
view: str | None = Query(default=None),
|
||||
):
|
||||
"""Henter publiserte golfanlegg i profiler tilpasset offentlig bruk."""
|
||||
resolved_view = "place" if summary and not view else view
|
||||
cache_key = ((resolved_view or "").strip().lower() or "__default__")
|
||||
cache_ttl = get_public_facilities_cache_ttl(resolved_view)
|
||||
facilities_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_facilities_cache", {})
|
||||
cached_payload = read_public_cache_entry(facilities_cache, cache_key)
|
||||
if cached_payload is not None:
|
||||
apply_public_cache_headers(response, cache_ttl)
|
||||
return cached_payload
|
||||
|
||||
query, fields = build_public_facilities_query(resolved_view)
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch(query)
|
||||
|
||||
payload = [sanitize_public_facility_row(row, fields=fields) for row in rows]
|
||||
write_public_cache_entry(facilities_cache, cache_key, payload, cache_ttl)
|
||||
apply_public_cache_headers(response, cache_ttl)
|
||||
return payload
|
||||
|
||||
@app.get("/api/facilities/{slug}")
|
||||
async def get_facility(slug: str):
|
||||
async def get_facility(slug: str, response: Response):
|
||||
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
|
||||
normalized_slug = str(slug or "").strip().lower()
|
||||
detail_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_facility_detail_cache", {})
|
||||
cached_payload = read_public_cache_entry(detail_cache, normalized_slug)
|
||||
if cached_payload is not None:
|
||||
apply_public_cache_headers(response, PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS)
|
||||
return cached_payload
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT f.*, (
|
||||
|
|
@ -2984,16 +3271,30 @@ async def get_facility(slug: str):
|
|||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||
|
||||
return format_row(row)
|
||||
|
||||
payload = sanitize_public_facility_row(row)
|
||||
write_public_cache_entry(
|
||||
detail_cache,
|
||||
normalized_slug,
|
||||
payload,
|
||||
PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS,
|
||||
)
|
||||
apply_public_cache_headers(response, PUBLIC_FACILITY_DETAIL_CACHE_TTL_SECONDS)
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/api/place-pages/{slug}")
|
||||
async def get_place_page(slug: str):
|
||||
async def get_place_page(slug: str, response: Response):
|
||||
normalized_slug = str(slug or "").strip().lower()
|
||||
if not normalized_slug:
|
||||
raise HTTPException(status_code=400, detail="Slug mangler.")
|
||||
|
||||
place_page_cache: dict[str, tuple[float, Any]] = getattr(app.state, "public_place_page_cache", {})
|
||||
cached_payload = read_public_cache_entry(place_page_cache, normalized_slug)
|
||||
if cached_payload is not None:
|
||||
apply_public_cache_headers(response, PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS)
|
||||
return cached_payload
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM place_pages WHERE slug = $1",
|
||||
|
|
@ -3008,7 +3309,15 @@ async def get_place_page(slug: str):
|
|||
"updated_at": None,
|
||||
}
|
||||
|
||||
return format_place_page_row(row)
|
||||
payload = format_place_page_row(row)
|
||||
write_public_cache_entry(
|
||||
place_page_cache,
|
||||
normalized_slug,
|
||||
payload,
|
||||
PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS,
|
||||
)
|
||||
apply_public_cache_headers(response, PUBLIC_PLACE_PAGE_CACHE_TTL_SECONDS)
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/api/admin/facilities")
|
||||
|
|
@ -3144,6 +3453,7 @@ async def create_admin_facility(request: Request):
|
|||
"SELECT id, slug, name, is_published FROM facilities WHERE id = $1",
|
||||
facility_id,
|
||||
)
|
||||
invalidate_public_api_caches()
|
||||
|
||||
schedule_facility_indexnow_submission_for_fields(
|
||||
facility_slug,
|
||||
|
|
@ -3200,6 +3510,7 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
|
|||
request.factbox_intro_html or "",
|
||||
)
|
||||
|
||||
invalidate_public_api_caches(include_place_pages=True)
|
||||
return format_place_page_row(row)
|
||||
|
||||
|
||||
|
|
@ -3649,6 +3960,7 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
|
|||
)
|
||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c.id)
|
||||
|
||||
invalidate_public_api_caches()
|
||||
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -3665,6 +3977,7 @@ async def update_facility_full(facility_id: int, request: Request):
|
|||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
|
||||
invalidate_public_api_caches()
|
||||
|
||||
schedule_facility_indexnow_submission_for_fields(
|
||||
facility_slug,
|
||||
|
|
@ -3687,6 +4000,7 @@ async def delete_facility(facility_id: int):
|
|||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||
|
||||
deleted_slug = str(deleted["slug"] or "").strip()
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]),
|
||||
reason="facility delete",
|
||||
|
|
@ -3830,6 +4144,7 @@ async def approve_membership_bulk(request: BulkApprovalRequest):
|
|||
approval.rimeligste_alternativ,
|
||||
approval.facility_id)
|
||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/medlemskap", "/golfbaner"]),
|
||||
reason="membership bulk approval",
|
||||
|
|
@ -3866,6 +4181,7 @@ async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
|||
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
|
||||
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
|
||||
request.value, facility_id)
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]),
|
||||
reason=f"facility quick edit ({request.field})",
|
||||
|
|
@ -3934,6 +4250,7 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
|||
WHERE id = $2
|
||||
""", json.dumps(approval.greenfee), approval.facility_id)
|
||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
|
||||
reason="greenfee bulk approval",
|
||||
|
|
@ -4006,6 +4323,7 @@ async def approve_vtg_content_bulk(request: BulkVtgContentRequest):
|
|||
approval.facility_id,
|
||||
)
|
||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
||||
reason="vtg content bulk approval",
|
||||
|
|
@ -4035,6 +4353,7 @@ async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
|
|||
approval.facility_id,
|
||||
)
|
||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
||||
reason="vtg courses bulk approval",
|
||||
|
|
@ -4068,6 +4387,7 @@ async def approve_vtg_bulk(request: BulkVtgRequest):
|
|||
approval.facility_id,
|
||||
)
|
||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
|
||||
reason="vtg bulk approval",
|
||||
|
|
@ -4110,6 +4430,7 @@ async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest):
|
|||
WHERE id = $2
|
||||
""", json.dumps(approval.golfpakker), approval.facility_id)
|
||||
facility_slugs = await fetch_facility_slugs(conn, facility_ids)
|
||||
invalidate_public_api_caches()
|
||||
schedule_indexnow_submission(
|
||||
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
|
||||
reason="golfpakker bulk approval",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ type Facility = {
|
|||
golfamore?: boolean | null;
|
||||
golfamore_url?: string | null;
|
||||
nsg_url?: string | null;
|
||||
has_golfpakker?: boolean | null;
|
||||
vtg_pris?: number | null;
|
||||
vtg_lenke?: string | null;
|
||||
vtg_beskrivelse?: string | null;
|
||||
|
|
@ -75,6 +76,7 @@ type FacilitySearchProps = {
|
|||
type SpecialFlags = {
|
||||
hasGolfamore: boolean;
|
||||
hasNSG: boolean;
|
||||
hasGolfPackages: boolean;
|
||||
hasSimulator: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -391,6 +393,7 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
|
|||
if (!specialFilter) return true;
|
||||
if (specialFilter === "golfamore") return flags.hasGolfamore;
|
||||
if (specialFilter === "nsg") return flags.hasNSG;
|
||||
if (specialFilter === "golfpakke") return flags.hasGolfPackages;
|
||||
if (specialFilter === "simulator") return flags.hasSimulator;
|
||||
return true;
|
||||
};
|
||||
|
|
@ -644,6 +647,7 @@ export default function FacilitySearch({
|
|||
Boolean(facility.golfamore_url) ||
|
||||
Object.keys(golfamoreData).length > 0;
|
||||
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
|
||||
const hasGolfPackages = facility.has_golfpakker === true;
|
||||
const hasSimulator = hasTruthyAmenity(amenities.simulator);
|
||||
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
|
||||
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
|
||||
|
|
@ -676,6 +680,7 @@ export default function FacilitySearch({
|
|||
|
||||
if (hasGolfamore) searchBlob += " golfamore";
|
||||
if (hasNSG) searchBlob += " nsg seniorgolf";
|
||||
if (hasGolfPackages) searchBlob += " golfpakke golfpakker";
|
||||
if (hasSimulator) searchBlob += " simulator";
|
||||
if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne";
|
||||
if (normalizedStatuses.includes("stengt")) searchBlob += " stengt";
|
||||
|
|
@ -700,6 +705,7 @@ export default function FacilitySearch({
|
|||
const matchesSpecial = matchesSpecialFilter(specialFilter, {
|
||||
hasGolfamore,
|
||||
hasNSG,
|
||||
hasGolfPackages,
|
||||
hasSimulator,
|
||||
});
|
||||
const selectedWeatherDayOffset = Number.parseInt(weatherDayFilter, 10);
|
||||
|
|
@ -717,6 +723,7 @@ export default function FacilitySearch({
|
|||
primaryStatus,
|
||||
hasGolfamore,
|
||||
hasNSG,
|
||||
hasGolfPackages,
|
||||
hasSimulator,
|
||||
hasDrivingRange,
|
||||
hasVtg,
|
||||
|
|
@ -892,10 +899,11 @@ export default function FacilitySearch({
|
|||
))}
|
||||
</FieldSelect>
|
||||
|
||||
<FieldSelect label="NSG / GOLFAMORE" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
|
||||
<FieldSelect label="NSG / GOLFAMORE / GOLFPAKKE" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
|
||||
<option value="">Ikke hensyntatt</option>
|
||||
<option value="golfamore">Golfamore</option>
|
||||
<option value="nsg">Seniorgolf / NSG</option>
|
||||
<option value="golfpakke">Tilbyr golfpakke</option>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ type Facility = {
|
|||
slug: string;
|
||||
name: string;
|
||||
image_url?: string | null;
|
||||
course_statuses?: CourseStatus[] | null;
|
||||
course_statuses?: unknown;
|
||||
};
|
||||
|
||||
const hashString = (value: string) => {
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) {
|
|||
}
|
||||
|
||||
async function getFacilityAliasMap() {
|
||||
const response = await fetch(`${API_URL}/facilities?summary=true`, {
|
||||
cache: "no-store",
|
||||
const response = await fetch(`${API_URL}/facilities?view=aliases`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export type FacilityRecord = {
|
|||
golfamore?: boolean | null;
|
||||
golfamore_url?: string | null;
|
||||
nsg_url?: string | null;
|
||||
has_golfpakker?: boolean | null;
|
||||
greenfee?: unknown;
|
||||
standard_medlemskap?: number | null;
|
||||
total_hole_count?: number | null;
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export default async function OpenGraphImage({ params }: OpenGraphImageProps) {
|
|||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
display: "flex",
|
||||
alignSelf: "flex-start",
|
||||
borderRadius: 999,
|
||||
background: "rgba(139, 195, 74, 0.92)",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import FacilitySearch from "@/app/FacilitySearch";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const revalidate = 900;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pageTitle = "Golfbaner i Norge";
|
||||
|
|
@ -20,25 +22,7 @@ export const metadata = createPageMetadata({
|
|||
});
|
||||
|
||||
export default async function GolfCoursesIndexPage() {
|
||||
let facilities = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, {
|
||||
next: { revalidate: 0 },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
facilities = await res.json();
|
||||
} catch (error) {
|
||||
console.error("Kritisk feil ved henting av golfbaner:", error);
|
||||
facilities = [];
|
||||
}
|
||||
|
||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { API_URL } from "@/config/constants";
|
||||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||
import InfoPageShell from "@/components/InfoPageShell";
|
||||
import ClubNumbersTable from "@/components/ClubNumbersTable";
|
||||
import {
|
||||
|
|
@ -18,27 +18,11 @@ export const metadata = createPageMetadata({
|
|||
path: "/klubbnummer",
|
||||
});
|
||||
|
||||
export const revalidate = 3600;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ClubNumbersPage() {
|
||||
let facilities: FacilityRecord[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, {
|
||||
next: { revalidate: 0 },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
facilities = Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error("Kunne ikke hente klubbnummer:", error);
|
||||
facilities = [];
|
||||
}
|
||||
const facilities = await fetchPublicFacilities<FacilityRecord>("clubnumbers", revalidate);
|
||||
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { API_URL } from "@/config/constants";
|
||||
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
|
||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const revalidate = 1800;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pageTitle = "Medlemskap i norske golfklubber";
|
||||
|
|
@ -19,24 +20,7 @@ export const metadata = createPageMetadata({
|
|||
});
|
||||
|
||||
export default async function MembershipPage() {
|
||||
let facilities: MembershipFacility[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, {
|
||||
next: { revalidate: 0 },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
facilities = Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error("Kunne ikke hente medlemsdata:", error);
|
||||
facilities = [];
|
||||
}
|
||||
const facilities = await fetchPublicFacilities<MembershipFacility>("membership", revalidate);
|
||||
|
||||
const visibleFacilities = facilities.filter(
|
||||
(facility) =>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import FacilitySearch from "./FacilitySearch";
|
||||
import HeroSlider from "./HeroSlider";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||
import { createPageMetadata } from "@/app/seo";
|
||||
|
||||
export const revalidate = 900;
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = createPageMetadata({
|
||||
title: "Komplett oversikt over ALLE norske golfbaner",
|
||||
|
|
@ -14,25 +16,7 @@ export const metadata = createPageMetadata({
|
|||
const getHeroRotationSeed = () => new Date().toISOString().slice(0, 13);
|
||||
|
||||
export default async function Home() {
|
||||
let facilities = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, {
|
||||
next: { revalidate: 0 },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
facilities = await res.json();
|
||||
} catch (error) {
|
||||
console.error("Kritisk feil ved henting av data:", error);
|
||||
facilities = [];
|
||||
}
|
||||
|
||||
const safeData = Array.isArray(facilities) ? facilities : [];
|
||||
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
|
||||
|
||||
return (
|
||||
<main className="site-shell min-h-screen">
|
||||
|
|
|
|||
47
frontend/src/app/publicFacilities.ts
Normal file
47
frontend/src/app/publicFacilities.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { API_URL } from "@/config/constants";
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function fetchPublicFacilities<T>(
|
||||
view: string,
|
||||
revalidateSeconds: number,
|
||||
{
|
||||
allowEmpty = false,
|
||||
attempts = 3,
|
||||
}: {
|
||||
allowEmpty?: boolean;
|
||||
attempts?: number;
|
||||
} = {},
|
||||
): Promise<T[]> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities?view=${view}`, {
|
||||
next: { revalidate: revalidateSeconds },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("API returnerte ikke en liste");
|
||||
}
|
||||
|
||||
if (!allowEmpty && data.length === 0) {
|
||||
throw new Error("API returnerte tom liste");
|
||||
}
|
||||
|
||||
return data as T[];
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error("Ukjent feil ved henting av anlegg");
|
||||
if (attempt < attempts) {
|
||||
await delay(attempt * 250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Kunne ikke hente anlegg");
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ type FacilitySeoRecord = FacilityRecord & {
|
|||
};
|
||||
|
||||
export const SITE_NAME = "TeeOff";
|
||||
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://nye.teeoff.no").replace(/\/$/, "");
|
||||
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no").replace(/\/$/, "");
|
||||
export const DEFAULT_DESCRIPTION =
|
||||
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfbaner samlet på ett sted.";
|
||||
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ type SitemapFacility = {
|
|||
vtg_updated_at?: string | null;
|
||||
};
|
||||
|
||||
export const revalidate = 3600;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: buildAbsoluteUrl("/"),
|
||||
|
|
@ -83,7 +86,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||
let facilities: SitemapFacility[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, { cache: "no-store" });
|
||||
const res = await fetch(`${API_URL}/facilities?view=sitemap`, {
|
||||
next: { revalidate },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
facilities = Array.isArray(data) ? data : [];
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
createItemListJsonLd,
|
||||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||
|
||||
type PlacePageData = {
|
||||
slug?: string;
|
||||
|
|
@ -124,27 +125,13 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
|
|||
notFound();
|
||||
}
|
||||
|
||||
let facilities: FacilityRecord[] = [];
|
||||
let placePage: PlacePageData | null = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities?summary=1`, {
|
||||
next: { revalidate },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
facilities = await res.json();
|
||||
} catch (error) {
|
||||
console.error("Kritisk feil ved henting av sted-data:", error);
|
||||
facilities = [];
|
||||
}
|
||||
const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/place-pages/${slug}`, {
|
||||
cache: "no-store",
|
||||
next: { revalidate },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { API_URL } from "@/config/constants";
|
||||
import VtgExplorer from "./VtgExplorer";
|
||||
import { fetchPublicFacilities } from "@/app/publicFacilities";
|
||||
import type { FacilityRecord } from "@/app/facilityData";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
createPageMetadata,
|
||||
} from "@/app/seo";
|
||||
|
||||
export const revalidate = 1800;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pageTitle = "Veien til Golf";
|
||||
|
|
@ -20,24 +21,7 @@ export const metadata = createPageMetadata({
|
|||
});
|
||||
|
||||
export default async function VtgPage() {
|
||||
let facilities: FacilityRecord[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/facilities`, {
|
||||
next: { revalidate: 0 },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API returnerte status ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
facilities = Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error("Kunne ikke hente VTG-data:", error);
|
||||
facilities = [];
|
||||
}
|
||||
const facilities = await fetchPublicFacilities<FacilityRecord>("vtg", revalidate);
|
||||
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
|
|
|
|||
Loading…
Reference in a new issue