Etter dag 1 ny server

This commit is contained in:
Erol Haagenrud 2026-04-17 09:25:32 +02:00
parent 6511a3aee2
commit e2f94dcaaa
19 changed files with 958 additions and 313 deletions

View file

@ -1,17 +0,0 @@
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

View file

@ -0,0 +1,138 @@
"""
TEE OFF ADMIN ACCESS BOOTSTRAP
---------------------------------------------------------------------------
FUNKSJON: Oppretter eller oppdaterer én administrator uten å påvirke andre.
Passord leses skjult fra terminalen, og 2FA kan genereres/roteres.
STATUS: Trygg erstatning for create_admin.py når flere admins skal eksistere.
---------------------------------------------------------------------------
"""
import argparse
import asyncio
import getpass
import sys
import asyncpg
import pyotp
from passlib.hash import pbkdf2_sha256
from env_config import get_database_url
DB_URL = get_database_url()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Opprett eller oppdater en admin-bruker trygt.")
parser.add_argument("--username", required=True, help="Brukernavn for admin-brukeren")
parser.add_argument("--email", required=True, help="E-post for admin-brukeren")
parser.add_argument(
"--rotate-2fa",
action="store_true",
help="Generer en ny 2FA-hemmelighet selv om brukeren allerede har en",
)
return parser.parse_args()
async def bootstrap_admin() -> None:
args = parse_args()
username = args.username.strip()
email = args.email.strip().lower()
if not username:
print("❌ Brukernavn kan ikke være tomt.")
sys.exit(1)
if "@" not in email:
print("❌ E-postadressen ser ugyldig ut.")
sys.exit(1)
while True:
password = getpass.getpass("Skriv inn passord: ")
password_confirm = getpass.getpass("Gjenta passord: ")
if password != password_confirm:
print("❌ Passordene er ikke like. Prøv igjen.\n")
continue
if len(password) < 8:
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
break
password_hash = pbkdf2_sha256.hash(password)
conn = None
try:
conn = await asyncpg.connect(DB_URL)
existing = await conn.fetchrow(
"""
SELECT id, username, email, otp_secret
FROM admins
WHERE username = $1 OR email = $2
ORDER BY id ASC
LIMIT 1
""",
username,
email,
)
otp_secret = pyotp.random_base32() if (args.rotate_2fa or not existing or not existing["otp_secret"]) else existing["otp_secret"]
if existing:
await conn.execute(
"""
UPDATE admins
SET username = $1,
email = $2,
password_hash = $3,
otp_secret = $4
WHERE id = $5
""",
username,
email,
password_hash,
otp_secret,
existing["id"],
)
action = "oppdatert"
else:
await conn.execute(
"""
INSERT INTO admins (username, email, password_hash, otp_secret)
VALUES ($1, $2, $3, $4)
""",
username,
email,
password_hash,
otp_secret,
)
action = "opprettet"
except asyncpg.UniqueViolationError:
print("❌ Brukernavn eller e-post er allerede i bruk av en annen admin.")
sys.exit(1)
except Exception as exc:
print(f"❌ Kunne ikke bootstrappe admin-brukeren: {type(exc).__name__}")
sys.exit(1)
finally:
if conn is not None:
await conn.close()
provisioning_uri = pyotp.TOTP(otp_secret).provisioning_uri(
name=email or username,
issuer_name="TeeOff.no",
)
print("\n✅ ADMIN BRUKER KLAR")
print("-" * 50)
print(f"Brukeren '{username}' er {action}.")
print("Passordet ble satt skjult i terminalen.")
print("Legg inn denne 2FA-hemmeligheten i authenticator-appen:")
print(f"2FA-nøkkel: {otp_secret}")
print("Alternativt kan du bruke provisioning URI:")
print(provisioning_uri)
print("-" * 50 + "\n")
if __name__ == "__main__":
try:
asyncio.run(bootstrap_admin())
except KeyboardInterrupt:
print("\nAvbrutt.")
sys.exit(0)

View file

