Før hastighetsøkning

This commit is contained in:
Erol Haagenrud 2026-04-26 09:52:05 +02:00
parent fec5f4e8c6
commit dc7ed19f02
7 changed files with 704 additions and 282 deletions

View file

@ -0,0 +1,137 @@
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
OSLO_TIMEZONE = ZoneInfo("Europe/Oslo")
def get_oslo_today() -> date:
return datetime.now(OSLO_TIMEZONE).date()
async def ensure_course_status_history_table(conn) -> None:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS course_status_history (
id SERIAL PRIMARY KEY,
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
old_status TEXT,
new_status TEXT NOT NULL,
change_source TEXT NOT NULL,
changed_by TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_course_status_history_changed_at
ON course_status_history (changed_at DESC)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_course_status_history_course_id
ON course_status_history (course_id, changed_at DESC)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_course_status_history_facility_id
ON course_status_history (facility_id, changed_at DESC)
"""
)
async def log_course_status_change(
conn,
*,
course_id: int,
facility_id: int,
old_status: str | None,
new_status: str | None,
change_source: str,
changed_by: str | None = None,
) -> bool:
normalized_old = str(old_status or "").strip().lower() or "ukjent"
normalized_new = str(new_status or "").strip().lower() or "ukjent"
normalized_source = str(change_source or "").strip().lower()
normalized_changed_by = str(changed_by or "").strip() or None
if not normalized_source:
raise ValueError("change_source må være satt")
if normalized_old == normalized_new:
return False
await conn.execute(
"""
INSERT INTO course_status_history (
course_id,
facility_id,
old_status,
new_status,
change_source,
changed_by
)
VALUES ($1, $2, $3, $4, $5, $6)
""",
course_id,
facility_id,
normalized_old,
normalized_new,
normalized_source,
normalized_changed_by,
)
return True
async def list_course_status_history(conn, *, changed_on: date | None = None, limit: int = 100):
target_date = changed_on or get_oslo_today()
start_at = datetime.combine(target_date, time.min, tzinfo=OSLO_TIMEZONE)
end_at = start_at + timedelta(days=1)
rows = await conn.fetch(
"""
SELECT
h.id,
h.course_id,
h.facility_id,
h.old_status,
h.new_status,
h.change_source,
h.changed_by,
h.changed_at,
c.name AS course_name,
f.name AS facility_name,
f.slug AS facility_slug
FROM course_status_history h
JOIN courses c ON c.id = h.course_id
JOIN facilities f ON f.id = h.facility_id
WHERE h.changed_at >= $1
AND h.changed_at < $2
ORDER BY h.changed_at DESC, h.id DESC
LIMIT $3
""",
start_at,
end_at,
limit,
)
return [
{
"id": row["id"],
"course_id": row["course_id"],
"facility_id": row["facility_id"],
"old_status": row["old_status"],
"new_status": row["new_status"],
"change_source": row["change_source"],
"changed_by": row["changed_by"],
"changed_at": row["changed_at"].isoformat() if row["changed_at"] else None,
"course_name": row["course_name"],
"facility_name": row["facility_name"],
"facility_slug": row["facility_slug"],
}
for row in rows
]

View file

@ -22,6 +22,7 @@ import re
import secrets import secrets
import hashlib import hashlib
import smtplib import smtplib
import unicodedata
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path from pathlib import Path
@ -41,6 +42,12 @@ from scrape_jobs import (
ensure_scrape_jobs_table, ensure_scrape_jobs_table,
list_scrape_jobs, list_scrape_jobs,
) )
from course_status_history import (
ensure_course_status_history_table,
get_oslo_today,
list_course_status_history,
log_course_status_change,
)
from env_config import get_database_url, get_required_env from env_config import get_database_url, get_required_env
from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows
from weather_forecast import ensure_weather_forecast_table, weather_sync_loop from weather_forecast import ensure_weather_forecast_table, weather_sync_loop
@ -679,6 +686,43 @@ class PublicFacilityFeedbackRequest(BaseModel):
message: str message: str
website: Optional[str] = "" website: Optional[str] = ""
started_at: Optional[int] = None started_at: Optional[int] = None
LEGACY_FACILITY_FIELD_ALIASES = {
'vtg_presentasjon': 'vtg_beskrivelse',
'vtg_kursdatoer': 'vtg_datoer',
}
FACILITY_ALLOWED_FIELDS = [
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
'image_url', 'logo_url', 'front_image_url', 'gallery',
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft',
'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published',
'golfpakker_draft', 'golfpakker_updated_at'
]
FACILITY_MEMBERSHIP_FIELDS = {
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
}
FACILITY_VTG_CONTENT_FIELDS = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'}
FACILITY_VTG_COURSE_FIELDS = {'vtg_datoer'}
FACILITY_VTG_FIELDS = FACILITY_VTG_CONTENT_FIELDS | FACILITY_VTG_COURSE_FIELDS | {
'vtg_updated_at',
'vtg_content_updated_at',
'vtg_courses_updated_at',
}
# --- FUNKSJONER --- # --- FUNKSJONER ---
def format_row(row): def format_row(row):
""" """
@ -1151,6 +1195,227 @@ def sanitize_featured_media_id(featured_media_id: str | None, media_gallery: lis
return None return None
def normalize_facility_slug(value: Any) -> str:
normalized = unicodedata.normalize("NFKD", str(value or "").strip().lower())
normalized = "".join(char for char in normalized if not unicodedata.combining(char))
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
return normalized.strip("-")
def apply_legacy_facility_field_aliases(data: dict[str, Any] | None) -> dict[str, Any]:
normalized = dict(data or {})
for legacy_field, canonical_field in LEGACY_FACILITY_FIELD_ALIASES.items():
if legacy_field in normalized and canonical_field not in normalized:
normalized[canonical_field] = normalized[legacy_field]
return normalized
def schedule_facility_indexnow_submission_for_fields(
facility_slug: str,
changed_field_names: set[str],
reason: str,
):
extra_paths = ["/golfbaner"]
if changed_field_names & FACILITY_MEMBERSHIP_FIELDS:
extra_paths.append("/medlemskap")
if changed_field_names & FACILITY_VTG_FIELDS:
extra_paths.append("/vtg")
schedule_indexnow_submission(
collect_facility_indexnow_urls([facility_slug], extra_paths=extra_paths),
reason=reason,
)
async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tuple[str, set[str]]:
normalized_data = apply_legacy_facility_field_aliases(data)
update_data = {k: v for k, v in normalized_data.items() if k in FACILITY_ALLOWED_FIELDS}
changed_field_names = set(update_data.keys())
facility_slug = str(
await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
).strip()
if not facility_slug:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
facility_columns = await get_table_columns(conn, "facilities")
update_data = {k: v for k, v in update_data.items() if k in facility_columns}
if update_data:
if 'footnote' in update_data and 'footnote_updated_at' not in update_data:
existing_footnote = await conn.fetchval(
"SELECT footnote FROM facilities WHERE id = $1",
facility_id
)
incoming_footnote = str(update_data.get('footnote') or '').strip()
current_footnote = str(existing_footnote or '').strip()
if incoming_footnote != current_footnote:
update_data['footnote_updated_at'] = datetime.utcnow() if incoming_footnote else None
set_clauses = []
values = []
date_fields = [
'membership_updated_at',
'greenfee_updated_at',
'vtg_updated_at',
'vtg_content_updated_at',
'vtg_courses_updated_at',
'status_updated_at',
'footnote_updated_at',
'golfpakker_updated_at'
]
if changed_field_names & FACILITY_VTG_CONTENT_FIELDS:
vtg_content_ts = datetime.utcnow()
update_data.setdefault('vtg_content_updated_at', vtg_content_ts)
update_data.setdefault('vtg_updated_at', vtg_content_ts)
if changed_field_names & FACILITY_VTG_COURSE_FIELDS:
vtg_course_ts = datetime.utcnow()
update_data.setdefault('vtg_courses_updated_at', vtg_course_ts)
update_data.setdefault('vtg_updated_at', vtg_course_ts)
for i, (k, v) in enumerate(update_data.items(), 1):
if isinstance(v, (dict, list)):
set_clauses.append(f"{k} = ${i}::jsonb")
values.append(json.dumps(v))
elif k in date_fields:
set_clauses.append(f"{k} = ${i}")
if v == "" or v is None:
values.append(None)
else:
dt_str = str(v).replace("Z", "+00:00")
try:
dt_obj = datetime.fromisoformat(dt_str)
values.append(dt_obj)
except ValueError:
values.append(None)
else:
set_clauses.append(f"{k} = ${i}")
values.append(v)
values.append(facility_id)
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
await conn.execute(query, *values)
if 'vtg_datoer' in update_data:
await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer'))
if 'courses' in normalized_data:
submitted_courses = [course for course in (normalized_data.get('courses') or []) if course]
normalized_courses: list[dict[str, Any]] = []
for course in submitted_courses:
normalized_course = dict(course)
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
normalized_courses.append(normalized_course)
if normalized_courses:
if not any(course['is_main_course'] for course in normalized_courses):
normalized_courses[0]['is_main_course'] = True
else:
main_assigned = False
for course in normalized_courses:
if course['is_main_course'] and not main_assigned:
main_assigned = True
else:
course['is_main_course'] = False
retained_course_ids: list[int] = []
for course in normalized_courses:
course_id = course.get('id')
holes = [hole for hole in (course.get('holes') or []) if hole]
hole_count = len(holes) or None
course_par = parse_optional_int(course.get('par'))
course_length_meters = parse_optional_int(course.get('length_meters'))
valid_until_str = course.get('slope_valid_until')
if valid_until_str == "" or valid_until_str is None:
valid_until = None
else:
try:
date_part = str(valid_until_str).split('T')[0]
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
except ValueError:
valid_until = None
tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
if course_id:
await conn.execute("""
UPDATE courses
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
slope_valid_until=$9
WHERE id=$10 AND facility_id=$11
""",
course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until, course_id, facility_id)
else:
course_id = await conn.fetchval("""
INSERT INTO courses (
facility_id, name, holes, par, length_meters, architect,
status, is_main_course, tee_boxes, slope_valid_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id
""",
facility_id, course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until)
retained_course_ids.append(int(course_id))
retained_hole_ids: list[int] = []
for hole in holes:
hole_id = hole.get('id')
hole_number = parse_optional_int(hole.get('hole_number'))
hole_par = parse_optional_int(hole.get('par'))
hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
lengths_json = json.dumps(hole.get('lengths') or {})
if hole_id:
await conn.execute("""
UPDATE holes
SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
WHERE id=$5 AND course_id=$6
""",
hole_number, hole_par, hole_hcp_index,
lengths_json, hole_id, course_id)
else:
hole_id = await conn.fetchval("""
INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id
""",
course_id, hole_number, hole_par, hole_hcp_index,
lengths_json)
retained_hole_ids.append(int(hole_id))
if retained_hole_ids:
await conn.execute(
"DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
course_id,
retained_hole_ids,
)
else:
await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
if retained_course_ids:
await conn.execute(
"DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
facility_id,
retained_course_ids,
)
else:
await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
return facility_slug, changed_field_names
def build_hero_images_from_media_gallery( def build_hero_images_from_media_gallery(
media_gallery: list[dict[str, str]], media_gallery: list[dict[str, str]],
fallback_hero_images: list[dict[str, str]], fallback_hero_images: list[dict[str, str]],
@ -1825,6 +2090,7 @@ async def lifespan(app: FastAPI):
await ensure_articles_table(conn) await ensure_articles_table(conn)
await ensure_public_user_tables(conn) await ensure_public_user_tables(conn)
await ensure_scrape_jobs_table(conn) await ensure_scrape_jobs_table(conn)
await ensure_course_status_history_table(conn)
await ensure_weather_forecast_table(conn) await ensure_weather_forecast_table(conn)
app.state.weather_sync_stop_event = asyncio.Event() app.state.weather_sync_stop_event = asyncio.Event()
app.state.weather_sync_task = asyncio.create_task( app.state.weather_sync_task = asyncio.create_task(
@ -2827,6 +3093,70 @@ async def get_admin_facility(slug: str):
return format_row(row) return format_row(row)
@app.post("/api/admin/facilities")
async def create_admin_facility(request: Request):
"""Oppretter et nytt golfanlegg og lagrer full editor-payload i samme operasjon."""
data = apply_legacy_facility_field_aliases(await request.json())
facility_name = str(data.get("name") or "").strip()
if not facility_name:
raise HTTPException(status_code=400, detail="Anleggsnavn mangler.")
normalized_slug = normalize_facility_slug(data.get("slug") or facility_name)
if not normalized_slug:
raise HTTPException(status_code=400, detail="Slug mangler eller er ugyldig.")
async with app.state.pool.acquire() as conn:
async with conn.transaction():
existing_id = await conn.fetchval(
"SELECT id FROM facilities WHERE slug = $1",
normalized_slug,
)
if existing_id:
raise HTTPException(status_code=409, detail="Slug er allerede i bruk.")
facility_columns = await get_table_columns(conn, "facilities")
insert_fields = ["name", "slug"]
insert_values: list[Any] = [facility_name, normalized_slug]
if "is_published" in facility_columns:
insert_fields.append("is_published")
insert_values.append(bool(data.get("is_published")) if "is_published" in data else False)
placeholders = ", ".join(f"${index}" for index in range(1, len(insert_values) + 1))
created = await conn.fetchrow(
f"""
INSERT INTO facilities ({", ".join(insert_fields)})
VALUES ({placeholders})
RETURNING id
""",
*insert_values,
)
facility_id = int(created["id"])
data["name"] = facility_name
data["slug"] = normalized_slug
if "is_published" in facility_columns and "is_published" not in data:
data["is_published"] = False
facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
saved_row = await conn.fetchrow(
"SELECT id, slug, name, is_published FROM facilities WHERE id = $1",
facility_id,
)
schedule_facility_indexnow_submission_for_fields(
facility_slug,
changed_field_names,
reason="facility create",
)
return {
"status": "success",
"message": "Golfanlegget ble opprettet.",
"facility": format_row(saved_row),
}
@app.get("/api/admin/place-pages/{slug}") @app.get("/api/admin/place-pages/{slug}")
async def get_admin_place_page(slug: str): async def get_admin_place_page(slug: str):
normalized_slug = str(slug or "").strip().lower() normalized_slug = str(slug or "").strip().lower()
@ -3268,7 +3598,7 @@ async def delete_admin_article(article_id: int):
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings") @app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate): async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate, http_request: Request):
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL).""" """Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
try: try:
@ -3295,7 +3625,29 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte # Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
if settings.scrape_method == 'manual' and settings.courses: if settings.scrape_method == 'manual' and settings.courses:
for c in settings.courses: for c in settings.courses:
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id) current_course = await conn.fetchrow(
"SELECT id, facility_id, status FROM courses WHERE id = $1 AND facility_id = $2",
c.id,
facility_id,
)
if not current_course:
continue
old_status = current_course["status"] or "ukjent"
new_status = c.status
if str(old_status or "").strip().lower() == str(new_status or "").strip().lower():
continue
await log_course_status_change(
conn,
course_id=int(current_course["id"]),
facility_id=int(current_course["facility_id"]),
old_status=old_status,
new_status=new_status,
change_source="manual",
changed_by=getattr(http_request.state, "admin_username", None),
)
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c.id)
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."}
@ -3308,241 +3660,15 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
@app.put("/api/admin/facilities/{facility_id}/full") @app.put("/api/admin/facilities/{facility_id}/full")
async def update_facility_full(facility_id: int, request: Request): async def update_facility_full(facility_id: int, request: Request):
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren).""" """Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
data = await request.json() data = apply_legacy_facility_field_aliases(await request.json())
legacy_field_aliases = {
'vtg_presentasjon': 'vtg_beskrivelse',
'vtg_kursdatoer': 'vtg_datoer',
}
for legacy_field, canonical_field in legacy_field_aliases.items():
if legacy_field in data and canonical_field not in data:
data[canonical_field] = data[legacy_field]
# Felter som er trygge å oppdatere manuelt på anlegget
allowed_fields = [
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
'image_url', 'logo_url', 'front_image_url', 'gallery',
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft',
'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published',
'golfpakker_draft', 'golfpakker_updated_at'
]
update_data = {k: v for k, v in data.items() if k in allowed_fields}
membership_fields = {
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at'
}
vtg_content_fields = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'}
vtg_course_fields = {'vtg_datoer'}
vtg_fields = vtg_content_fields | vtg_course_fields | {'vtg_updated_at', 'vtg_content_updated_at', 'vtg_courses_updated_at'}
changed_field_names = set(update_data.keys())
facility_slug = ""
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
async with conn.transaction(): # Sikrer at alt lagres samlet async with conn.transaction():
facility_slug = str( facility_slug, changed_field_names = await save_facility_full(conn, facility_id, data)
await conn.fetchval("SELECT slug FROM facilities WHERE id = $1", facility_id) or ""
).strip()
facility_columns = await get_table_columns(conn, "facilities")
update_data = {k: v for k, v in update_data.items() if k in facility_columns}
# 1. OPPDATER ANLEGG (FACILITIES)
if update_data:
if 'footnote' in update_data and 'footnote_updated_at' not in update_data:
existing_footnote = await conn.fetchval(
"SELECT footnote FROM facilities WHERE id = $1",
facility_id
)
incoming_footnote = str(update_data.get('footnote') or '').strip()
current_footnote = str(existing_footnote or '').strip()
if incoming_footnote != current_footnote: schedule_facility_indexnow_submission_for_fields(
update_data['footnote_updated_at'] = datetime.utcnow() if incoming_footnote else None facility_slug,
changed_field_names,
set_clauses = []
values = []
# Definer hvilke felt som er datoer i databasen
date_fields = [
'membership_updated_at',
'greenfee_updated_at',
'vtg_updated_at',
'vtg_content_updated_at',
'vtg_courses_updated_at',
'status_updated_at',
'footnote_updated_at',
'golfpakker_updated_at'
]
if changed_field_names & vtg_content_fields:
vtg_content_ts = datetime.utcnow()
update_data.setdefault('vtg_content_updated_at', vtg_content_ts)
update_data.setdefault('vtg_updated_at', vtg_content_ts)
if changed_field_names & vtg_course_fields:
vtg_course_ts = datetime.utcnow()
update_data.setdefault('vtg_courses_updated_at', vtg_course_ts)
update_data.setdefault('vtg_updated_at', vtg_course_ts)
for i, (k, v) in enumerate(update_data.items(), 1):
if isinstance(v, (dict, list)):
set_clauses.append(f"{k} = ${i}::jsonb")
values.append(json.dumps(v))
elif k in date_fields:
set_clauses.append(f"{k} = ${i}")
# Håndter tomme datoer og konverter til Python datetime
if v == "" or v is None:
values.append(None)
else:
# Tving strengen over til et ekte datetime-objekt.
# .replace() håndterer Next.js' "Z"-format.
dt_str = str(v).replace("Z", "+00:00")
try:
dt_obj = datetime.fromisoformat(dt_str)
values.append(dt_obj)
except ValueError:
values.append(None)
else:
set_clauses.append(f"{k} = ${i}")
values.append(v)
values.append(facility_id)
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
await conn.execute(query, *values)
if 'vtg_datoer' in update_data:
await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer'))
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
if 'courses' in data:
submitted_courses = [course for course in (data.get('courses') or []) if course]
normalized_courses: list[dict[str, Any]] = []
for index, course in enumerate(submitted_courses):
normalized_course = dict(course)
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
normalized_courses.append(normalized_course)
if normalized_courses:
if not any(course['is_main_course'] for course in normalized_courses):
normalized_courses[0]['is_main_course'] = True
else:
main_assigned = False
for course in normalized_courses:
if course['is_main_course'] and not main_assigned:
main_assigned = True
else:
course['is_main_course'] = False
retained_course_ids: list[int] = []
for course in normalized_courses:
course_id = course.get('id')
holes = [hole for hole in (course.get('holes') or []) if hole]
hole_count = len(holes) or None
course_par = parse_optional_int(course.get('par'))
course_length_meters = parse_optional_int(course.get('length_meters'))
valid_until_str = course.get('slope_valid_until')
if valid_until_str == "" or valid_until_str is None:
valid_until = None
else:
try:
date_part = str(valid_until_str).split('T')[0]
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
except ValueError:
valid_until = None
tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
if course_id:
await conn.execute("""
UPDATE courses
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
slope_valid_until=$9
WHERE id=$10 AND facility_id=$11
""",
course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until, course_id, facility_id)
else:
course_id = await conn.fetchval("""
INSERT INTO courses (
facility_id, name, holes, par, length_meters, architect,
status, is_main_course, tee_boxes, slope_valid_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id
""",
facility_id, course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until)
retained_course_ids.append(int(course_id))
retained_hole_ids: list[int] = []
for hole in holes:
hole_id = hole.get('id')
hole_number = parse_optional_int(hole.get('hole_number'))
hole_par = parse_optional_int(hole.get('par'))
hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
lengths_json = json.dumps(hole.get('lengths') or {})
if hole_id:
await conn.execute("""
UPDATE holes
SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
WHERE id=$5 AND course_id=$6
""",
hole_number, hole_par, hole_hcp_index,
lengths_json, hole_id, course_id)
else:
hole_id = await conn.fetchval("""
INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id
""",
course_id, hole_number, hole_par, hole_hcp_index,
lengths_json)
retained_hole_ids.append(int(hole_id))
if retained_hole_ids:
await conn.execute(
"DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
course_id,
retained_hole_ids,
)
else:
await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
if retained_course_ids:
await conn.execute(
"DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
facility_id,
retained_course_ids,
)
else:
await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
extra_paths = ["/golfbaner"]
if changed_field_names & membership_fields:
extra_paths.append("/medlemskap")
if changed_field_names & vtg_fields:
extra_paths.append("/vtg")
schedule_indexnow_submission(
collect_facility_indexnow_urls([facility_slug], extra_paths=extra_paths),
reason="facility full update", reason="facility full update",
) )
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."} return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
@ -3580,6 +3706,20 @@ async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit:
return await list_scrape_jobs(app.state.pool, job_type=job_type, limit=limit) return await list_scrape_jobs(app.state.pool, job_type=job_type, limit=limit)
@app.get("/api/admin/course-status-history")
async def get_admin_course_status_history(
changed_on: Optional[date] = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
):
"""Henter banestatusendringer for en gitt dato, med Oslo som standard for 'i dag'."""
async with app.state.pool.acquire() as conn:
return await list_course_status_history(
conn,
changed_on=changed_on or get_oslo_today(),
limit=limit,
)
@app.post("/api/admin/2fa/setup") @app.post("/api/admin/2fa/setup")
async def get_admin_2fa_setup(request: AdminPasswordConfirm, http_request: Request): async def get_admin_2fa_setup(request: AdminPasswordConfirm, http_request: Request):
"""Verifiserer passord på nytt og returnerer TOTP-oppsett for 1Password/Authenticator.""" """Verifiserer passord på nytt og returnerer TOTP-oppsett for 1Password/Authenticator."""

View file

@ -0,0 +1,31 @@
import { cookies } from "next/headers";
import { API_URL } from "@/config/constants";
import EditFacilityClient from "../rediger/[slug]/EditFacilityClient";
const EMPTY_FACILITY = {
name: "",
slug: "",
is_published: false,
courses: [],
gallery: [],
greenfee: [],
golfpakker: [],
social_links: [],
vtg_datoer: [],
cooperating_clubs: [],
amenities: {},
nsg_data: {},
golfamore_data: {},
};
export default async function NewFacilityPage() {
const cookieHeader = (await cookies()).toString();
const allRes = await fetch(`${API_URL}/admin/facilities`, {
cache: "no-store",
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
});
const allFacilities = allRes.ok ? await allRes.json() : [];
return <EditFacilityClient initialData={EMPTY_FACILITY} allFacilities={allFacilities} />;
}

View file

@ -1462,6 +1462,12 @@ export default function AdminDashboard() {
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/nytt-anlegg"
className="btn btn-md btn-primary"
>
Nytt anlegg
</Link>
<Link <Link
href="/admin/steder" href="/admin/steder"
className="btn btn-md btn-secondary" className="btn btn-md btn-secondary"

View file

@ -472,6 +472,30 @@ const normalizeStringList = (value: any): string[] => {
return []; return [];
}; };
const normalizeStringArray = (value: any): string[] => {
if (Array.isArray(value)) {
return value.map((entry) => String(entry || "").trim()).filter(Boolean);
}
if (typeof value === 'string') {
try {
return normalizeStringArray(JSON.parse(value));
} catch {
return [];
}
}
return [];
};
const slugify = (value: string) =>
value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const getMediaFieldLabel = (field: string) => { const getMediaFieldLabel = (field: string) => {
if (field === 'image_url') return 'hovedbildet'; if (field === 'image_url') return 'hovedbildet';
if (field === 'logo_url') return 'logoen'; if (field === 'logo_url') return 'logoen';
@ -480,9 +504,10 @@ const getMediaFieldLabel = (field: string) => {
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) { export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
const router = useRouter(); const router = useRouter();
const isCreateMode = typeof initialData?.id !== 'number';
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
...initialData, ...initialData,
is_published: initialData?.is_published !== false, is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false,
courses: Array.isArray(initialData?.courses) ? initialData.courses : [], courses: Array.isArray(initialData?.courses) ? initialData.courses : [],
}); });
const [activeTab, setActiveTab] = useState('generelt'); const [activeTab, setActiveTab] = useState('generelt');
@ -490,6 +515,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
const [deletingFacility, setDeletingFacility] = useState(false); const [deletingFacility, setDeletingFacility] = useState(false);
const [mediaFeedback, setMediaFeedback] = useState(""); const [mediaFeedback, setMediaFeedback] = useState("");
const [uploadingTarget, setUploadingTarget] = useState<string | null>(null); const [uploadingTarget, setUploadingTarget] = useState<string | null>(null);
const [slugTouched, setSlugTouched] = useState(Boolean(initialData?.slug));
const mainImageInputRef = useRef<HTMLInputElement | null>(null); const mainImageInputRef = useRef<HTMLInputElement | null>(null);
const logoImageInputRef = useRef<HTMLInputElement | null>(null); const logoImageInputRef = useRef<HTMLInputElement | null>(null);
const galleryInputRef = useRef<HTMLInputElement | null>(null); const galleryInputRef = useRef<HTMLInputElement | null>(null);
@ -499,14 +525,27 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
// Sørg for at cooperating_clubs er et array // Sørg for at cooperating_clubs er et array
const [coopClubs, setCoopClubs] = useState<string[]>( const [coopClubs, setCoopClubs] = useState<string[]>(
Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs : normalizeStringArray(initialData?.cooperating_clubs)
(typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : [])
); );
const facilityName = String(formData.name || initialData?.name || "").trim() || "Nytt anlegg";
const facilitySlug = String(formData.slug || initialData?.slug || "").trim();
const publicFacilityUrl = facilitySlug ? `/golfbaner/${facilitySlug}` : "";
const handleChange = (field: string, value: any) => { const handleChange = (field: string, value: any) => {
setFormData((prev: any) => ({ ...prev, [field]: value })); setFormData((prev: any) => ({ ...prev, [field]: value }));
}; };
const handleNameChange = (value: string) => {
setFormData((prev: any) => {
const next = { ...prev, name: value };
if (isCreateMode && !slugTouched) {
next.slug = slugify(value);
}
return next;
});
};
const updateCourses = (updater: (courses: any[]) => any[]) => { const updateCourses = (updater: (courses: any[]) => any[]) => {
const nextCourses = updater(Array.isArray(formData.courses) ? formData.courses : []); const nextCourses = updater(Array.isArray(formData.courses) ? formData.courses : []);
handleChange('courses', nextCourses); handleChange('courses', nextCourses);
@ -656,17 +695,31 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
const res = await adminFetch(`/api/admin/facilities/${initialData.id}/full`, { const endpoint = isCreateMode ? '/api/admin/facilities' : `/api/admin/facilities/${initialData.id}/full`;
method: 'PUT', const method = isCreateMode ? 'POST' : 'PUT';
const res = await adminFetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData) body: JSON.stringify(formData)
}); });
if (res.ok) { if (res.ok) {
alert("Lagret suksessfullt!"); if (isCreateMode) {
router.refresh(); const payload = await res.json();
const createdSlug = String(payload?.facility?.slug || "").trim();
if (!createdSlug) {
alert("Anlegget ble opprettet, men mangler slug i svaret.");
return;
}
router.push(`/admin/rediger/${createdSlug}`);
router.refresh();
} else {
alert("Lagret suksessfullt!");
router.refresh();
}
} else { } else {
alert("Noe gikk galt under lagring."); const error = await res.json().catch(() => null);
alert(error?.detail || "Noe gikk galt under lagring.");
} }
} catch (e) { } catch (e) {
alert("Nettverksfeil."); alert("Nettverksfeil.");
@ -675,6 +728,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
}; };
const handleDeleteFacility = async () => { const handleDeleteFacility = async () => {
if (isCreateMode) return;
const confirmed = window.confirm(`Slette anlegget "${initialData.name}" permanent? Dette fjerner også baner og hull.`); const confirmed = window.confirm(`Slette anlegget "${initialData.name}" permanent? Dette fjerner også baner og hull.`);
if (!confirmed) return; if (!confirmed) return;
@ -723,42 +777,44 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div> <div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link> <Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black text-[#11280f]"> <h1 className="text-4xl font-black text-[#11280f]">
Rediger:{" "} {isCreateMode ? "Nytt anlegg: " : "Rediger: "}
{formData.is_published ? ( {!isCreateMode && formData.is_published && publicFacilityUrl ? (
<Link <Link
href={`/golfbaner/${initialData.slug}`} href={publicFacilityUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#8bc34a]" className="text-[#8bc34a]"
title="Åpne anleggssiden i ny fane" title="Åpne anleggssiden i ny fane"
> >
{initialData.name} {facilityName}
</Link> </Link>
) : ( ) : (
<span className="text-[#8bc34a]">{initialData.name}</span> <span className="text-[#8bc34a]">{facilityName}</span>
)} )}
</h1> </h1>
<p className="mt-3 flex flex-wrap items-center gap-3 text-xs font-black uppercase tracking-widest"> <p className="mt-3 flex flex-wrap items-center gap-3 text-xs font-black uppercase tracking-widest">
<span className={`rounded-xl px-3 py-2 ${formData.is_published ? 'bg-[#8bc34a] text-white' : 'bg-amber-100 text-amber-800'}`}> <span className={`rounded-xl px-3 py-2 ${formData.is_published ? 'bg-[#8bc34a] text-white' : 'bg-amber-100 text-amber-800'}`}>
{formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'} {formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'}
</span> </span>
<span className="rounded-xl bg-gray-100 px-3 py-2 text-gray-500">Slug: {initialData.slug}</span> <span className="rounded-xl bg-gray-100 px-3 py-2 text-gray-500">Slug: {facilitySlug || 'ikke satt ennå'}</span>
</p> </p>
</div> </div>
<div className="flex w-full flex-col gap-3 md:w-auto md:flex-row"> <div className="flex w-full flex-col gap-3 md:w-auto md:flex-row">
<button {!isCreateMode ? (
onClick={handleDeleteFacility} <button
disabled={deletingFacility} onClick={handleDeleteFacility}
className="btn btn-lg btn-danger w-full md:w-auto disabled:opacity-50" disabled={deletingFacility}
> className="btn btn-lg btn-danger w-full md:w-auto disabled:opacity-50"
{deletingFacility ? "Sletter..." : "Slett anlegg"} >
</button> {deletingFacility ? "Sletter..." : "Slett anlegg"}
</button>
) : null}
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50" className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
> >
{saving ? "Lagrer..." : "Lagre endringer"} {saving ? (isCreateMode ? "Oppretter..." : "Lagrer...") : (isCreateMode ? "Opprett anlegg" : "Lagre endringer")}
</button> </button>
</div> </div>
</div> </div>
@ -801,8 +857,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
</div> </div>
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8"> <div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label> <label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label>
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleChange('name', e.target.value)} /> <input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleNameChange(e.target.value)} />
</div> </div>
{isCreateMode ? (
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Slug</label>
<input
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none"
value={facilitySlug}
onChange={e => {
setSlugTouched(true);
handleChange('slug', slugify(e.target.value));
}}
placeholder="f.eks. oslo-golfklubb"
/>
<p className="text-xs text-gray-500">Brukes i URL-en. Hvis du lar feltet stå tomt, foreslås slug automatisk fra navnet.</p>
</div>
) : null}
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8"> <div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label> <label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label>
@ -932,7 +1003,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]"> <div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]">
<div className="aspect-[16/10]"> <div className="aspect-[16/10]">
{getValue('image_url', 'text') ? ( {getValue('image_url', 'text') ? (
<img src={getValue('image_url', 'text')} alt={`${initialData.name} hovedbilde`} className="h-full w-full object-cover" /> <img src={getValue('image_url', 'text')} alt={`${facilityName} hovedbilde`} className="h-full w-full object-cover" />
) : ( ) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-white/70"> <div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-white/70">
Ingen hovedbilde valgt Ingen hovedbilde valgt
@ -978,7 +1049,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#f6f7f3]"> <div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#f6f7f3]">
<div className="aspect-square max-w-[240px]"> <div className="aspect-square max-w-[240px]">
{getValue('logo_url', 'text') ? ( {getValue('logo_url', 'text') ? (
<img src={getValue('logo_url', 'text')} alt={`${initialData.name} logo`} className="h-full w-full object-contain p-4" /> <img src={getValue('logo_url', 'text')} alt={`${facilityName} logo`} className="h-full w-full object-contain p-4" />
) : ( ) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-[#11280f]/45"> <div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-[#11280f]/45">
Ingen logo valgt Ingen logo valgt
@ -1024,7 +1095,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="grid gap-4 lg:grid-cols-[220px,minmax(0,1fr)]"> <div className="grid gap-4 lg:grid-cols-[220px,minmax(0,1fr)]">
<div className="overflow-hidden rounded-[1.25rem] border border-[#112015]/8 bg-[#112015]"> <div className="overflow-hidden rounded-[1.25rem] border border-[#112015]/8 bg-[#112015]">
<div className="aspect-[4/3]"> <div className="aspect-[4/3]">
<img src={url} alt={`${initialData.name} galleri ${index + 1}`} className="h-full w-full object-cover" /> <img src={url} alt={`${facilityName} galleri ${index + 1}`} className="h-full w-full object-cover" />
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">

View file

@ -1,4 +1,3 @@
import { unstable_cache } from "next/cache";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData"; import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData";
@ -115,23 +114,19 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) {
return Object.fromEntries(aliases); return Object.fromEntries(aliases);
} }
const getCachedFacilityAliasMap = unstable_cache( async function getFacilityAliasMap() {
async () => { const response = await fetch(`${API_URL}/facilities?summary=true`, {
const response = await fetch(`${API_URL}/facilities`, { cache: "no-store",
next: { revalidate: 3600 }, });
});
if (!response.ok) { if (!response.ok) {
return {}; return {};
} }
const data = await response.json(); const data = await response.json();
const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : []; const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : [];
return buildFacilityAliasMap(facilities); return buildFacilityAliasMap(facilities);
}, }
["facility-short-aliases"],
{ revalidate: 3600 },
);
export async function resolveFacilityAlias(alias: string) { export async function resolveFacilityAlias(alias: string) {
const normalizedAlias = slugify(alias); const normalizedAlias = slugify(alias);
@ -140,6 +135,6 @@ export async function resolveFacilityAlias(alias: string) {
return MANUAL_FACILITY_ALIASES[normalizedAlias]; return MANUAL_FACILITY_ALIASES[normalizedAlias];
} }
const aliasMap = await getCachedFacilityAliasMap(); const aliasMap = await getFacilityAliasMap();
return aliasMap[normalizedAlias] || null; return aliasMap[normalizedAlias] || null;
} }

View file

@ -37,6 +37,41 @@ const formatPhoneForUrl = (phone: string) => {
return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized; return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized;
}; };
const getYouTubeEmbedUrl = (url: string) => {
const match = url.match(
/(?:youtube\.com\/watch\?v=|youtube\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/i,
);
return match ? `https://www.youtube.com/embed/${match[1]}?rel=0` : null;
};
const getVimeoEmbedUrl = (url: string) => {
const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
return match ? `https://player.vimeo.com/video/${match[1]}` : null;
};
const getFacilityVideoEmbedUrl = (url: string | null | undefined) => {
const raw = String(url || "").trim();
if (!raw) return null;
return getYouTubeEmbedUrl(raw) || getVimeoEmbedUrl(raw) || raw;
};
const getYrMeteogramUrl = (url: string | null | undefined) => {
const raw = String(url || "").trim();
if (!raw) return null;
const normalized = raw.replace(/\/$/, "");
const locationIdMatch = normalized.match(/\/(10-\d+)(?:\/|$)/);
if (locationIdMatch) {
return `https://www.yr.no/nb/innhold/${locationIdMatch[1]}/meteogram.svg`;
}
if (normalized.includes("/graf/dag/")) {
return `${normalized.replace("/graf/dag/", "/innhold/")}/meteogram.svg`;
}
return `${normalized}/meteogram.svg`;
};
const renderValue = (val: unknown, fallback = "Nei") => { const renderValue = (val: unknown, fallback = "Nei") => {
const raw = String(val || "").trim(); const raw = String(val || "").trim();
if (!raw) return fallback; if (!raw) return fallback;
@ -285,7 +320,8 @@ export default function FacilityDetailView({
}; };
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null; const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
const weatherImg = facility.weather_url?.replace("/graf/dag/", "/innhold/").replace(/\/$/, "") + "/meteogram.svg"; const weatherImg = getYrMeteogramUrl(facility.weather_url);
const videoEmbedUrl = getFacilityVideoEmbedUrl(facility.video_url);
const getGalleryImageAlt = (imageUrl: string) => { const getGalleryImageAlt = (imageUrl: string) => {
const normalized = String(imageUrl || "").trim().toLowerCase(); const normalized = String(imageUrl || "").trim().toLowerCase();
if (!normalized) { if (!normalized) {
@ -601,7 +637,7 @@ export default function FacilityDetailView({
<section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center"> <section id="weather" className="bg-white p-0 md:p-12 md:rounded-[3rem] shadow-sm border-b md:border-none overflow-hidden text-center">
<h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3> <h3 className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] py-8 md:py-0 md:mb-10 flex items-center justify-center gap-3"><Icon children={ICONS.weather} /> Vær for {facility.name}</h3>
<div className="w-full flex justify-center px-4 md:px-0"> <div className="w-full flex justify-center px-4 md:px-0">
{facility.weather_url ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt={`Værvarsel for ${facility.name}`} loading="lazy" decoding="async" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>} {weatherImg ? ( <img src={weatherImg} className="w-full h-auto block max-w-5xl" alt={`Værvarsel for ${facility.name}`} loading="lazy" decoding="async" /> ) : <p className="text-center py-24 text-gray-300 italic text-sm">Værvarsel ikke tilgjengelig</p>}
</div> </div>
</section> </section>
@ -624,11 +660,17 @@ export default function FacilityDetailView({
)} )}
{/* 7. VIDEO SEKSJON */} {/* 7. VIDEO SEKSJON */}
{facility.video_url && ( {videoEmbedUrl && (
<section id="video" className="space-y-6"> <section id="video" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2> <h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white"> <div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
<iframe src={facility.video_url} className="w-full h-full" allowFullScreen /> <iframe
src={videoEmbedUrl}
title={`Video fra ${facility.name}`}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div> </div>
</section> </section>
)} )}