Etter å ha tettet passordlekkasjer og annet

This commit is contained in:
Erol 2026-04-16 09:58:08 +02:00
parent 9e8d622ca4
commit c26f6e8f20
24 changed files with 213 additions and 85 deletions

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
PUBLIC_BASE_URL=https://teeoff.no
NEXT_PUBLIC_SITE_URL=https://teeoff.no
PUBLIC_SESSION_SECRET=replace-with-a-long-random-secret
JWT_SECRET=replace-with-a-separate-long-random-secret
PUBLIC_COMMENT_DEFAULT_STATUS=pending
SMTP_SERVER=send.one.com
SMTP_PORT=465
SMTP_USER=comment@example.com
SMTP_PASS=replace-with-your-smtp-password
PUBLIC_FROM_EMAIL=TeeOff kommentarer <comment@example.com>
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES=20
POSTGRES_USER=teeoff_admin
POSTGRES_PASSWORD=replace-with-your-postgres-password
POSTGRES_DB=teeoff
DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff

7
.gitignore vendored
View file

@ -2,3 +2,10 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
.env .env
backend/.env
.env.*
!.env.example
!backend/.env.example
*.dump
*_dump.txt
kode_eksport_*/

View file

@ -1,6 +0,0 @@
SMTP_SERVER=send.one.com
SMTP_PORT=465
SMTP_USER=teeoff@teeoff.no
SMTP_PASS=Shallot Distress43, Serving Smog Hangnail Shower
EMAIL_TO=erol.haagenrud@teeoff.no
GEMINI_API_KEY=AIzaSyDX_WCvZcH3Z8xRpH-XWaoeVYWuE0Wrlog

8
backend/.env.example Normal file
View file

@ -0,0 +1,8 @@
SMTP_SERVER=send.one.com
SMTP_PORT=465
SMTP_USER=teeoff@example.com
SMTP_PASS=replace-with-your-smtp-password
EMAIL_TO=ops@example.com
GEMINI_API_KEY=replace-with-your-gemini-api-key
DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff
JWT_SECRET=replace-with-a-long-random-secret

37
backend/env_config.py Normal file
View file

@ -0,0 +1,37 @@
import os
from pathlib import Path
from urllib.parse import quote
from dotenv import load_dotenv
BACKEND_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BACKEND_DIR.parent
load_dotenv(PROJECT_ROOT / ".env", override=False)
load_dotenv(BACKEND_DIR / ".env", override=True)
def get_required_env(name: str) -> str:
value = str(os.getenv(name, "")).strip()
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
def get_database_url() -> str:
direct = str(os.getenv("DATABASE_URL", "")).strip()
if direct:
return direct
user = str(os.getenv("POSTGRES_USER", "teeoff_admin")).strip()
password = str(os.getenv("POSTGRES_PASSWORD", "")).strip()
database = str(os.getenv("POSTGRES_DB", "teeoff")).strip()
host = str(os.getenv("POSTGRES_HOST", "db")).strip()
port = str(os.getenv("POSTGRES_PORT", "5432")).strip()
if not password:
raise RuntimeError(
"Missing database credentials. Set DATABASE_URL or POSTGRES_PASSWORD in your environment."
)
return f"postgresql://{user}:{quote(password)}@{host}:{port}/{database}"

View file

@ -2,8 +2,9 @@ import asyncio
import asyncpg import asyncpg
import urllib.request import urllib.request
import json import json
from env_config import get_database_url
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" DB_URL = get_database_url()
async def fetch_json(url): async def fetch_json(url):
"""Hjelpefunksjon for å hente JSON fra en URL""" """Hjelpefunksjon for å hente JSON fra en URL"""

View file

@ -6,10 +6,11 @@ import os
import re import re
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
from env_config import get_database_url
# Laster miljøvariabler # Laster miljøvariabler
load_dotenv() load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
# Grunn-URL uten page-parameter # Grunn-URL uten page-parameter
WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100" WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"

View file