@ -80,6 +80,8 @@ def get_int_env(name: str, default: int) -> int:
return default
ADMIN_SESSION_MAX_AGE_SECONDS = get_int_env("ADMIN_SESSION_MAX_AGE_SECONDS", 60 * 60 * 12)
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30)
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20)
PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60)
@ -479,6 +481,19 @@ async def resolve_cooperating_club_slugs(
return resolved_slugs
async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]:
rows = await conn.fetch(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
""",
schema_name,
table_name,
)
return {str(row["column_name"]) for row in rows}
def generate_totp_qr_svg(provisioning_uri: str) -> str:
image = qrcode.make(
provisioning_uri,
@ -1047,6 +1062,8 @@ async def require_admin_session_for_admin_routes(request: Request, call_next):
@app.post("/api/auth/login")
async def login(data: dict):
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
remember_me = bool(data.get("remember_me") or data.get("rememberMe"))
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT * FROM admins WHERE username = $1 OR email = $1",
@ -1066,19 +1083,25 @@ async def login(data: dict):
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
temp_token = jwt.encode(
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
{
"sub": admin['username'],
"partial": True,
"remember_me": remember_me,
"exp": datetime.utcnow() + timedelta(minutes=5),
},
SECRET_KEY, algorithm=ALGORITHM
)
return {"step": "2fa", "temp_token": temp_token}
@app.post("/api/auth/verify-2fa")
async def verify_2fa(data: dict, response: Response):
async def verify_2fa(data: dict, response: Response, request: Request):
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
try:
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
if not payload.get("partial"):
raise JWTError()
username = payload.get("sub")
remember_me = bool(payload.get("remember_me"))
except JWTError:
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
@ -1090,8 +1113,13 @@ async def verify_2fa(data: dict, response: Response):
print("❌ Ugyldig 2FA-kode ved admin-innlogging")
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
session_max_age = (
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS
if remember_me
else ADMIN_SESSION_MAX_AGE_SECONDS
)
final_token = jwt.encode(
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
{"sub": username, "exp": datetime.utcnow() + timedelta(seconds=session_max_age)},
SECRET_KEY, algorithm=ALGORITHM
)
@ -1099,20 +1127,22 @@ async def verify_2fa(data: dict, response: Response):
response.set_cookie(
key="admin_session",
value=final_token,
max_age=session_max_age,
expires=session_max_age,
httponly=True,
samesite="lax",
secure=False # Sett til True i produksjon (HTTPS)
secure=should_use_secure_cookies(request),
)
return {"status": "success"}
@app.post("/api/auth/logout")
async def logout(response: Response):
async def logout(response: Response, request: Request):
"""Logger ut admin ved å slette sesjonscookien."""
response.delete_cookie(
key="admin_session",
httponly=True,
samesite="lax",
secure=False,
secure=should_use_secure_cookies(request),
)
return {"status": "success"}
@ -1927,6 +1957,8 @@ async def update_facility_full(facility_id: int, request: Request):
async with app.state.pool.acquire() as conn:
async with conn.transaction(): # Sikrer at alt lagres samlet
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:
@ -2196,6 +2228,9 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
async with app.state.pool.acquire() as conn:
async with conn.transaction():
facility_columns = await get_table_columns(conn, "facilities")
has_cooperating_clubs = "cooperating_clubs" in facility_columns
for approval in request.approvals:
draft_row = await conn.fetchrow(
"SELECT greenfee_draft FROM facilities WHERE id = $1",
@ -2210,6 +2245,7 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
exclude_facility_id=approval.facility_id,
)
if has_cooperating_clubs:
await conn.execute("""
UPDATE facilities
SET greenfee = $1::jsonb,
@ -2221,6 +2257,14 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
greenfee_draft = NULL
WHERE id = $3
""", json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
else:
await conn.execute("""
UPDATE facilities
SET greenfee = $1::jsonb,
greenfee_updated_at = NOW(),
greenfee_draft = NULL
WHERE id = $2
""", json.dumps(approval.greenfee), approval.facility_id)
return {"status": "success"}
@app.post("/api/admin/run-greenfee-scraper")

BIN
bilde1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

32
deploy/Caddyfile Normal file
View file

@ -0,0 +1,32 @@
{
email {$ACME_EMAIL}
}
www.teeoff.no {
redir https://teeoff.no{uri} permanent
}
teeoff.no {
encode zstd gzip
log {
output stdout
format console
}
# This upload route is implemented in Next.js, not FastAPI.
handle /api/admin/uploads/images* {
reverse_proxy frontend:3000
}
# All other /api traffic goes to the FastAPI backend.
# Use handle, not handle_path, so the /api prefix is preserved.
handle /api/* {
reverse_proxy api:8000
}
# Everything else is served by Next.js.
handle {
reverse_proxy frontend:3000
}
}

95
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,95 @@
services:
db:
image: postgis/postgis:15-3.4
container_name: teeoff_db
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- teeoff_db_data:/var/lib/postgresql/data
restart: unless-stopped
api:
build: ./backend
container_name: teeoff_api
environment:
DATABASE_URL: ${DATABASE_URL}
JWT_SECRET: ${JWT_SECRET}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET}
PUBLIC_COMMENT_DEFAULT_STATUS: ${PUBLIC_COMMENT_DEFAULT_STATUS}
SMTP_SERVER: ${SMTP_SERVER}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
volumes:
- ./backend:/app
- ./frontend/src/content:/shared/frontend-content:ro
- ./frontend/public/media:/app/public/media
depends_on:
- db
restart: unless-stopped
expose:
- "8000"
worker:
build: ./backend
container_name: teeoff_worker
command: python worker.py
environment:
DATABASE_URL: ${DATABASE_URL}
GEMINI_API_KEY: ${GEMINI_API_KEY}
SMTP_SERVER: ${SMTP_SERVER}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
EMAIL_TO: ${EMAIL_TO}
volumes:
- ./backend:/app
depends_on:
- db
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
container_name: teeoff_frontend
command: npm start
environment:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
volumes:
- ./frontend/public/uploads:/app/public/uploads
depends_on:
- api
restart: unless-stopped
expose:
- "3000"
caddy:
image: caddy:2
container_name: teeoff_caddy
ports:
- "80:80"
- "443:443"
environment:
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- frontend
- api
restart: unless-stopped
volumes:
teeoff_db_data:
caddy_data:
caddy_config:

141
docs/vps-deploy-teeoff.md Normal file
View file

@ -0,0 +1,141 @@
# TeeOff VPS deployment
This project can be served on `https://teeoff.no` with Docker Compose and Caddy.
## 1. DNS at one.com
Create or update these records:
- `A` record for the root domain (`@` / empty hostname) -> `85.137.228.98`
- `CNAME` for `www` -> `teeoff.no`
If `teeoff.no` currently has old `A`, `AAAA`, web-forward, or alias records pointing elsewhere, remove or replace them.
If you do not actively use IPv6 on the VPS, remove any stale `AAAA` record for `teeoff.no` and `www`.
one.com documents A/CNAME management here:
- https://help.one.com/hc/en-us/articles/360000799298-How-do-I-create-an-A-record
- https://help.one.com/hc/en-us/articles/360000803517-How-do-I-create-a-CNAME-record
## 2. Required environment values
On the VPS, create `.env` in the project root and make sure these values are correct:
```env
PUBLIC_BASE_URL=https://teeoff.no
NEXT_PUBLIC_SITE_URL=https://teeoff.no
DATABASE_URL=postgresql://teeoff_admin:...@db:5432/teeoff
POSTGRES_USER=teeoff_admin
POSTGRES_PASSWORD=...
POSTGRES_DB=teeoff
JWT_SECRET=...
PUBLIC_SESSION_SECRET=...
ACME_EMAIL=you@example.com
```
If public comments or Google login are used, keep the SMTP and Google OAuth values configured too.
Important Google OAuth update:
- Authorized redirect URI should include `https://teeoff.no/api/public/auth/google/callback`
## 3. Start the production stack
Use the production compose file:
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
This stack exposes only:
- `80/tcp`
- `443/tcp`
The app containers stay internal and are reached through Caddy only.
## 4. Reverse proxy layout
The Caddy config is in `deploy/Caddyfile`.
Routing is:
- `https://teeoff.no/api/admin/uploads/images` -> Next.js
- all other `https://teeoff.no/api/*` -> FastAPI
- everything else -> Next.js frontend
That exception matters because the image upload endpoint is implemented in Next.js, while the rest of the API lives in FastAPI.
The `/api` prefix must be preserved when proxying, because both apps define routes with `/api/...` paths.
## 5. VPS hardening
Recommended minimum host steps on Ubuntu 24.04:
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y ufw fail2ban
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
Important Docker note: published Docker ports can bypass `ufw` if you expose them directly on the host. That is why the production compose file publishes only Caddy's `80` and `443`.
Useful official references:
- Ubuntu UFW docs: https://ubuntu.com/server/docs/how-to/security/firewalls/
- Docker firewall warning: https://docs.docker.com/engine/network/packet-filtering-firewalls/
- Docker port publishing warning: https://docs.docker.com/engine/network/port-publishing/
- Caddy automatic HTTPS: https://caddyserver.com/docs/automatic-https
## 6. SSH hardening
Do this after you have verified SSH key login works:
- create a non-root sudo user if you do not already use one
- disable password authentication in `/etc/ssh/sshd_config`
- disable root SSH login if you do not need it
- restart SSH
Typical settings:
```text
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
```
Then:
```bash
sudo systemctl restart ssh
```
## 7. Verification checklist
After DNS has propagated and the stack is up:
```bash
curl -I http://teeoff.no
curl -I https://teeoff.no
curl -I https://www.teeoff.no
curl -I https://teeoff.no/api/health
```
Expected results:
- `http://teeoff.no` redirects to HTTPS
- `https://www.teeoff.no` redirects to `https://teeoff.no`
- `https://teeoff.no/api/health` returns the API health payload
## 8. Existing compose file
`docker-compose.yml` is still useful for the earlier/local setup.
For the VPS cutover, prefer:
```bash
docker compose -f docker-compose.prod.yml up -d --build
```

1
frontend/.nvmrc Normal file
View file

@ -0,0 +1 @@
20

View file

@ -2,15 +2,18 @@ FROM node:20-alpine
WORKDIR /app
# Kopier package.json og installer avhengigheter
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
# Kopier package.json og installer avhengigheter deterministisk
COPY package*.json ./
RUN npm install
RUN npm ci
# Kopier resten av koden
COPY . .
# BYGG koden her (kjøres jyb én gang når imaget bygges
# Bygg koden én gang ved image-build
RUN npm run build
# Vi starter serveren i "produksjons"-modus (utviklingsmodus).
# Start Next i produksjonsmodus
CMD ["npm", "start"]

View file

@ -2,11 +2,15 @@
"name": "frontend",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.9.0",
"npm": ">=10"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint ."
},
"dependencies": {
"@tiptap/extension-image": "^3.22.3",

View file

@ -236,13 +236,20 @@ const sanitizeHref = (value: string) => {
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
};
const getSiteOrigin = () => {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no";
};
const isInternalTeeoffHref = (href: string) => {
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
return true;
}
try {
const url = new URL(href, "https://nye.teeoff.no");
const url = new URL(href, getSiteOrigin());
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
} catch {
return false;

View file

@ -13,7 +13,7 @@ import { API_URL } from "@/config/constants";
export default function AdminLogin() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
const [formData, setFormData] = useState({ username: '', password: '', code: '', rememberMe: false });
const [tempToken, setTempToken] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
@ -28,7 +28,11 @@ export default function AdminLogin() {
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: formData.username, password: formData.password })
body: JSON.stringify({
username: formData.username,
password: formData.password,
remember_me: formData.rememberMe,
})
});
const data = await res.json();
@ -85,6 +89,15 @@ export default function AdminLogin() {
<>
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
<label className="flex items-center gap-3 rounded-2xl bg-[#f7faF4] px-4 py-4 text-sm font-bold text-[#11280f]">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-[#11280f] focus:ring-[#8bc34a]"
checked={formData.rememberMe}
onChange={e => setFormData(prevState => ({ ...prevState, rememberMe: e.target.checked }))}
/>
Husk meg i 30 dager denne enheten
</label>
</>
) : (
<div className="space-y-4">

View file

@ -0,0 +1,86 @@
"use client";
import { Icon as LeafletIcon } from "leaflet";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
import { STATUS_MAP } from "@/config/constants";
import { STATUS_ICON_PATHS } from "@/app/facilityData";
type FacilityDetailLeafletMapProps = {
lat: number;
lng: number;
name: string;
city?: string | null;
county?: string | null;
primaryStatus: string;
mapUrl: string | null;
};
const detailMarkerIconCache: Record<string, LeafletIcon> = {};
const getDetailMarkerIcon = (status: string) => {
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
if (!detailMarkerIconCache[key]) {
detailMarkerIconCache[key] = new LeafletIcon({
iconUrl: STATUS_ICON_PATHS[key],
iconSize: [34, 48],
iconAnchor: [17, 48],
popupAnchor: [0, -42],
});
}
return detailMarkerIconCache[key];
};
export default function FacilityDetailLeafletMap({
lat,
lng,
name,
city,
county,
primaryStatus,
mapUrl,
}: FacilityDetailLeafletMapProps) {
return (
<div className="h-[450px] w-full md:h-[650px]">
<MapContainer
center={[lat, lng]}
zoom={13}
scrollWheelZoom={false}
zoomControl
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker
position={[lat, lng]}
icon={getDetailMarkerIcon(primaryStatus)}
>
<Popup>
<div className="space-y-3">
<div>
<p className="text-lg font-extrabold text-[#112015]">{name}</p>
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
{city} {county}
</p>
</div>
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
{STATUS_MAP[primaryStatus] || "Ukjent status"}
</div>
{mapUrl && (
<a
href={mapUrl}
target="_blank"
rel="noreferrer"
className="btn btn-sm btn-secondary"
>
Åpne kart
</a>
)}
</div>
</Popup>
</Marker>
</MapContainer>
</div>
);
}

View file

@ -14,27 +14,20 @@
*/
import { useState, useEffect } from 'react';
import { Icon as LeafletIcon } from "leaflet";
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
import dynamic from "next/dynamic";
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
import Link from 'next/link';
import CourseDisplay from './CourseDisplay';
const detailMarkerIconCache: Record<string, LeafletIcon> = {};
const getDetailMarkerIcon = (status: string) => {
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
if (!detailMarkerIconCache[key]) {
detailMarkerIconCache[key] = new LeafletIcon({
iconUrl: STATUS_ICON_PATHS[key],
iconSize: [34, 48],
iconAnchor: [17, 48],
popupAnchor: [0, -42],
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
ssr: false,
loading: () => (
<div className="flex h-[450px] w-full items-center justify-center bg-[#f1f7ed] text-sm font-bold text-[#617063] md:h-[650px]">
Laster kart
</div>
),
});
}
return detailMarkerIconCache[key];
};
const formatPhoneForUrl = (phone: string) => {
if (!phone) return "";
@ -65,13 +58,20 @@ const sanitizeHref = (value: string) => {
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
};
const getSiteOrigin = () => {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no";
};
const isInternalTeeoffHref = (href: string) => {
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
return true;
}
try {
const url = new URL(href, "https://nye.teeoff.no");
const url = new URL(href, getSiteOrigin());
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
} catch {
return false;
@ -445,20 +445,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</span>
</div>
{/* SAMARBEIDENDE KLUBBER */}
{cooperatingClubs.length > 0 && (
<div className="pt-2">
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
<div className="flex flex-wrap gap-2">
{cooperatingClubs.map((slug: string) => (
<Link key={slug} href={`/golfbaner/${slug}`} className="btn btn-sm btn-secondary">
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
</Link>
))}
</div>
</div>
)}
{golfpakkerRaw.length > 0 && (
<div className="pt-4">
<span className="text-gray-400 block mb-2">Golfpakker:</span>
@ -503,48 +489,15 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<section id="map" 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">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="teeoff-map overflow-hidden md:rounded-[3rem] border-y-4 md:border-[12px] border-white bg-white shadow-xl">
<div className="h-[450px] md:h-[650px] w-full">
<MapContainer
center={[facility.lat as number, facility.lng as number]}
zoom={13}
scrollWheelZoom={false}
zoomControl
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
<FacilityDetailLeafletMap
lat={facility.lat as number}
lng={facility.lng as number}
name={facility.name}
city={facility.city}
county={facility.county}
primaryStatus={primaryStatus}
mapUrl={mapUrl}
/>
<Marker
position={[facility.lat as number, facility.lng as number]}
icon={getDetailMarkerIcon(primaryStatus)}
>
<Popup>
<div className="space-y-3">
<div>
<p className="text-lg font-extrabold text-[#112015]">{facility.name}</p>
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
{facility.city} {facility.county}
</p>
</div>
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
{STATUS_MAP[primaryStatus] || "Ukjent status"}
</div>
{mapUrl && (
<a
href={mapUrl}
target="_blank"
rel="noreferrer"
className="btn btn-sm btn-secondary"
>
Åpne kart
</a>
)}
</div>
</Popup>
</Marker>
</MapContainer>
</div>
</div>
</section>
)}
@ -655,6 +608,20 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
Krav: {facility.guest_requirements}
</p>
)}
{cooperatingClubs.length > 0 && (
<div className="mt-4 border-t border-gray-50 pt-4">
<span className="mb-3 block text-[10px] font-black uppercase tracking-widest text-gray-400">
Samarbeidende klubber
</span>
<div className="flex flex-wrap gap-2">
{cooperatingClubs.map((slug: string) => (
<Link key={slug} href={`/golfbaner/${slug}`} className="btn btn-sm btn-secondary">
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
</Link>
))}
</div>
</div>
)}
</div>
)}
</div>

View file

@ -1,105 +1,22 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo } from "react";
import { Icon, LatLngBounds } from "leaflet";
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
import {
type EnrichedFacility,
STATUS_ICON_PATHS,
buildMapUrl,
formatUpdatedDate,
getStatusLabel,
parseJson,
} from "@/app/facilityData";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { type EnrichedFacility, STATUS_ICON_PATHS } from "@/app/facilityData";
type PlaceMapProps = {
facilities: EnrichedFacility[];
placeLabel: string;
};
const markerIconCache: Record<string, Icon> = {};
const getMarkerIcon = (status: string) => {
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
if (!markerIconCache[key]) {
markerIconCache[key] = new Icon({
iconUrl: STATUS_ICON_PATHS[key],
iconSize: [34, 48],
iconAnchor: [17, 48],
popupAnchor: [0, -42],
const PlaceMapLeaflet = dynamic(() => import("./PlaceMapLeaflet"), {
ssr: false,
loading: () => (
<div className="flex h-[26rem] w-full items-center justify-center bg-[#F3F6EE] text-sm font-bold text-[#617063] sm:h-[34rem]">
Laster kart
</div>
),
});
}
return markerIconCache[key];
};
function ShiftScrollZoomGuard() {
const map = useMap();
useEffect(() => {
const updateWheelMode = (shiftPressed: boolean) => {
if (window.innerWidth < 1024) {
map.scrollWheelZoom.enable();
return;
}
if (shiftPressed) {
map.scrollWheelZoom.enable();
} else {
map.scrollWheelZoom.disable();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Shift") updateWheelMode(true);
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === "Shift") updateWheelMode(false);
};
const onBlur = () => updateWheelMode(false);
const onResize = () => updateWheelMode(false);
updateWheelMode(false);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
window.removeEventListener("resize", onResize);
};
}, [map]);
return null;
}
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
const map = useMap();
useEffect(() => {
const withCoords = facilities.filter(
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
);
if (withCoords.length === 0) return;
if (withCoords.length === 1) {
map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10);
return;
}
const bounds = new LatLngBounds(
withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number])
);
map.fitBounds(bounds, { padding: [36, 36] });
}, [facilities, map]);
return null;
}
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
if (type === "teeoff") {
@ -252,108 +169,7 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
</div>
<div className="teeoff-map overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
<div className="h-[26rem] w-full sm:h-[34rem]">
<MapContainer
center={[64.5, 15.5]}
zoom={5}
scrollWheelZoom
zoomControl
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ShiftScrollZoomGuard />
<FitMapBounds facilities={mapFacilities} />
{mapFacilities.map((facility) => {
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
return (
<Marker
key={facility.id}
position={[facility.lat as number, facility.lng as number]}
icon={getMarkerIcon(facility.primaryStatus)}
>
<Popup>
<div className="space-y-3">
<div>
<Link href={`/golfbaner/${facility.slug}`} className="text-lg font-extrabold text-[#112015] hover:text-[#FF5722]">
{facility.name}
</Link>
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
{facility.city} {facility.county}
</p>
</div>
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
{getStatusLabel(facility.primaryStatus)}
</div>
{facility.status_updated_at && (
<p className="text-[11px] text-[#617063]">Oppdatert {formatUpdatedDate(facility.status_updated_at)}</p>
)}
<div className="flex flex-wrap gap-2">
<a href={`/golfbaner/${facility.slug}`} className="map-popup-icon" aria-label={`Se ${facility.name} på TeeOff`}>
<ActionGlyph type="teeoff" />
</a>
{facility.phone && (
<a href={`tel:${facility.phone.replace(/\s/g, "")}`} className="map-popup-icon" aria-label={`Ring ${facility.name}`}>
<ActionGlyph type="phone" />
</a>
)}
{facility.email && (
<a href={`mailto:${facility.email}`} className="map-popup-icon" aria-label={`Send e-post til ${facility.name}`}>
<ActionGlyph type="mail" />
</a>
)}
{facility.website_url && (
<a href={facility.website_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Besøk nettsiden til ${facility.name}`}>
<ActionGlyph type="home" />
</a>
)}
{facility.golfbox_tournament_url && (
<a href={facility.golfbox_tournament_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Turneringer hos ${facility.name}`}>
<ActionGlyph type="calendar" />
</a>
)}
{facility.weather_url && (
<a href={facility.weather_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Vær for ${facility.name}`}>
<ActionGlyph type="weather" />
</a>
)}
{facebook && (
<a href={facebook} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Facebook for ${facility.name}`}>
<ActionGlyph type="facebook" />
</a>
)}
{instagram && (
<a href={instagram} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Instagram for ${facility.name}`}>
<ActionGlyph type="instagram" />
</a>
)}
{buildMapUrl(facility.lat, facility.lng) && (
<a
href={buildMapUrl(facility.lat, facility.lng) || "#"}
target="_blank"
rel="noreferrer"
className="rounded-full border border-[#D7DED0] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] transition hover:border-[#FF5722] hover:text-[#FF5722]"
>
Åpne kart
</a>
)}
</div>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
<PlaceMapLeaflet facilities={mapFacilities} />
</div>
</section>
);

View file

@ -0,0 +1,267 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import { Icon, LatLngBounds } from "leaflet";
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
import {
buildMapUrl,
formatUpdatedDate,
getStatusLabel,
parseJson,
STATUS_ICON_PATHS,
type EnrichedFacility,
} from "@/app/facilityData";
type PlaceMapLeafletProps = {
facilities: EnrichedFacility[];
};
const markerIconCache: Record<string, Icon> = {};
const getMarkerIcon = (status: string) => {
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
if (!markerIconCache[key]) {
markerIconCache[key] = new Icon({
iconUrl: STATUS_ICON_PATHS[key],
iconSize: [34, 48],
iconAnchor: [17, 48],
popupAnchor: [0, -42],
});
}
return markerIconCache[key];
};
function ShiftScrollZoomGuard() {
const map = useMap();
useEffect(() => {
const updateWheelMode = (shiftPressed: boolean) => {
if (window.innerWidth < 1024) {
map.scrollWheelZoom.enable();
return;
}
if (shiftPressed) {
map.scrollWheelZoom.enable();
} else {
map.scrollWheelZoom.disable();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Shift") updateWheelMode(true);
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === "Shift") updateWheelMode(false);
};
const onBlur = () => updateWheelMode(false);
const onResize = () => updateWheelMode(false);
updateWheelMode(false);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
window.removeEventListener("resize", onResize);
};
}, [map]);
return null;
}
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
const map = useMap();
useEffect(() => {
const withCoords = facilities.filter(
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
);
if (withCoords.length === 0) return;
if (withCoords.length === 1) {
map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10);
return;
}
const bounds = new LatLngBounds(
withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number])
);
map.fitBounds(bounds, { padding: [36, 36] });
}, [facilities, map]);
return null;
}
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
if (type === "teeoff") {
return <span className="text-lg font-black leading-none text-[#FF5722]">t</span>;
}
return (
<svg
className="h-4 w-4 text-[#FF5722]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{type === "phone" && <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />}
{type === "mail" && (
<>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</>
)}
{type === "home" && (
<>
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5 10.5V20h14v-9.5" />
</>
)}
{type === "calendar" && (
<>
<path d="M3 10h18" />
<path d="M8 3v4" />
<path d="M16 3v4" />
<rect x="4" y="5" width="16" height="16" rx="2" />
</>
)}
{type === "weather" && (
<>
<path d="M12 2v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="M20 12h2" />
<path d="m19.07 4.93-1.41 1.41" />
<path d="M15.947 12.65a4 4 0 0 0-5.925-4.128" />
<path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z" />
</>
)}
{type === "facebook" && <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />}
{type === "instagram" && (
<>
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
</>
)}
</svg>
);
}
export default function PlaceMapLeaflet({ facilities }: PlaceMapLeafletProps) {
return (
<div className="h-[26rem] w-full sm:h-[34rem]">
<MapContainer
center={[64.5, 15.5]}
zoom={5}
scrollWheelZoom
zoomControl
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ShiftScrollZoomGuard />
<FitMapBounds facilities={facilities} />
{facilities.map((facility) => {
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
return (
<Marker
key={facility.id}
position={[facility.lat as number, facility.lng as number]}
icon={getMarkerIcon(facility.primaryStatus)}
>
<Popup>
<div className="space-y-3">
<div>
<Link href={`/golfbaner/${facility.slug}`} className="text-lg font-extrabold text-[#112015] hover:text-[#FF5722]">
{facility.name}
</Link>
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
{facility.city} {facility.county}
</p>
</div>
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
{getStatusLabel(facility.primaryStatus)}
</div>
{facility.status_updated_at && (
<p className="text-[11px] text-[#617063]">Oppdatert {formatUpdatedDate(facility.status_updated_at)}</p>
)}
<div className="flex flex-wrap gap-2">
<a href={`/golfbaner/${facility.slug}`} className="map-popup-icon" aria-label={`Se ${facility.name} på TeeOff`}>
<ActionGlyph type="teeoff" />
</a>
{facility.phone && (
<a href={`tel:${facility.phone.replace(/\s/g, "")}`} className="map-popup-icon" aria-label={`Ring ${facility.name}`}>
<ActionGlyph type="phone" />
</a>
)}
{facility.email && (
<a href={`mailto:${facility.email}`} className="map-popup-icon" aria-label={`Send e-post til ${facility.name}`}>
<ActionGlyph type="mail" />
</a>
)}
{facility.website_url && (
<a href={facility.website_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Besøk nettsiden til ${facility.name}`}>
<ActionGlyph type="home" />
</a>
)}
{facility.golfbox_tournament_url && (
<a href={facility.golfbox_tournament_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Turneringer hos ${facility.name}`}>
<ActionGlyph type="calendar" />
</a>
)}
{facility.weather_url && (
<a href={facility.weather_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Vær for ${facility.name}`}>
<ActionGlyph type="weather" />
</a>
)}
{facebook && (
<a href={facebook} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Facebook for ${facility.name}`}>
<ActionGlyph type="facebook" />
</a>
)}
{instagram && (
<a href={instagram} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Instagram for ${facility.name}`}>
<ActionGlyph type="instagram" />
</a>
)}
{buildMapUrl(facility.lat, facility.lng) && (
<a
href={buildMapUrl(facility.lat, facility.lng) || "#"}
target="_blank"
rel="noreferrer"
className="rounded-full border border-[#D7DED0] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] transition hover:border-[#FF5722] hover:text-[#FF5722]"
>
Åpne kart
</a>
)}
</div>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
);
}

View file

@ -20,7 +20,7 @@ CREATE TABLE facilities (
proshop_url VARCHAR(255),
cafe_url VARCHAR(255),
nsg_agreement BOOLEAN DEFAULT FALSE,
cooperating_clubs TEXT,
cooperating_clubs JSONB DEFAULT '[]'::jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View file

@ -0,0 +1,47 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'facilities'
AND column_name = 'cooperating_clubs'
AND udt_name = 'text'
) THEN
ALTER TABLE public.facilities
RENAME COLUMN cooperating_clubs TO cooperating_clubs_legacy;
END IF;
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'facilities'
AND column_name = 'cooperating_clubs'
) THEN
ALTER TABLE public.facilities
ADD COLUMN cooperating_clubs jsonb DEFAULT '[]'::jsonb;
END IF;
ALTER TABLE public.facilities
ALTER COLUMN cooperating_clubs SET DEFAULT '[]'::jsonb;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'facilities'
AND column_name = 'cooperating_clubs_legacy'
) THEN
UPDATE public.facilities
SET cooperating_clubs = CASE
WHEN cooperating_clubs_legacy IS NULL OR btrim(cooperating_clubs_legacy) = '' THEN '[]'::jsonb
WHEN cooperating_clubs_legacy ~ '^\s*\[' THEN cooperating_clubs_legacy::jsonb
ELSE to_jsonb(regexp_split_to_array(cooperating_clubs_legacy, '\s*,\s*'))
END
WHERE cooperating_clubs = '[]'::jsonb;
ALTER TABLE public.facilities
DROP COLUMN cooperating_clubs_legacy;
END IF;
END $$;

View file

@ -24,6 +24,7 @@ CREATE TABLE facilities (
website_url VARCHAR(255),
golfbox_booking_url VARCHAR(255),
golfbox_tournament_url VARCHAR(255),
cooperating_clubs JSONB DEFAULT '[]'::jsonb,
facebook_url VARCHAR(255),
instagram_url VARCHAR(255),
weather_url VARCHAR(255),