Før fase to i effektiviseringen

This commit is contained in:
Erol Haagenrud 2026-04-26 11:29:35 +02:00
parent dc7ed19f02
commit a508db6071
15 changed files with 548 additions and 259 deletions

View file

@ -22,6 +22,7 @@ import re
import secrets import secrets
import hashlib import hashlib
import smtplib import smtplib
import time
import unicodedata import unicodedata
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from email.message import EmailMessage 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_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip()
INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").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") 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: def get_configured_public_base_url() -> str:
for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"): for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"):
configured = os.getenv(env_name, "").strip().rstrip("/") 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_content_updated_at',
'vtg_courses_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 --- # --- FUNKSJONER ---
def format_row(row): def format_row(row):
""" """
@ -784,6 +892,24 @@ def format_row(row):
return d 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]: def prepare_vtg_content_draft_payload(value: Any) -> dict[str, Any]:
if not isinstance(value, dict): if not isinstance(value, dict):
return {} return {}
@ -2097,6 +2223,7 @@ async def lifespan(app: FastAPI):
weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event) weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event)
) )
app.state.contact_submission_tracker = {} app.state.contact_submission_tracker = {}
initialize_public_api_caches()
print("✅ Database tilkoblet og pool opprettet") print("✅ Database tilkoblet og pool opprettet")
except Exception as e: except Exception as e:
print(f"❌ Databasefeil under oppstart: {e}") print(f"❌ Databasefeil under oppstart: {e}")
@ -2768,12 +2895,108 @@ async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsert
# --- DATA ENDPOINTS --- # --- DATA ENDPOINTS ---
@app.get("/api/facilities") def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]:
async def get_facilities(summary: bool = False): normalized_view = (view or "").strip().lower()
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
async with app.state.pool.acquire() as conn: course_statuses_sql = """
if summary: (
rows = await conn.fetch(""" 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 SELECT
f.id, f.id,
f.slug, f.slug,
@ -2794,6 +3017,50 @@ async def get_facilities(summary: bool = False):
f.golfamore, f.golfamore,
f.golfamore_url, f.golfamore_url,
f.nsg_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.greenfee,
f.standard_medlemskap, f.standard_medlemskap,
f.vtg_pris, f.vtg_pris,
@ -2806,144 +3073,164 @@ async def get_facilities(summary: bool = False):
f.footnote, f.footnote,
f.footnote_updated_at, f.footnote_updated_at,
f.status_updated_at, f.status_updated_at,
( {course_statuses_sql},
SELECT jsonb_agg(cs) FROM ( {total_hole_count_sql},
SELECT id, name, status FROM courses {hole_par_counts_sql},
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' {shortest_hole_sql},
ORDER BY is_main_course DESC, id ASC {longest_hole_sql},
) cs {weather_compact_sql}
) 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
FROM facilities f FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC ORDER BY f.name ASC
""") """,
else: FACILITY_VIEW_PLACE_FIELDS,
rows = await conn.fetch(""" )
SELECT f.*, (
SELECT jsonb_agg(cs) FROM ( if normalized_view == "membership":
SELECT id, name, status FROM courses return (
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' """
ORDER BY is_main_course DESC, id ASC SELECT
) cs f.id,
) as course_statuses, ( f.slug,
SELECT COUNT(*) f.name,
FROM holes h f.city,
JOIN courses c ON c.id = h.course_id f.county,
WHERE c.facility_id = f.id f.medlemskap_url,
) as total_hole_count, ( f.membership_updated_at,
SELECT jsonb_build_object( f.standard_medlemskap_kommentarer,
'3', COUNT(*) FILTER (WHERE h.par = 3), f.navn_standard_medlemskap,
'4', COUNT(*) FILTER (WHERE h.par = 4), f.standard_medlemskap,
'5', COUNT(*) FILTER (WHERE h.par = 5), f.navn_rimeligste_alternativ,
'6', COUNT(*) FILTER (WHERE h.par = 6) f.rimeligste_alternativ
)
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
FROM facilities f FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC 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}") @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.""" """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: async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(""" row = await conn.fetchrow("""
SELECT f.*, ( SELECT f.*, (
@ -2984,16 +3271,30 @@ async def get_facility(slug: str):
if not row: if not row:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") 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}") @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() normalized_slug = str(slug or "").strip().lower()
if not normalized_slug: if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler.") 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: async with app.state.pool.acquire() as conn:
row = await conn.fetchrow( row = await conn.fetchrow(
"SELECT * FROM place_pages WHERE slug = $1", "SELECT * FROM place_pages WHERE slug = $1",
@ -3008,7 +3309,15 @@ async def get_place_page(slug: str):
"updated_at": None, "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") @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", "SELECT id, slug, name, is_published FROM facilities WHERE id = $1",
facility_id, facility_id,
) )
invalidate_public_api_caches()
schedule_facility_indexnow_submission_for_fields( schedule_facility_indexnow_submission_for_fields(
facility_slug, facility_slug,
@ -3200,6 +3510,7 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest):
request.factbox_intro_html or "", request.factbox_intro_html or "",
) )
invalidate_public_api_caches(include_place_pages=True)
return format_place_page_row(row) 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) 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."} return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
except Exception as e: 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 app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data) facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
invalidate_public_api_caches()
schedule_facility_indexnow_submission_for_fields( schedule_facility_indexnow_submission_for_fields(
facility_slug, facility_slug,
@ -3687,6 +4000,7 @@ async def delete_facility(facility_id: int):
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
deleted_slug = str(deleted["slug"] or "").strip() deleted_slug = str(deleted["slug"] or "").strip()
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]), collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]),
reason="facility delete", reason="facility delete",
@ -3830,6 +4144,7 @@ async def approve_membership_bulk(request: BulkApprovalRequest):
approval.rimeligste_alternativ, approval.rimeligste_alternativ,
approval.facility_id) approval.facility_id)
facility_slugs = await fetch_facility_slugs(conn, facility_ids) facility_slugs = await fetch_facility_slugs(conn, facility_ids)
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/medlemskap", "/golfbaner"]), collect_facility_indexnow_urls(facility_slugs, extra_paths=["/medlemskap", "/golfbaner"]),
reason="membership bulk approval", 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 # 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", await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
request.value, facility_id) request.value, facility_id)
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]), collect_facility_indexnow_urls([facility_slug], extra_paths=["/golfbaner"]),
reason=f"facility quick edit ({request.field})", reason=f"facility quick edit ({request.field})",
@ -3934,6 +4250,7 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
WHERE id = $2 WHERE id = $2
""", json.dumps(approval.greenfee), approval.facility_id) """, json.dumps(approval.greenfee), approval.facility_id)
facility_slugs = await fetch_facility_slugs(conn, facility_ids) facility_slugs = await fetch_facility_slugs(conn, facility_ids)
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]), collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
reason="greenfee bulk approval", reason="greenfee bulk approval",
@ -4006,6 +4323,7 @@ async def approve_vtg_content_bulk(request: BulkVtgContentRequest):
approval.facility_id, approval.facility_id,
) )
facility_slugs = await fetch_facility_slugs(conn, facility_ids) facility_slugs = await fetch_facility_slugs(conn, facility_ids)
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg content bulk approval", reason="vtg content bulk approval",
@ -4035,6 +4353,7 @@ async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest):
approval.facility_id, approval.facility_id,
) )
facility_slugs = await fetch_facility_slugs(conn, facility_ids) facility_slugs = await fetch_facility_slugs(conn, facility_ids)
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg courses bulk approval", reason="vtg courses bulk approval",
@ -4068,6 +4387,7 @@ async def approve_vtg_bulk(request: BulkVtgRequest):
approval.facility_id, approval.facility_id,
) )
facility_slugs = await fetch_facility_slugs(conn, facility_ids) facility_slugs = await fetch_facility_slugs(conn, facility_ids)
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]),
reason="vtg bulk approval", reason="vtg bulk approval",
@ -4110,6 +4430,7 @@ async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest):
WHERE id = $2 WHERE id = $2
""", json.dumps(approval.golfpakker), approval.facility_id) """, json.dumps(approval.golfpakker), approval.facility_id)
facility_slugs = await fetch_facility_slugs(conn, facility_ids) facility_slugs = await fetch_facility_slugs(conn, facility_ids)
invalidate_public_api_caches()
schedule_indexnow_submission( schedule_indexnow_submission(
collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]), collect_facility_indexnow_urls(facility_slugs, extra_paths=["/golfbaner"]),
reason="golfpakker bulk approval", reason="golfpakker bulk approval",

View file

@ -35,6 +35,7 @@ type Facility = {
golfamore?: boolean | null; golfamore?: boolean | null;
golfamore_url?: string | null; golfamore_url?: string | null;
nsg_url?: string | null; nsg_url?: string | null;
has_golfpakker?: boolean | null;
vtg_pris?: number | null; vtg_pris?: number | null;
vtg_lenke?: string | null; vtg_lenke?: string | null;
vtg_beskrivelse?: string | null; vtg_beskrivelse?: string | null;
@ -75,6 +76,7 @@ type FacilitySearchProps = {
type SpecialFlags = { type SpecialFlags = {
hasGolfamore: boolean; hasGolfamore: boolean;
hasNSG: boolean; hasNSG: boolean;
hasGolfPackages: boolean;
hasSimulator: boolean; hasSimulator: boolean;
}; };
@ -391,6 +393,7 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
if (!specialFilter) return true; if (!specialFilter) return true;
if (specialFilter === "golfamore") return flags.hasGolfamore; if (specialFilter === "golfamore") return flags.hasGolfamore;
if (specialFilter === "nsg") return flags.hasNSG; if (specialFilter === "nsg") return flags.hasNSG;
if (specialFilter === "golfpakke") return flags.hasGolfPackages;
if (specialFilter === "simulator") return flags.hasSimulator; if (specialFilter === "simulator") return flags.hasSimulator;
return true; return true;
}; };
@ -644,6 +647,7 @@ export default function FacilitySearch({
Boolean(facility.golfamore_url) || Boolean(facility.golfamore_url) ||
Object.keys(golfamoreData).length > 0; Object.keys(golfamoreData).length > 0;
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).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 hasSimulator = hasTruthyAmenity(amenities.simulator);
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange); const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange);
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []); const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
@ -676,6 +680,7 @@ export default function FacilitySearch({
if (hasGolfamore) searchBlob += " golfamore"; if (hasGolfamore) searchBlob += " golfamore";
if (hasNSG) searchBlob += " nsg seniorgolf"; if (hasNSG) searchBlob += " nsg seniorgolf";
if (hasGolfPackages) searchBlob += " golfpakke golfpakker";
if (hasSimulator) searchBlob += " simulator"; if (hasSimulator) searchBlob += " simulator";
if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne"; if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne";
if (normalizedStatuses.includes("stengt")) searchBlob += " stengt"; if (normalizedStatuses.includes("stengt")) searchBlob += " stengt";
@ -700,6 +705,7 @@ export default function FacilitySearch({
const matchesSpecial = matchesSpecialFilter(specialFilter, { const matchesSpecial = matchesSpecialFilter(specialFilter, {
hasGolfamore, hasGolfamore,
hasNSG, hasNSG,
hasGolfPackages,
hasSimulator, hasSimulator,
}); });
const selectedWeatherDayOffset = Number.parseInt(weatherDayFilter, 10); const selectedWeatherDayOffset = Number.parseInt(weatherDayFilter, 10);
@ -717,6 +723,7 @@ export default function FacilitySearch({
primaryStatus, primaryStatus,
hasGolfamore, hasGolfamore,
hasNSG, hasNSG,
hasGolfPackages,
hasSimulator, hasSimulator,
hasDrivingRange, hasDrivingRange,
hasVtg, hasVtg,
@ -892,10 +899,11 @@ export default function FacilitySearch({
))} ))}
</FieldSelect> </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="">Ikke hensyntatt</option>
<option value="golfamore">Golfamore</option> <option value="golfamore">Golfamore</option>
<option value="nsg">Seniorgolf / NSG</option> <option value="nsg">Seniorgolf / NSG</option>
<option value="golfpakke">Tilbyr golfpakke</option>
</FieldSelect> </FieldSelect>
</div> </div>

View file

@ -48,7 +48,7 @@ type Facility = {
slug: string; slug: string;
name: string; name: string;
image_url?: string | null; image_url?: string | null;
course_statuses?: CourseStatus[] | null; course_statuses?: unknown;
}; };
const hashString = (value: string) => { const hashString = (value: string) => {

View file

@ -115,8 +115,8 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) {
} }
async function getFacilityAliasMap() { async function getFacilityAliasMap() {
const response = await fetch(`${API_URL}/facilities?summary=true`, { const response = await fetch(`${API_URL}/facilities?view=aliases`, {
cache: "no-store", next: { revalidate: 3600 },
}); });
if (!response.ok) { if (!response.ok) {

View file

@ -28,6 +28,7 @@ export type FacilityRecord = {
golfamore?: boolean | null; golfamore?: boolean | null;
golfamore_url?: string | null; golfamore_url?: string | null;
nsg_url?: string | null; nsg_url?: string | null;
has_golfpakker?: boolean | null;
greenfee?: unknown; greenfee?: unknown;
standard_medlemskap?: number | null; standard_medlemskap?: number | null;
total_hole_count?: number | null; total_hole_count?: number | null;

View file

@ -106,7 +106,7 @@ export default async function OpenGraphImage({ params }: OpenGraphImageProps) {
> >
<div <div
style={{ style={{
display: "inline-flex", display: "flex",
alignSelf: "flex-start", alignSelf: "flex-start",
borderRadius: 999, borderRadius: 999,
background: "rgba(139, 195, 74, 0.92)", background: "rgba(139, 195, 74, 0.92)",

View file

@ -1,5 +1,6 @@
import FacilitySearch from "@/app/FacilitySearch"; import FacilitySearch from "@/app/FacilitySearch";
import { API_URL } from "@/config/constants"; import type { FacilityRecord } from "@/app/facilityData";
import { fetchPublicFacilities } from "@/app/publicFacilities";
import { import {
createBreadcrumbJsonLd, createBreadcrumbJsonLd,
createCollectionPageJsonLd, createCollectionPageJsonLd,
@ -7,6 +8,7 @@ import {
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
export const revalidate = 900;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const pageTitle = "Golfbaner i Norge"; const pageTitle = "Golfbaner i Norge";
@ -20,25 +22,7 @@ export const metadata = createPageMetadata({
}); });
export default async function GolfCoursesIndexPage() { export default async function GolfCoursesIndexPage() {
let facilities = []; const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
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 collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle, name: pageTitle,
description: pageDescription, description: pageDescription,

View file

@ -1,5 +1,5 @@
import { API_URL } from "@/config/constants";
import type { FacilityRecord } from "@/app/facilityData"; import type { FacilityRecord } from "@/app/facilityData";
import { fetchPublicFacilities } from "@/app/publicFacilities";
import InfoPageShell from "@/components/InfoPageShell"; import InfoPageShell from "@/components/InfoPageShell";
import ClubNumbersTable from "@/components/ClubNumbersTable"; import ClubNumbersTable from "@/components/ClubNumbersTable";
import { import {
@ -18,27 +18,11 @@ export const metadata = createPageMetadata({
path: "/klubbnummer", path: "/klubbnummer",
}); });
export const revalidate = 3600;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ClubNumbersPage() { export default async function ClubNumbersPage() {
let facilities: FacilityRecord[] = []; const facilities = await fetchPublicFacilities<FacilityRecord>("clubnumbers", revalidate);
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 collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle, name: pageTitle,

View file

@ -1,11 +1,12 @@
import { API_URL } from "@/config/constants";
import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer"; import MembershipExplorer, { type MembershipFacility } from "./MembershipExplorer";
import { fetchPublicFacilities } from "@/app/publicFacilities";
import { import {
createBreadcrumbJsonLd, createBreadcrumbJsonLd,
createCollectionPageJsonLd, createCollectionPageJsonLd,
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
export const revalidate = 1800;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const pageTitle = "Medlemskap i norske golfklubber"; const pageTitle = "Medlemskap i norske golfklubber";
@ -19,24 +20,7 @@ export const metadata = createPageMetadata({
}); });
export default async function MembershipPage() { export default async function MembershipPage() {
let facilities: MembershipFacility[] = []; const facilities = await fetchPublicFacilities<MembershipFacility>("membership", revalidate);
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 visibleFacilities = facilities.filter( const visibleFacilities = facilities.filter(
(facility) => (facility) =>

View file

@ -1,8 +1,10 @@
import FacilitySearch from "./FacilitySearch"; import FacilitySearch from "./FacilitySearch";
import HeroSlider from "./HeroSlider"; 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"; import { createPageMetadata } from "@/app/seo";
export const revalidate = 900;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata = createPageMetadata({ export const metadata = createPageMetadata({
title: "Komplett oversikt over ALLE norske golfbaner", title: "Komplett oversikt over ALLE norske golfbaner",
@ -14,25 +16,7 @@ export const metadata = createPageMetadata({
const getHeroRotationSeed = () => new Date().toISOString().slice(0, 13); const getHeroRotationSeed = () => new Date().toISOString().slice(0, 13);
export default async function Home() { export default async function Home() {
let facilities = []; const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate);
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 : [];
return ( return (
<main className="site-shell min-h-screen"> <main className="site-shell min-h-screen">

View 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");
}

View file

@ -42,7 +42,7 @@ type FacilitySeoRecord = FacilityRecord & {
}; };
export const SITE_NAME = "TeeOff"; 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 = export const DEFAULT_DESCRIPTION =
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfbaner samlet på ett sted."; "Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfbaner samlet på ett sted.";
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE); export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);

View file

@ -10,6 +10,9 @@ type SitemapFacility = {
vtg_updated_at?: string | null; vtg_updated_at?: string | null;
}; };
export const revalidate = 3600;
export const dynamic = "force-dynamic";
const staticRoutes: MetadataRoute.Sitemap = [ const staticRoutes: MetadataRoute.Sitemap = [
{ {
url: buildAbsoluteUrl("/"), url: buildAbsoluteUrl("/"),
@ -83,7 +86,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let facilities: SitemapFacility[] = []; let facilities: SitemapFacility[] = [];
try { 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) { if (res.ok) {
const data = await res.json(); const data = await res.json();
facilities = Array.isArray(data) ? data : []; facilities = Array.isArray(data) ? data : [];

View file

@ -20,6 +20,7 @@ import {
createItemListJsonLd, createItemListJsonLd,
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
import { fetchPublicFacilities } from "@/app/publicFacilities";
type PlacePageData = { type PlacePageData = {
slug?: string; slug?: string;
@ -124,27 +125,13 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
notFound(); notFound();
} }
let facilities: FacilityRecord[] = [];
let placePage: PlacePageData | null = null; let placePage: PlacePageData | null = null;
try { const facilities = await fetchPublicFacilities<FacilityRecord>("place", revalidate);
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 = [];
}
try { try {
const res = await fetch(`${API_URL}/place-pages/${slug}`, { const res = await fetch(`${API_URL}/place-pages/${slug}`, {
cache: "no-store", next: { revalidate },
}); });
if (!res.ok) { if (!res.ok) {

View file

@ -1,5 +1,5 @@
import { API_URL } from "@/config/constants";
import VtgExplorer from "./VtgExplorer"; import VtgExplorer from "./VtgExplorer";
import { fetchPublicFacilities } from "@/app/publicFacilities";
import type { FacilityRecord } from "@/app/facilityData"; import type { FacilityRecord } from "@/app/facilityData";
import { import {
createBreadcrumbJsonLd, createBreadcrumbJsonLd,
@ -7,6 +7,7 @@ import {
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
export const revalidate = 1800;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const pageTitle = "Veien til Golf"; const pageTitle = "Veien til Golf";
@ -20,24 +21,7 @@ export const metadata = createPageMetadata({
}); });
export default async function VtgPage() { export default async function VtgPage() {
let facilities: FacilityRecord[] = []; const facilities = await fetchPublicFacilities<FacilityRecord>("vtg", revalidate);
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 collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle, name: pageTitle,