@ -13,7 +13,9 @@ import asyncpg
import os import os
from urllib.parse import urlparse from urllib.parse import urlparse
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") from env_config import get_database_url
DB_URL = get_database_url()
# Hvilke filer vi skal lese, og hvilke databasefelt de tilhører # Hvilke filer vi skal lese, og hvilke databasefelt de tilhører
FILES_TO_IMPORT = { FILES_TO_IMPORT = {

View file

@ -1,7 +1,8 @@
import asyncio, asyncpg, urllib.request, json, re, os, requests import asyncio, asyncpg, urllib.request, json, re, os, requests
from env_config import get_database_url
# --- KONFIGURASJON --- # --- KONFIGURASJON ---
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" DB_URL = get_database_url()
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed" WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100&_embed"
MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media" MEDIA_ENDPOINT = "https://teeoff.no/wp-json/wp/v2/media"
MEDIA_DIR = "./public/media" MEDIA_DIR = "./public/media"

View file

@ -27,7 +27,6 @@ from email.message import EmailMessage
from pathlib import Path from pathlib import Path
from jose import jwt, JWTError from jose import jwt, JWTError
from passlib.context import CryptContext from passlib.context import CryptContext
from dotenv import load_dotenv
import qrcode import qrcode
import qrcode.image.svg import qrcode.image.svg
import httpx import httpx
@ -42,12 +41,11 @@ from scrape_jobs import (
ensure_scrape_jobs_table, ensure_scrape_jobs_table,
list_scrape_jobs, list_scrape_jobs,
) )
from env_config import get_database_url, get_required_env
load_dotenv()
# --- KONFIGURASJON --- # --- KONFIGURASJON ---
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production") SECRET_KEY = get_required_env("JWT_SECRET")
ALGORITHM = "HS256" ALGORITHM = "HS256"
PUBLIC_SESSION_SECRET = os.getenv("PUBLIC_SESSION_SECRET", SECRET_KEY) PUBLIC_SESSION_SECRET = os.getenv("PUBLIC_SESSION_SECRET", SECRET_KEY)
PUBLIC_SESSION_COOKIE = "teeoff_user_session" PUBLIC_SESSION_COOKIE = "teeoff_user_session"
@ -992,7 +990,7 @@ async def ensure_public_user_tables(conn):
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Opprett database-pool ved start # Opprett database-pool ved start
try: try:
print(f"📡 Forsøker å koble til database på: {DB_URL}") print("📡 Forsøker å koble til database")
app.state.pool = await asyncpg.create_pool( app.state.pool = await asyncpg.create_pool(
DB_URL, DB_URL,
min_size=5, min_size=5,
@ -1049,8 +1047,6 @@ async def require_admin_session_for_admin_routes(request: Request, call_next):
@app.post("/api/auth/login") @app.post("/api/auth/login")
async def login(data: dict): async def login(data: dict):
"""Steg 1: Sjekk passord og returner temp_token for 2FA.""" """Steg 1: Sjekk passord og returner temp_token for 2FA."""
print(f"🔐 Loggin-forsøk for: {data.get('username')}")
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow( admin = await conn.fetchrow(
"SELECT * FROM admins WHERE username = $1 OR email = $1", "SELECT * FROM admins WHERE username = $1 OR email = $1",
@ -1058,27 +1054,21 @@ async def login(data: dict):
) )
if not admin: if not admin:
print(" - ❌ Bruker ikke funnet i databasen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
try: try:
is_valid = pwd_context.verify(data.get('password'), h) is_valid = pwd_context.verify(data.get('password'), admin['password_hash'])
except Exception as e: except Exception as e:
print(f" - 🔥 FEIL VED LESING AV HASH: {e}") print("❌ Kunne ikke verifisere admin-passord")
raise HTTPException(status_code=500, detail="Internt problem med passord-format") raise HTTPException(status_code=500, detail="Internt problem med passord-format")
if not is_valid: if not is_valid:
print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
temp_token = jwt.encode( temp_token = jwt.encode(
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)}, {"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
SECRET_KEY, algorithm=ALGORITHM SECRET_KEY, algorithm=ALGORITHM
) )
print(" - ✅ Steg 1 fullført. Temp-token generert.")
return {"step": "2fa", "temp_token": temp_token} return {"step": "2fa", "temp_token": temp_token}
@app.post("/api/auth/verify-2fa") @app.post("/api/auth/verify-2fa")
@ -1097,7 +1087,7 @@ async def verify_2fa(data: dict, response: Response):
totp = pyotp.TOTP(admin['otp_secret']) totp = pyotp.TOTP(admin['otp_secret'])
if not totp.verify(data.get('code')): if not totp.verify(data.get('code')):
print(f" - ❌ Feil 2FA-kode oppgitt for {username}") print("❌ Ugyldig 2FA-kode ved admin-innlogging")
raise HTTPException(status_code=401, detail="Feil 2FA-kode") raise HTTPException(status_code=401, detail="Feil 2FA-kode")
final_token = jwt.encode( final_token = jwt.encode(
@ -1899,7 +1889,7 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
except Exception as e: except Exception as e:
if isinstance(e, HTTPException): if isinstance(e, HTTPException):
raise e raise e
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Kunne ikke oppdatere skrapeinnstillingene")
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) --- # --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
@app.put("/api/admin/facilities/{facility_id}/full") @app.put("/api/admin/facilities/{facility_id}/full")
@ -2110,7 +2100,8 @@ async def health_check():
await conn.execute("SELECT 1") await conn.execute("SELECT 1")
return {"status": "healthy", "database": "connected"} return {"status": "healthy", "database": "connected"}
except Exception as e: except Exception as e:
return {"status": "unhealthy", "error": str(e)} print(f"❌ Health check feilet: {type(e).__name__}")
return {"status": "unhealthy", "error": "database_unavailable"}
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER --- # --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---

View file

@ -2,8 +2,9 @@ import asyncio
import asyncpg import asyncpg
import json import json
import re import re
from env_config import get_database_url
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" DB_URL = get_database_url()
# Data hentet direkte fra bildet du sendte # Data hentet direkte fra bildet du sendte
GOLFAMORE_DATA = { GOLFAMORE_DATA = {

View file

@ -16,13 +16,14 @@ import asyncpg
import google.generativeai as genai import google.generativeai as genai
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from dotenv import load_dotenv from dotenv import load_dotenv
from env_config import get_database_url
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv() load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if not GEMINI_API_KEY: if not GEMINI_API_KEY:

View file

@ -15,11 +15,12 @@ from bs4 import BeautifulSoup
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
import google.generativeai as genai import google.generativeai as genai
from dotenv import load_dotenv from dotenv import load_dotenv
from env_config import get_database_url
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv() load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if not GEMINI_API_KEY: if not GEMINI_API_KEY:

View file

@ -16,11 +16,12 @@ from bs4 import BeautifulSoup
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
import google.generativeai as genai import google.generativeai as genai
from dotenv import load_dotenv from dotenv import load_dotenv
from env_config import get_database_url
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv() load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if not GEMINI_API_KEY: if not GEMINI_API_KEY:

View file

@ -4,8 +4,9 @@ import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import re import re
import json import json
from env_config import get_database_url
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" DB_URL = get_database_url()
def clean_name(text): def clean_name(text):
if not text: return "" if not text: return ""

View file

@ -15,11 +15,12 @@ except ImportError:
from google import genai from google import genai
from dotenv import load_dotenv from dotenv import load_dotenv
from env_config import get_database_url
from scrape_utils import ProgressCallback, emit_progress, make_progress_event from scrape_utils import ProgressCallback, emit_progress, make_progress_event
load_dotenv() load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
# ========================================== # ==========================================
# KONFIGURERER GEMINI AI (NY SDK) # KONFIGURERER GEMINI AI (NY SDK)

View file

@ -15,11 +15,12 @@ from bs4 import BeautifulSoup
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
import google.generativeai as genai import google.generativeai as genai
from dotenv import load_dotenv from dotenv import load_dotenv
from env_config import get_database_url
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv() load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if not GEMINI_API_KEY: if not GEMINI_API_KEY:

View file

@ -1,6 +1,7 @@
import asyncio, asyncpg, urllib.request, json import asyncio, asyncpg, urllib.request, json
from env_config import get_database_url
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" DB_URL = get_database_url()
# Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her # Vi fjerner acf_format=standard da rå-feltnavnene er tryggere her
WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100" WP_API_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100"

View file

@ -2,8 +2,9 @@ import asyncio
import asyncpg import asyncpg
import os import os
from passlib.context import CryptContext from passlib.context import CryptContext
from env_config import get_database_url, get_required_env
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
# Vi setter opp passord-sjekkeren AKKURAT slik main.py gjør det # Vi setter opp passord-sjekkeren AKKURAT slik main.py gjør det
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
@ -13,8 +14,8 @@ async def test_sannheten():
print(" 🔍 TEE OFF SANNHETSSERUM") print(" 🔍 TEE OFF SANNHETSSERUM")
print("="*50) print("="*50)
username = "Envide Webutvikling" username = os.getenv("TEST_ADMIN_USERNAME", "Envide Webutvikling").strip()
test_password = "Solveig Vilde Ingvild Gina" # Sørg for at dette er det du satte sist! test_password = get_required_env("TEST_ADMIN_PASSWORD")
try: try:
conn = await asyncpg.connect(DB_URL) conn = await asyncpg.connect(DB_URL)

View file

@ -12,9 +12,10 @@ import os
import sys import sys
import getpass import getpass
from passlib.hash import pbkdf2_sha256 from passlib.hash import pbkdf2_sha256
from env_config import get_database_url
# Henter database-URL fra miljøvariabler (samme metode som backenden din bruker) # Henter database-URL fra miljøvariabler (samme metode som backenden din bruker)
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") DB_URL = get_database_url()
async def update_admin_password(): async def update_admin_password():
print("\n" + "="*50) print("\n" + "="*50)

View file

@ -3,8 +3,8 @@ import os
import traceback import traceback
import asyncpg import asyncpg
from dotenv import load_dotenv
from env_config import get_database_url
from scrape_job_runner import run_scrape_job from scrape_job_runner import run_scrape_job
from scrape_jobs import ( from scrape_jobs import (
classify_scrape_error, classify_scrape_error,
@ -17,9 +17,7 @@ from scrape_jobs import (
update_scrape_job_progress, update_scrape_job_progress,
) )
load_dotenv() DB_URL = get_database_url()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
WORKER_NAME = os.getenv("SCRAPE_WORKER_NAME", f"scrape-worker-{os.getpid()}") WORKER_NAME = os.getenv("SCRAPE_WORKER_NAME", f"scrape-worker-{os.getpid()}")
POLL_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_POLL_INTERVAL", "5")) POLL_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_POLL_INTERVAL", "5"))
HEARTBEAT_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_HEARTBEAT_INTERVAL", "15")) HEARTBEAT_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_HEARTBEAT_INTERVAL", "15"))

View file

@ -3,9 +3,9 @@ services:
image: postgis/postgis:15-3.4 image: postgis/postgis:15-3.4
container_name: teeoff_db container_name: teeoff_db
environment: environment:
POSTGRES_USER: teeoff_admin POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: teeoff_secret_password POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: teeoff POSTGRES_DB: ${POSTGRES_DB}
ports: ports:
- "5433:5432" - "5433:5432"
volumes: volumes:
@ -16,6 +16,8 @@ services:
build: ./backend build: ./backend
container_name: teeoff_api container_name: teeoff_api
environment: environment:
DATABASE_URL: ${DATABASE_URL}
JWT_SECRET: ${JWT_SECRET}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
@ -42,6 +44,8 @@ services:
build: ./backend build: ./backend
container_name: teeoff_worker container_name: teeoff_worker
command: python worker.py command: python worker.py
environment:
DATABASE_URL: ${DATABASE_URL}
volumes: volumes:
- ./backend:/app - ./backend:/app
depends_on: depends_on:

View file

@ -17,6 +17,7 @@ type Facility = {
id: number; id: number;
slug: string; slug: string;
name: string; name: string;
architect?: string | null;
description?: string | null; description?: string | null;
city?: string | null; city?: string | null;
county?: string | null; county?: string | null;
@ -58,10 +59,10 @@ type SpecialFlags = {
hasGolfamore: boolean; hasGolfamore: boolean;
hasNSG: boolean; hasNSG: boolean;
hasSimulator: boolean; hasSimulator: boolean;
hasDrivingRange: boolean;
hasVtg: boolean;
}; };
const HIDDEN_COUNTY_SLUGS = new Set(["innlandet", "viken"]);
const AREA_GROUPS: Record<string, string[]> = { const AREA_GROUPS: Record<string, string[]> = {
"nord-norge": ["finnmark", "troms", "nordland"], "nord-norge": ["finnmark", "troms", "nordland"],
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"], "midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
@ -81,9 +82,9 @@ const HIERARCHICAL_AREA_OPTIONS = [
{ value: "county:finnmark", label: "\u00A0\u00A0\u00A0Finnmark" }, { value: "county:finnmark", label: "\u00A0\u00A0\u00A0Finnmark" },
{ value: "county:troms", label: "\u00A0\u00A0\u00A0Troms" }, { value: "county:troms", label: "\u00A0\u00A0\u00A0Troms" },
{ value: "county:nordland", label: "\u00A0\u00A0\u00A0Nordland" }, { value: "county:nordland", label: "\u00A0\u00A0\u00A0Nordland" },
{ value: "county:trondelag", label: "Trøndelag" },
{ value: "county:nord-trondelag", label: "\u00A0\u00A0\u00A0Nord-Trøndelag" }, { value: "county:nord-trondelag", label: "\u00A0\u00A0\u00A0Nord-Trøndelag" },
{ value: "county:sor-trondelag", label: "\u00A0\u00A0\u00A0Sør-Trøndelag" }, { value: "county:sor-trondelag", label: "\u00A0\u00A0\u00A0Sør-Trøndelag" },
{ value: "county:trondelag", label: "\u00A0\u00A0\u00A0Trøndelag" },
{ value: "region:vestlandet", label: "Vestlandet" }, { value: "region:vestlandet", label: "Vestlandet" },
{ value: "county:more-og-romsdal", label: "\u00A0\u00A0\u00A0Møre og Romsdal" }, { value: "county:more-og-romsdal", label: "\u00A0\u00A0\u00A0Møre og Romsdal" },
{ value: "county:sogn-og-fjordane", label: "\u00A0\u00A0\u00A0Sogn og Fjordane" }, { value: "county:sogn-og-fjordane", label: "\u00A0\u00A0\u00A0Sogn og Fjordane" },
@ -103,8 +104,6 @@ const HIERARCHICAL_AREA_OPTIONS = [
{ value: "region:oslo-og-akershus", label: "\u00A0\u00A0\u00A0Oslo og Akershus" }, { value: "region:oslo-og-akershus", label: "\u00A0\u00A0\u00A0Oslo og Akershus" },
{ value: "county:akershus", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Akershus" }, { value: "county:akershus", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Akershus" },
{ value: "county:oslo", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Oslo" }, { value: "county:oslo", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Oslo" },
{ value: "county:innlandet", label: "\u00A0\u00A0\u00A0Innlandet" },
{ value: "county:viken", label: "\u00A0\u00A0\u00A0Viken" },
]; ];
const STATUS_ORDER = [ const STATUS_ORDER = [
@ -319,8 +318,6 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
if (specialFilter === "golfamore") return flags.hasGolfamore; if (specialFilter === "golfamore") return flags.hasGolfamore;
if (specialFilter === "nsg") return flags.hasNSG; if (specialFilter === "nsg") return flags.hasNSG;
if (specialFilter === "simulator") return flags.hasSimulator; if (specialFilter === "simulator") return flags.hasSimulator;
if (specialFilter === "drivingrange") return flags.hasDrivingRange;
if (specialFilter === "vtg") return flags.hasVtg;
return true; return true;
}; };
@ -353,6 +350,8 @@ export default function FacilitySearch({
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [holeFilter, setHoleFilter] = useState(""); const [holeFilter, setHoleFilter] = useState("");
const [specialFilter, setSpecialFilter] = useState(""); const [specialFilter, setSpecialFilter] = useState("");
const [architectFilter, setArchitectFilter] = useState("");
const [facilityFilter, setFacilityFilter] = useState("");
const [sortMethod, setSortMethod] = useState<SortMethod>("updated"); const [sortMethod, setSortMethod] = useState<SortMethod>("updated");
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
@ -383,7 +382,7 @@ export default function FacilitySearch({
for (const facility of Array.isArray(initialFacilities) ? initialFacilities : []) { for (const facility of Array.isArray(initialFacilities) ? initialFacilities : []) {
const label = String(facility?.county || "").trim(); const label = String(facility?.county || "").trim();
const slug = slugify(label); const slug = slugify(label);
if (label && slug && !unique.has(slug)) unique.set(slug, label); if (label && slug && !HIDDEN_COUNTY_SLUGS.has(slug) && !unique.has(slug)) unique.set(slug, label);
} }
return Array.from(unique.entries()) return Array.from(unique.entries())
@ -410,6 +409,30 @@ export default function FacilitySearch({
return options; return options;
}, [countyOptions]); }, [countyOptions]);
const architectOptions = useMemo(() => {
const unique = new Map<string, string>();
for (const facility of Array.isArray(initialFacilities) ? initialFacilities : []) {
const label = String(facility?.architect || "").trim();
const key = normalizeText(label);
if (label && key && !unique.has(key)) unique.set(key, label);
}
return Array.from(unique.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "nb"));
}, [initialFacilities]);
const facilityOptions = useMemo(() => {
return (Array.isArray(initialFacilities) ? initialFacilities : [])
.filter((facility) => facility?.slug && facility?.name)
.map((facility) => ({
value: facility.slug,
label: facility.name,
}))
.sort((a, b) => a.label.localeCompare(b.label, "nb"));
}, [initialFacilities]);
const processedFacilities = useMemo(() => { const processedFacilities = useMemo(() => {
if (!Array.isArray(initialFacilities)) return []; if (!Array.isArray(initialFacilities)) return [];
@ -420,7 +443,6 @@ export default function FacilitySearch({
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {}); const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
const golfamoreData = parseJson<Record<string, unknown>>(facility.golfamore_data, {}); const golfamoreData = parseJson<Record<string, unknown>>(facility.golfamore_data, {});
const nsgData = parseJson<Record<string, unknown>>(facility.nsg_data, {}); const nsgData = parseJson<Record<string, unknown>>(facility.nsg_data, {});
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
const rawStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []); const rawStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []);
const statuses = const statuses =
Array.isArray(rawStatuses) && rawStatuses.length > 0 Array.isArray(rawStatuses) && rawStatuses.length > 0
@ -435,12 +457,7 @@ export default function FacilitySearch({
const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0; const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0;
const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0; const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0;
const hasSimulator = hasTruthyAmenity(amenities.simulator); const hasSimulator = hasTruthyAmenity(amenities.simulator);
const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange); const architectKey = normalizeText(facility.architect || "");
const hasVtg =
Boolean(facility.vtg_pris) ||
Boolean(facility.vtg_lenke) ||
Boolean(facility.vtg_beskrivelse) ||
(Array.isArray(vtgDates) && vtgDates.length > 0);
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0; const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
const lastUpdatedTs = Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0; const lastUpdatedTs = Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0;
@ -454,6 +471,7 @@ export default function FacilitySearch({
facility.city, facility.city,
facility.county, facility.county,
facility.banetype, facility.banetype,
facility.architect,
holeValue, holeValue,
...statuses.map((status) => status.name), ...statuses.map((status) => status.name),
...regions, ...regions,
@ -464,8 +482,6 @@ export default function FacilitySearch({
if (hasGolfamore) searchBlob += " golfamore"; if (hasGolfamore) searchBlob += " golfamore";
if (hasNSG) searchBlob += " nsg seniorgolf"; if (hasNSG) searchBlob += " nsg seniorgolf";
if (hasSimulator) searchBlob += " simulator"; if (hasSimulator) searchBlob += " simulator";
if (hasDrivingRange) searchBlob += " drivingrange range";
if (hasVtg) searchBlob += " vtg veien til golf nybegynnerkurs";
if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne"; if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne";
if (normalizedStatuses.includes("stengt")) searchBlob += " stengt"; if (normalizedStatuses.includes("stengt")) searchBlob += " stengt";
if (normalizedStatuses.includes("aapen_med_vintergreener")) searchBlob += " vinter vintergreener"; if (normalizedStatuses.includes("aapen_med_vintergreener")) searchBlob += " vinter vintergreener";
@ -490,9 +506,9 @@ export default function FacilitySearch({
hasGolfamore, hasGolfamore,
hasNSG, hasNSG,
hasSimulator, hasSimulator,
hasDrivingRange,
hasVtg,
}); });
const matchesArchitect = !architectFilter || architectKey === architectFilter;
const matchesFacility = !facilityFilter || facility.slug === facilityFilter;
return { return {
...facility, ...facility,
@ -500,7 +516,6 @@ export default function FacilitySearch({
primaryStatus, primaryStatus,
hasGolfamore, hasGolfamore,
hasNSG, hasNSG,
hasVtg,
distance, distance,
lastUpdatedTs, lastUpdatedTs,
matchesSearch, matchesSearch,
@ -508,6 +523,8 @@ export default function FacilitySearch({
matchesStatus, matchesStatus,
matchesHoles, matchesHoles,
matchesSpecial, matchesSpecial,
matchesArchitect,
matchesFacility,
}; };
}) })
.filter( .filter(
@ -516,7 +533,9 @@ export default function FacilitySearch({
facility.matchesArea && facility.matchesArea &&
facility.matchesStatus && facility.matchesStatus &&
facility.matchesHoles && facility.matchesHoles &&
facility.matchesSpecial facility.matchesSpecial &&
facility.matchesArchitect &&
facility.matchesFacility
) )
.sort((a, b) => { .sort((a, b) => {
if (sortMethod === "dist") { if (sortMethod === "dist") {
@ -529,9 +548,17 @@ export default function FacilitySearch({
} }
return a.name.localeCompare(b.name, "nb"); return a.name.localeCompare(b.name, "nb");
}); });
}, [areaFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]); }, [areaFilter, architectFilter, facilityFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]);
const filtersCount = [areaFilter, statusFilter, holeFilter, specialFilter, searchQuery.trim()].filter(Boolean).length; const filtersCount = [
areaFilter,
statusFilter,
holeFilter,
specialFilter,
architectFilter,
facilityFilter,
searchQuery.trim(),
].filter(Boolean).length;
const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${ const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${
filtersCount > 0 ? `${filtersCount} aktive filtre` : "" filtersCount > 0 ? `${filtersCount} aktive filtre` : ""
}`; }`;
@ -572,11 +599,13 @@ export default function FacilitySearch({
<option value="stenger_snart">Stenger snart</option> <option value="stenger_snart">Stenger snart</option>
<option value="aapner_snart">Åpner snart</option> <option value="aapner_snart">Åpner snart</option>
<option value="stengt">Stengt</option> <option value="stengt">Stengt</option>
<option value="under_utvikling">Under utvikling</option>
<option value="nedlagt">Nedlagt</option>
<option value="ukjent">Ukjent status</option> <option value="ukjent">Ukjent status</option>
</FieldSelect> </FieldSelect>
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}> <FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
<option value="">Alle golfbaner</option> <option value="">Alle antall hull</option>
<option value="18-plus">18 hull eller mer</option> <option value="18-plus">18 hull eller mer</option>
<option value="18">Nøyaktig 18 hull</option> <option value="18">Nøyaktig 18 hull</option>
<option value="9">9 hull</option> <option value="9">9 hull</option>
@ -589,12 +618,38 @@ export default function FacilitySearch({
<option value="golfamore">Golfamore</option> <option value="golfamore">Golfamore</option>
<option value="nsg">Seniorgolf / NSG</option> <option value="nsg">Seniorgolf / NSG</option>
<option value="simulator">Simulator</option> <option value="simulator">Simulator</option>
<option value="drivingrange">Drivingrange</option>
<option value="vtg">Tilbyr VTG</option>
</FieldSelect> </FieldSelect>
</div> </div>
<div className="mt-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_auto]"> <div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)_220px_auto]">
<FieldSelect
label="Arkitekt"
value={architectFilter}
onChange={setArchitectFilter}
labelClassName={labelClassName}
>
<option value="">Alle arkitekter</option>
{architectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</FieldSelect>
<FieldSelect
label="Golfanlegg"
value={facilityFilter}
onChange={setFacilityFilter}
labelClassName={labelClassName}
>
<option value="">Alle golfanlegg</option>
{facilityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</FieldSelect>
<FieldInput <FieldInput
label="Søk" label="Søk"
value={searchQuery} value={searchQuery}
@ -622,6 +677,8 @@ export default function FacilitySearch({
setStatusFilter(""); setStatusFilter("");
setHoleFilter(""); setHoleFilter("");
setSpecialFilter(""); setSpecialFilter("");
setArchitectFilter("");
setFacilityFilter("");
setSortMethod(userLocation ? "dist" : "updated"); setSortMethod(userLocation ? "dist" : "updated");
}} }}
className={`btn btn-md mt-[1.72rem] h-[52px] ${ className={`btn btn-md mt-[1.72rem] h-[52px] ${

View file

@ -38,8 +38,8 @@ export default function AdminLogin() {
} else { } else {
setError(data.detail || 'Ugyldig pålogging'); setError(data.detail || 'Ugyldig pålogging');
} }
} catch (err) { } catch {
console.error("🔥 DEN EKTE FEILEN ER:", err); console.error("Admin login request failed");
setError('Systemfeil: Kunne ikke koble til API-et'); setError('Systemfeil: Kunne ikke koble til API-et');
} finally { } finally {
setIsLoading(false); setIsLoading(false);