diff --git a/backend/main.py b/backend/main.py index 673b47a..57ca05c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -302,6 +302,11 @@ class GreenfeeApproval(BaseModel): greenfee: List[dict] +class GolfpakkerApproval(BaseModel): + facility_id: int + golfpakker: List[dict] + + class VtgApproval(BaseModel): facility_id: int vtg_pris: int | None @@ -312,6 +317,10 @@ class BulkVtgRequest(BaseModel): approvals: List[VtgApproval] +class BulkGolfpakkerRequest(BaseModel): + approvals: List[GolfpakkerApproval] + + class AdminPasswordConfirm(BaseModel): password: str @@ -358,7 +367,8 @@ def format_row(row): for key in [ 'status_updated_at', 'created_at', 'slope_valid_until', - 'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at' + 'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at', + 'golfpakker_updated_at' ]: if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat() @@ -369,7 +379,7 @@ def format_row(row): ] json_dict_fields = [ 'amenities', 'vtg', 'nsg_data', 'golfamore_data', - 'membership_draft', 'greenfee_draft', 'vtg_draft' + 'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft' ] for field in json_list_fields: @@ -397,10 +407,80 @@ def format_row(row): d[field] = {} elif not isinstance(val, dict): d[field] = {} - + return d +def normalize_club_lookup_value(value: str | None) -> str: + text = str(value or "").strip().lower() + if not text: + return "" + + text = text.replace("&", " og ") + text = re.sub(r"\bgk\b", " golfklubb ", text) + text = re.sub(r"\bgs\b", " golfsenter ", text) + text = re.sub(r"[^a-z0-9æøå]+", " ", text) + text = re.sub(r"\s+", " ", text).strip() + return text + + +async def resolve_cooperating_club_slugs( + conn, + suggested_names: list[str] | None, + *, + exclude_facility_id: int | None = None, +) -> list[str]: + cleaned_names = [str(name or "").strip() for name in (suggested_names or []) if str(name or "").strip()] + if not cleaned_names: + return [] + + rows = await conn.fetch("SELECT id, name, slug FROM facilities ORDER BY name ASC") + candidates: list[tuple[str, str]] = [] + for row in rows: + facility_id = int(row["id"]) + if exclude_facility_id and facility_id == exclude_facility_id: + continue + + name = str(row["name"] or "").strip() + slug = str(row["slug"] or "").strip() + normalized_candidates = " ".join( + part for part in [ + normalize_club_lookup_value(name), + normalize_club_lookup_value(slug.replace("-", " ")), + ] if part + ).strip() + candidates.append((slug, normalized_candidates)) + + resolved_slugs: list[str] = [] + seen_slugs: set[str] = set() + + for suggested_name in cleaned_names: + normalized_suggestion = normalize_club_lookup_value(suggested_name) + if not normalized_suggestion: + continue + + exact_matches = [candidate for candidate in candidates if candidate[1] == normalized_suggestion] + if len(exact_matches) == 1: + slug = exact_matches[0][0] + if slug and slug not in seen_slugs: + resolved_slugs.append(slug) + seen_slugs.add(slug) + continue + + partial_matches = [ + candidate + for candidate in candidates + if normalized_suggestion in candidate[1] or candidate[1] in normalized_suggestion + ] + if len(partial_matches) == 1: + slug = partial_matches[0][0] + if slug and slug not in seen_slugs: + resolved_slugs.append(slug) + seen_slugs.add(slug) + + return resolved_slugs + + def generate_totp_qr_svg(provisioning_uri: str) -> str: image = qrcode.make( provisioning_uri, @@ -782,7 +862,10 @@ async def ensure_facility_columns(conn): """Legger til nye facility-kolonner ved behov.""" await conn.execute(""" ALTER TABLE facilities - ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ + ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS golfpakker_url TEXT, + ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB, + ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ """) @@ -1845,8 +1928,9 @@ async def update_facility_full(facility_id: int, request: Request): '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', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke', - 'footnote_updated_at' + 'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', + 'vtg_updated_at', 'vtg_draft', 'footnote_updated_at', + 'golfpakker_draft', 'golfpakker_updated_at' ] update_data = {k: v for k, v in data.items() if k in allowed_fields} @@ -1876,7 +1960,8 @@ async def update_facility_full(facility_id: int, request: Request): 'greenfee_updated_at', 'vtg_updated_at', 'status_updated_at', - 'footnote_updated_at' + 'footnote_updated_at', + 'golfpakker_updated_at' ] for i, (k, v) in enumerate(update_data.items(), 1): @@ -2073,15 +2158,28 @@ async def approve_membership_bulk(request: BulkApprovalRequest): @app.patch("/api/admin/facilities/{facility_id}/quick-edit") async def quick_edit_facility(facility_id: int, request: QuickEditRequest): """Lyn-redigering av enkle URL-felter fra admin-dashbordet.""" - # Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering - allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector'] + # Sikkerhet: Tillat KUN disse URL-/tekstfeltene for hurtigredigering + allowed_fields = ['scrape_status_url', 'medlemskap_url', 'greenfee_url', 'golfpakker_url', 'vtg_lenke', 'scrape_status_selector', 'footnote', 'website_url'] if request.field not in allowed_fields: raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.") async with app.state.pool.acquire() as conn: - # 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) + if request.field == 'footnote': + normalized_value = str(request.value or '').strip() or None + await conn.execute( + """ + UPDATE facilities + SET footnote = $1, + footnote_updated_at = CASE WHEN $1 IS NULL THEN NULL ELSE NOW() END + WHERE id = $2 + """, + normalized_value, + facility_id + ) + else: + # 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) return {"status": "success"} # --- GREENFEE "VASKERI" ENDEPUNKTER --- @@ -2108,13 +2206,30 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest): async with app.state.pool.acquire() as conn: async with conn.transaction(): for approval in request.approvals: + draft_row = await conn.fetchrow( + "SELECT greenfee_draft FROM facilities WHERE id = $1", + approval.facility_id + ) + draft_payload = format_row(draft_row) if draft_row else {} + draft_data = draft_payload.get("greenfee_draft", {}) if isinstance(draft_payload, dict) else {} + suggested_clubs = draft_data.get("foreslatt_avtaleklubber", []) if isinstance(draft_data, dict) else [] + cooperating_club_slugs = await resolve_cooperating_club_slugs( + conn, + suggested_clubs if isinstance(suggested_clubs, list) else [], + exclude_facility_id=approval.facility_id, + ) + await conn.execute(""" UPDATE facilities SET greenfee = $1::jsonb, + cooperating_clubs = CASE + WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs + ELSE $2::jsonb + END, greenfee_updated_at = NOW(), greenfee_draft = NULL - WHERE id = $2 - """, json.dumps(approval.greenfee), approval.facility_id) + WHERE id = $3 + """, json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id) return {"status": "success"} @app.post("/api/admin/run-greenfee-scraper") @@ -2162,6 +2277,43 @@ async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, http_request: Requ print(f"📡 API mottok forespørsel om VTG-skraping for IDer: {request.facility_ids}") return await queue_scrape_job("vtg", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None)) + +@app.get("/api/admin/golfpakker/drafts") +async def get_golfpakker_drafts(): + """Henter alle anlegg som har et ventende golfpakke-forslag.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, slug, website_url, golfpakker_url, golfpakker, golfpakker_draft + FROM facilities + WHERE golfpakker_draft IS NOT NULL + AND golfpakker_draft::text != '{}' + ORDER BY name ASC + """) + return [format_row(row) for row in rows] + + +@app.post("/api/admin/golfpakker/approve-bulk") +async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest): + """Godkjenner AI-forslag for golfpakker og sletter utkastet.""" + async with app.state.pool.acquire() as conn: + async with conn.transaction(): + for approval in request.approvals: + await conn.execute(""" + UPDATE facilities + SET golfpakker = $1::jsonb, + golfpakker_updated_at = NOW(), + golfpakker_draft = NULL + WHERE id = $2 + """, json.dumps(approval.golfpakker), approval.facility_id) + return {"status": "success"} + + +@app.post("/api/admin/run-golfpakker-scraper") +async def run_golfpakker_scraper_endpoint(request: ScrapeRunRequest, http_request: Request): + """Tar imot IDer for golfpakkeskraping og legger jobben i kø.""" + print(f"📡 API mottok forespørsel om golfpakkeskraping for IDer: {request.facility_ids}") + return await queue_scrape_job("golfpakker", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None)) + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/scrape_golfpakker.py b/backend/scrape_golfpakker.py new file mode 100644 index 0000000..6142a5d --- /dev/null +++ b/backend/scrape_golfpakker.py @@ -0,0 +1,368 @@ +""" +TEE OFF - GOLFPAKKE-SKRAPER MED GEMINI AI +--------------------------------------------------------------------------- +Starter på klubbens nettside, følger relevante interne lenker om golfpakker/ +opphold/hotell, og lagrer AI-forslag som utkast. +--------------------------------------------------------------------------- +""" + +import argparse +import asyncio +import json +import os +from urllib.parse import urljoin, urlparse + +import asyncpg +import google.generativeai as genai +from bs4 import BeautifulSoup +from dotenv import load_dotenv +from playwright.async_api import async_playwright + +from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json + +load_dotenv() + +DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +if not GEMINI_API_KEY: + raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!") + +genai.configure(api_key=GEMINI_API_KEY) +model = genai.GenerativeModel("gemini-2.5-flash") + +PACKAGE_LINK_HINTS = ( + "golfpakke", + "golfpakker", + "pakke", + "pakker", + "opphold", + "overnatting", + "hotel", + "hotell", + "resort", + "accommodation", + "stay", +) + + +def _extract_text(html_content: str) -> str: + soup = BeautifulSoup(html_content, "html.parser") + for tag in soup(["script", "style", "nav", "footer", "header"]): + tag.extract() + return soup.get_text(separator=" ", strip=True) + + +def _extract_candidate_links(html_content: str, base_url: str) -> list[str]: + soup = BeautifulSoup(html_content, "html.parser") + base_host = urlparse(base_url).netloc.lower() + candidates: list[str] = [] + seen: set[str] = set() + + for anchor in soup.find_all("a", href=True): + href = str(anchor.get("href") or "").strip() + if not href or href.startswith("#") or href.startswith("mailto:") or href.startswith("tel:") or href.startswith("javascript:"): + continue + + absolute_url = urljoin(base_url, href) + parsed = urlparse(absolute_url) + if parsed.scheme not in {"http", "https"}: + continue + if parsed.netloc.lower() != base_host: + continue + + haystack = f"{absolute_url} {anchor.get_text(' ', strip=True)}".lower() + if not any(hint in haystack for hint in PACKAGE_LINK_HINTS): + continue + + normalized_url = absolute_url.rstrip("/") + if normalized_url in seen: + continue + seen.add(normalized_url) + candidates.append(normalized_url) + + return candidates[:6] + + +async def fetch_page_data(url: str, browser) -> tuple[str, str]: + url = url.strip() + if not url.startswith("http"): + return "", "" + + print(f" 🌐 Laster inn: {url}") + page = await browser.new_page() + try: + await page.goto(url, wait_until="domcontentloaded", timeout=20000) + html_content = await page.content() + return _extract_text(html_content), html_content + except Exception as exc: + print(f" ❌ Feil ved lasting av {url}: {exc}") + return "", "" + finally: + await page.close() + + +async def collect_package_source_text(urls: list[str], browser) -> str: + combined_sections: list[str] = [] + visited_urls: set[str] = set() + + for source_url in urls: + page_text, html_content = await fetch_page_data(source_url, browser) + if page_text: + combined_sections.append(f"--- TEKST FRA SIDE ({source_url}) ---\n{page_text}") + visited_urls.add(source_url.rstrip("/")) + + if not html_content: + continue + + for candidate_url in _extract_candidate_links(html_content, source_url): + normalized_candidate = candidate_url.rstrip("/") + if normalized_candidate in visited_urls: + continue + visited_urls.add(normalized_candidate) + + candidate_text, _ = await fetch_page_data(candidate_url, browser) + if candidate_text: + combined_sections.append(f"--- TEKST FRA SIDE ({candidate_url}) ---\n{candidate_text}") + + return "\n\n".join(combined_sections) + + +def analyze_golfpakker_with_gemini(text: str, club_name: str) -> dict | None: + print(f" 🧠 Sender {len(text)} tegn til Gemini for golfpakke-analyse...") + + prompt = f""" +Du er en ekspert på norske golfklubber og golfpakker. +Din oppgave er å lese tekster hentet fra nettsidene til "{club_name}" og identifisere eventuelle golfpakker, oppholdspakker eller overnattingstilbud som er relevante for greenfeespillere. + +REGLER: +- Trekk bare ut faktiske golfpakker/oppholdspakker. Ikke vanlige greenfeepriser, medlemskap eller bedriftspakker. +- For hver pakke skal du hente ut: + 1. navn + 2. pris hvis den er eksplisitt oppgitt + 3. en kort oppsummering på 1-3 setninger om hva pakken går ut på + 4. lenke til siden der pakken presenteres +- Hvis flere pakker beskrives på samme side, kan de bruke samme lenke. +- Hvis pris ikke finnes eksplisitt, sett den til null. +- Hvis du ikke finner noen golfpakker, returner en tom liste. +- Bruk URL-ene som står i markørene `--- TEKST FRA SIDE (...) ---` når du fyller inn lenke. + +TEKST FRA NETTSIDENE: +{text} + +Returner KUN gyldig JSON med denne strukturen: +{{ + "foreslatt_golfpakker": [ + {{ + "navn": "Golfpakke med hotell", + "pris": 2490, + "beskrivelse": "Én natt på hotell, frokost og greenfee for to personer. Pakken gjelder i utvalgte perioder gjennom sesongen.", + "lenke": "https://eksempel.no/golfpakke" + }} + ], + "ai_begrunnelse": "Kort forklaring på hvilke sider og signaler du brukte." +}} +""" + + try: + response = model.generate_content(prompt) + parsed = parse_llm_json(response.text) + return parsed if isinstance(parsed, dict) else None + except Exception as exc: + print(f" ❌ AI-analyse feilet: {exc}") + return None + + +async def run_golfpakker_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None): + print("🚀 Starter golfpakke-skraperen...") + conn = await asyncpg.connect(DB_URL) + facilities = [] + analyzed_count = 0 + saved_count = 0 + skipped_count = 0 + failed_count = 0 + + try: + query = """ + SELECT + id, + name, + website_url, + golfpakker_url, + COALESCE(NULLIF(TRIM(golfpakker_url), ''), NULLIF(TRIM(website_url), '')) AS source_url + FROM facilities + WHERE COALESCE(NULLIF(TRIM(golfpakker_url), ''), NULLIF(TRIM(website_url), '')) IS NOT NULL + """ + if facility_ids: + query += f" AND id IN ({','.join(map(str, facility_ids))})" + + facilities = await conn.fetch(query) + total_facilities = len(facilities) + print(f"📋 Fant {total_facilities} anlegg å skrape.") + await emit_progress( + progress_callback, + progress_total=total_facilities, + progress_completed=0, + progress_ok=0, + progress_failed=0, + progress_skipped=0, + event=make_progress_event( + facility_id=None, + facility_name="Golfpakker", + outcome="info", + message=f"Starter golfpakkeskraping for {total_facilities} anlegg.", + processed=0, + total=total_facilities, + ), + ) + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + + for index, facility in enumerate(facilities, start=1): + fac_id = facility["id"] + name = facility["name"] + urls_raw = facility["source_url"] + + print(f"\n▶️ Behandler golfpakker for: {name} (ID: {fac_id})") + await emit_progress( + progress_callback, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="info", + message="Starter henting av golfpakke-kilde med fallback til nettside.", + processed=index - 1, + total=total_facilities, + ), + ) + + urls = [url.strip() for url in str(urls_raw or "").split(",") if url.strip()] + try: + combined_text = await collect_package_source_text(urls, browser) + + if len(combined_text) < 50: + print(" ⚠️ Fant for lite tekst, hopper over.") + skipped_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="warning", + message="Hoppet over fordi det ble funnet for lite relevant tekst.", + processed=index, + total=total_facilities, + ), + ) + continue + + draft_data = analyze_golfpakker_with_gemini(combined_text[:30000], name) + if not draft_data: + failed_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message="AI-analysen ga ikke et gyldig golfpakkeutkast.", + processed=index, + total=total_facilities, + ), + ) + continue + + analyzed_count += 1 + found_packages = len(draft_data.get("foreslatt_golfpakker", [])) + print(f" ✅ AI fant {found_packages} golfpakker.") + + await conn.execute( + """ + UPDATE facilities + SET golfpakker_draft = $1::jsonb + WHERE id = $2 + """, + json.dumps(draft_data), + fac_id, + ) + + print(" 💾 Golfpakke-utkast lagret i databasen!") + saved_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="success", + message=f"Utkast lagret med {found_packages} golfpakker.", + processed=index, + total=total_facilities, + ), + ) + except Exception as exc: + failed_count += 1 + print(f" ❌ Uventet feil for {name}: {exc}") + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message=f"Feilet under behandling: {str(exc).splitlines()[0]}", + processed=index, + total=total_facilities, + ), + ) + + await browser.close() + + finally: + await conn.close() + print("\n🏁 Golfpakkeskraping fullført.") + + return { + "processed_facilities": len(facilities), + "analyzed_facilities": analyzed_count, + "saved_drafts": saved_count, + "skipped_facilities": skipped_count, + "failed_facilities": failed_count, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Skrap golfpakker via AI.") + parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None) + args = parser.parse_args() + + facility_ids_list = None + if args.ids: + facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()] + + asyncio.run(run_golfpakker_scraper(facility_ids_list)) diff --git a/backend/scrape_job_runner.py b/backend/scrape_job_runner.py index 090ba4d..5e01895 100755 --- a/backend/scrape_job_runner.py +++ b/backend/scrape_job_runner.py @@ -1,5 +1,6 @@ from typing import Any +from scrape_golfpakker import run_golfpakker_scraper from scrape_greenfee import run_greenfee_scraper from scrape_membership import run_scraper as run_membership_scraper from scrape_status import run_daily_scraping @@ -19,6 +20,8 @@ async def run_scrape_job(job: dict[str, Any], progress_callback: ProgressCallbac result = await run_greenfee_scraper(facility_ids, progress_callback=progress_callback) elif job_type == "vtg": result = await run_vtg_scraper(facility_ids, progress_callback=progress_callback) + elif job_type == "golfpakker": + result = await run_golfpakker_scraper(facility_ids, progress_callback=progress_callback) else: raise ValueError(f"Ukjent scrape-jobbtype: {job_type}") diff --git a/backend/scrape_jobs.py b/backend/scrape_jobs.py index 70d6f88..ecca6ef 100755 --- a/backend/scrape_jobs.py +++ b/backend/scrape_jobs.py @@ -2,7 +2,7 @@ import json from datetime import date, datetime from typing import Any, Iterable -SCRAPE_JOB_TYPES = ("banestatus", "medlemskap", "greenfee", "vtg") +SCRAPE_JOB_TYPES = ("banestatus", "medlemskap", "greenfee", "vtg", "golfpakker") SCRAPE_JOB_STATUSES = ("pending", "running", "completed", "failed") DEFAULT_MAX_ATTEMPTS = 3 DEFAULT_RECENT_EVENTS_LIMIT = 12 @@ -132,7 +132,7 @@ async def ensure_scrape_jobs_table(conn) -> None: """ CREATE TABLE IF NOT EXISTS scrape_jobs ( id SERIAL PRIMARY KEY, - job_type VARCHAR(50) NOT NULL CHECK (job_type IN ('banestatus', 'medlemskap', 'greenfee', 'vtg')), + job_type VARCHAR(50) NOT NULL CHECK (job_type IN ('banestatus', 'medlemskap', 'greenfee', 'vtg', 'golfpakker')), facility_ids JSONB NOT NULL DEFAULT '[]'::jsonb, total_facilities INTEGER NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')), @@ -175,6 +175,29 @@ async def ensure_scrape_jobs_table(conn) -> None: await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS current_facility_name TEXT") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS last_error_at TIMESTAMPTZ") + await conn.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'scrape_jobs_job_type_check' + ) THEN + ALTER TABLE scrape_jobs DROP CONSTRAINT scrape_jobs_job_type_check; + END IF; + EXCEPTION + WHEN undefined_object THEN NULL; + END $$; + """ + ) + await conn.execute( + """ + ALTER TABLE scrape_jobs + ADD CONSTRAINT scrape_jobs_job_type_check + CHECK (job_type IN ('banestatus', 'medlemskap', 'greenfee', 'vtg', 'golfpakker')) + """ + ) await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status_created_at diff --git a/frontend/src/app/admin/golfpakker/page.tsx b/frontend/src/app/admin/golfpakker/page.tsx new file mode 100644 index 0000000..c7f21fd --- /dev/null +++ b/frontend/src/app/admin/golfpakker/page.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +import AdminMobileMenu from "@/components/AdminMobileMenu"; +import { API_URL } from "@/config/constants"; +import { adminFetch } from "@/config/adminFetch"; + +type GolfpakkeRow = { + navn: string; + pris: string; + beskrivelse: string; + lenke: string; +}; + +const EMPTY_ROW: GolfpakkeRow = { + navn: '', + pris: '', + beskrivelse: '', + lenke: '', +}; + +export default function GolfpakkerWasher() { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState([]); + const [saving, setSaving] = useState(false); + + const fetchDrafts = () => { + setLoading(true); + adminFetch(`${API_URL}/admin/golfpakker/drafts`) + .then((res) => res.json()) + .then((data) => { + const editableDrafts = data.map((facility: any) => { + let parsedDraft = facility.golfpakker_draft; + if (typeof parsedDraft === 'string') { + try { + parsedDraft = JSON.parse(parsedDraft); + } catch (error) { + console.error("Kunne ikke parse golfpakkeutkast", error); + } + } + + const suggestedPackages = Array.isArray(parsedDraft?.foreslatt_golfpakker) + ? parsedDraft.foreslatt_golfpakker + : (Array.isArray(facility.golfpakker) ? facility.golfpakker : []); + + return { + ...facility, + golfpakker_draft: parsedDraft, + edit_golfpakker: suggestedPackages.map((row: any) => ({ + navn: row?.navn || '', + pris: row?.pris ?? '', + beskrivelse: row?.beskrivelse || '', + lenke: row?.lenke || '', + })), + }; + }); + + setDrafts(editableDrafts); + setLoading(false); + }) + .catch(() => setLoading(false)); + }; + + useEffect(() => { + fetchDrafts(); + }, []); + + const toggleSelectAll = (checked: boolean) => { + if (checked) setSelectedIds(drafts.map((draft) => draft.id)); + else setSelectedIds([]); + }; + + const toggleOne = (id: number) => { + if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter((item) => item !== id)); + else setSelectedIds([...selectedIds, id]); + }; + + const updateField = (facilityId: number, rowIndex: number, field: keyof GolfpakkeRow, value: string) => { + setDrafts(drafts.map((draft) => { + if (draft.id !== facilityId) return draft; + const nextRows = [...draft.edit_golfpakker]; + nextRows[rowIndex] = { ...nextRows[rowIndex], [field]: value }; + return { ...draft, edit_golfpakker: nextRows }; + })); + }; + + const addRow = (facilityId: number) => { + setDrafts(drafts.map((draft) => { + if (draft.id !== facilityId) return draft; + return { ...draft, edit_golfpakker: [...draft.edit_golfpakker, { ...EMPTY_ROW }] }; + })); + }; + + const removeRow = (facilityId: number, rowIndex: number) => { + setDrafts(drafts.map((draft) => { + if (draft.id !== facilityId) return draft; + return { ...draft, edit_golfpakker: draft.edit_golfpakker.filter((_: unknown, index: number) => index !== rowIndex) }; + })); + }; + + const handleApprove = async () => { + const approvals = drafts + .filter((draft) => selectedIds.includes(draft.id)) + .map((draft) => ({ + facility_id: draft.id, + golfpakker: draft.edit_golfpakker + .map((row: GolfpakkeRow) => ({ + navn: row.navn.trim(), + pris: row.pris.trim() ? Number(row.pris) : null, + beskrivelse: row.beskrivelse.trim(), + lenke: row.lenke.trim(), + })) + .filter((row: any) => row.navn || row.pris !== null || row.beskrivelse || row.lenke), + })); + + if (approvals.length === 0) { + alert("Velg minst ett anlegg å godkjenne."); + return; + } + + setSaving(true); + try { + const res = await adminFetch(`${API_URL}/admin/golfpakker/approve-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approvals }), + }); + if (!res.ok) throw new Error("Kunne ikke lagre golfpakker"); + alert(`${approvals.length} anlegg oppdatert!`); + setSelectedIds([]); + fetchDrafts(); + } catch { + alert("Noe gikk galt under lagring."); + } finally { + setSaving(false); + } + }; + + if (loading) return
Laster golfpakkeutkast...
; + + return ( +
+
+ +
+
+ ← Tilbake til oversikten +

Golfpakke-Vaskeriet

+

Gå gjennom AI-funnede golfpakker, korriger lenker og beskrivelser, og godkjenn til live.

+
+ +
+ + {drafts.length === 0 ? ( +
+ 🧳 +

Ingen ventende golfpakkeutkast!

+
+ ) : ( +
+
+ 0} onChange={(e) => toggleSelectAll(e.target.checked)} /> + Velg Alle +
+ + {drafts.map((draft, index) => ( +
+
+
+ toggleOne(draft.id)} /> +
+
+
+

+ {draft.name} + ID: {draft.id} +

+ + Sjekk Nettside ↗ + +
+ + {draft.golfpakker_draft?.ai_begrunnelse && ( +
+ 🤖 AI Begrunnelse: {draft.golfpakker_draft.ai_begrunnelse} +
+ )} + +
+
+

Slik ser det ut i databasen nå:

+
+ {draft.golfpakker && draft.golfpakker.length > 0 ? draft.golfpakker.map((pakke: any, idx: number) => ( +
+
{pakke.navn || 'Uten navn'}
+
{pakke.pris ? `${pakke.pris},-` : 'Uten pris'}
+
{pakke.beskrivelse || 'Ingen beskrivelse'}
+
+ )) : 'Ingen golfpakker registrert.'} +
+
+ +
+

Nytt forslag å godkjenne:

+
+ {draft.edit_golfpakker.length === 0 ? ( +
+ AI fant ingen konkrete golfpakker, men du kan legge til manuelt. +
+ ) : ( + draft.edit_golfpakker.map((row: GolfpakkeRow, rowIndex: number) => ( +
+
+ updateField(draft.id, rowIndex, 'navn', e.target.value)} placeholder="Pakkenavn" /> + updateField(draft.id, rowIndex, 'pris', e.target.value)} placeholder="Pris" /> + +
+ updateField(draft.id, rowIndex, 'lenke', e.target.value)} placeholder="Lenke til pakken" /> +