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 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.*, (
@ -2985,15 +3272,29 @@ 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",

View file

@ -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>

View file

@ -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) => {

View file

@ -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) {

View file

@ -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;

View file

@ -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)",

View file

@ -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,

View file

@ -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,

View file

@ -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) =>

View file

@ -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">

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_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);

View file

@ -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 : [];

View file

@ -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) {

View file

@ -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,