From a508db6071cf8a0cc5eea7c602eec0a96a4543c4 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 26 Apr 2026 11:29:35 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20fase=20to=20i=20effektiviseringen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 599 ++++++++++++++---- frontend/src/app/FacilitySearch.tsx | 10 +- frontend/src/app/HeroSlider.tsx | 2 +- frontend/src/app/facilityAliases.ts | 4 +- frontend/src/app/facilityData.ts | 1 + .../app/golfbaner/[slug]/opengraph-image.tsx | 2 +- frontend/src/app/golfbaner/page.tsx | 24 +- frontend/src/app/klubbnummer/page.tsx | 22 +- frontend/src/app/medlemskap/page.tsx | 22 +- frontend/src/app/page.tsx | 24 +- frontend/src/app/publicFacilities.ts | 47 ++ frontend/src/app/seo.ts | 2 +- frontend/src/app/sitemap.ts | 7 +- frontend/src/app/sted/[slug]/page.tsx | 19 +- frontend/src/app/vtg/page.tsx | 22 +- 15 files changed, 548 insertions(+), 259 deletions(-) create mode 100644 frontend/src/app/publicFacilities.ts diff --git a/backend/main.py b/backend/main.py index d88e4f6..fee17d5 100644 --- a/backend/main.py +++ b/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", diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index be16cb1..4a56bac 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -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(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({ ))} - + + diff --git a/frontend/src/app/HeroSlider.tsx b/frontend/src/app/HeroSlider.tsx index f97e5bb..67fa806 100755 --- a/frontend/src/app/HeroSlider.tsx +++ b/frontend/src/app/HeroSlider.tsx @@ -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) => { diff --git a/frontend/src/app/facilityAliases.ts b/frontend/src/app/facilityAliases.ts index 49e2cce..5acca04 100644 --- a/frontend/src/app/facilityAliases.ts +++ b/frontend/src/app/facilityAliases.ts @@ -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) { diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts index c7d2294..83f1ff4 100755 --- a/frontend/src/app/facilityData.ts +++ b/frontend/src/app/facilityData.ts @@ -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; diff --git a/frontend/src/app/golfbaner/[slug]/opengraph-image.tsx b/frontend/src/app/golfbaner/[slug]/opengraph-image.tsx index 9c877e3..a548f3c 100755 --- a/frontend/src/app/golfbaner/[slug]/opengraph-image.tsx +++ b/frontend/src/app/golfbaner/[slug]/opengraph-image.tsx @@ -106,7 +106,7 @@ export default async function OpenGraphImage({ params }: OpenGraphImageProps) { >
("search", revalidate); const collectionJsonLd = createCollectionPageJsonLd({ name: pageTitle, description: pageDescription, diff --git a/frontend/src/app/klubbnummer/page.tsx b/frontend/src/app/klubbnummer/page.tsx index 986aee2..4c4897e 100644 --- a/frontend/src/app/klubbnummer/page.tsx +++ b/frontend/src/app/klubbnummer/page.tsx @@ -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("clubnumbers", revalidate); const collectionJsonLd = createCollectionPageJsonLd({ name: pageTitle, diff --git a/frontend/src/app/medlemskap/page.tsx b/frontend/src/app/medlemskap/page.tsx index b6c17d9..afb5100 100755 --- a/frontend/src/app/medlemskap/page.tsx +++ b/frontend/src/app/medlemskap/page.tsx @@ -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("membership", revalidate); const visibleFacilities = facilities.filter( (facility) => diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index d55f155..61a2314 100755 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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("search", revalidate); return (
diff --git a/frontend/src/app/publicFacilities.ts b/frontend/src/app/publicFacilities.ts new file mode 100644 index 0000000..f73cf7c --- /dev/null +++ b/frontend/src/app/publicFacilities.ts @@ -0,0 +1,47 @@ +import { API_URL } from "@/config/constants"; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function fetchPublicFacilities( + view: string, + revalidateSeconds: number, + { + allowEmpty = false, + attempts = 3, + }: { + allowEmpty?: boolean; + attempts?: number; + } = {}, +): Promise { + 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"); +} diff --git a/frontend/src/app/seo.ts b/frontend/src/app/seo.ts index 9840763..bb031f4 100755 --- a/frontend/src/app/seo.ts +++ b/frontend/src/app/seo.ts @@ -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); diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index be74694..fa0fed3 100755 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -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 { 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 : []; diff --git a/frontend/src/app/sted/[slug]/page.tsx b/frontend/src/app/sted/[slug]/page.tsx index 0d5e83c..3c381f4 100755 --- a/frontend/src/app/sted/[slug]/page.tsx +++ b/frontend/src/app/sted/[slug]/page.tsx @@ -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("place", revalidate); try { const res = await fetch(`${API_URL}/place-pages/${slug}`, { - cache: "no-store", + next: { revalidate }, }); if (!res.ok) { diff --git a/frontend/src/app/vtg/page.tsx b/frontend/src/app/vtg/page.tsx index 0ed9e70..c289258 100755 --- a/frontend/src/app/vtg/page.tsx +++ b/frontend/src/app/vtg/page.tsx @@ -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("vtg", revalidate); const collectionJsonLd = createCollectionPageJsonLd({ name: pageTitle,