diff --git a/2026-04-28_135017.png b/2026-04-28_135017.png new file mode 100644 index 0000000..2494b77 Binary files /dev/null and b/2026-04-28_135017.png differ diff --git a/backend/main.py b/backend/main.py index 1066bc5..aaaad1d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -50,7 +50,7 @@ from course_status_history import ( log_course_status_change, ) from env_config import get_database_url, get_required_env -from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows +from vtg_courses import filter_upcoming_courses, get_invalid_vtg_course_labels, normalize_vtg_course_rows from weather_forecast import ensure_weather_forecast_table, weather_sync_loop # --- KONFIGURASJON --- @@ -303,6 +303,15 @@ def collect_facility_indexnow_urls(slugs: list[str], extra_paths: list[str] | No return dedupe_strings(urls) +def collect_page_indexnow_urls(paths: list[str]) -> list[str]: + urls: list[str] = [] + for path in paths: + public_url = build_absolute_public_url(path) + if public_url: + urls.append(public_url) + return dedupe_strings(urls) + + def get_indexnow_key_location() -> str | None: configured = INDEXNOW_KEY_LOCATION.strip() if configured: @@ -760,6 +769,8 @@ class ArticleUpsertRequest(BaseModel): section: Optional[str] = "banebesok" slug: str title: str + meta_title: Optional[str] = None + meta_description: Optional[str] = None description: Optional[str] = None excerpt: Optional[str] = None eyebrow: Optional[str] = None @@ -1044,6 +1055,28 @@ async def replace_facility_vtg_courses(conn, facility_id: int, rows: Any) -> lis return normalized_rows +def ensure_valid_vtg_course_rows(approvals: list[Any]) -> None: + invalid_entries: list[str] = [] + for approval in approvals: + invalid_labels = get_invalid_vtg_course_labels(getattr(approval, "vtg_datoer", None)) + if invalid_labels: + preview = ", ".join(invalid_labels[:3]) + if len(invalid_labels) > 3: + preview = f"{preview}, +{len(invalid_labels) - 3} til" + invalid_entries.append( + f"anlegg {getattr(approval, 'facility_id', 'ukjent')}: {preview}" + ) + + if invalid_entries: + raise HTTPException( + status_code=400, + detail=( + "Kun kurs med gyldige datoer kan godkjennes. Ugyldige kursrader funnet for " + + "; ".join(invalid_entries) + ), + ) + + def format_place_page_row(row): if row is None: return None @@ -2461,6 +2494,8 @@ async def ensure_articles_table(conn): section VARCHAR(32) NOT NULL DEFAULT 'banebesok', slug VARCHAR(255) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, + meta_title TEXT, + meta_description TEXT, description TEXT, excerpt TEXT, eyebrow VARCHAR(120) DEFAULT 'Banebesøk', @@ -2492,6 +2527,14 @@ async def ensure_articles_table(conn): ALTER TABLE articles ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255) """) + await conn.execute(""" + ALTER TABLE articles + ADD COLUMN IF NOT EXISTS meta_title TEXT + """) + await conn.execute(""" + ALTER TABLE articles + ADD COLUMN IF NOT EXISTS meta_description TEXT + """) await conn.execute(""" UPDATE articles SET section = 'banebesok' @@ -3858,7 +3901,7 @@ async def get_place_page(slug: str, response: Response): return payload -VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "simulatorer"} +VALID_SITE_PAGE_SEO_KEYS = {"golfbaner", "vtg", "medlemskap", "banebesok", "meninger", "simulatorer"} @app.get("/api/page-seo/{page_key}") @@ -4082,6 +4125,10 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest): ) invalidate_public_api_caches(include_place_pages=True) + schedule_indexnow_submission( + collect_page_indexnow_urls([f"/sted/{normalized_slug}"]), + reason="admin place page upsert", + ) return format_place_page_row(row) @@ -4131,6 +4178,18 @@ async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRe normalize_optional_text(request.meta_description), ) + page_path_map = { + "golfbaner": "/golfbaner", + "vtg": "/vtg", + "medlemskap": "/medlemskap", + "banebesok": "/banebesok", + "meninger": "/meninger", + "simulatorer": "/simulatorer", + } + schedule_indexnow_submission( + collect_page_indexnow_urls([page_path_map[normalized_key]]), + reason="admin site page seo upsert", + ) return format_site_page_seo_row(row) @@ -4577,17 +4636,19 @@ async def upsert_admin_article(request: ArticleUpsertRequest): ) row = await conn.fetchrow(""" INSERT INTO articles ( - section, slug, title, description, excerpt, eyebrow, location_label, + section, slug, title, meta_title, meta_description, description, excerpt, eyebrow, location_label, facility_name, facility_slug, author_name, status, hero_images, media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, $10, $11, $12::jsonb, - $13::jsonb, $14, $15, $16, $17, $18, $19 + $1, $2, $3, $4, $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14::jsonb, + $15::jsonb, $16, $17, $18, $19, $20, $21 ) ON CONFLICT (slug) DO UPDATE SET section = EXCLUDED.section, title = EXCLUDED.title, + meta_title = EXCLUDED.meta_title, + meta_description = EXCLUDED.meta_description, description = EXCLUDED.description, excerpt = EXCLUDED.excerpt, eyebrow = EXCLUDED.eyebrow, @@ -4609,6 +4670,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest): section, requested_slug, request.title.strip(), + (request.meta_title or "").strip() or None, + (request.meta_description or "").strip() or None, (request.description or "").strip() or None, (request.excerpt or "").strip() or None, (request.eyebrow or "Banebesøk").strip(), @@ -5077,6 +5140,7 @@ async def approve_vtg_content_bulk(request: BulkVtgContentRequest): @app.post("/api/admin/vtg/approve-courses-bulk") async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest): + ensure_valid_vtg_course_rows(request.approvals) facility_ids = [approval.facility_id for approval in request.approvals] async with app.state.pool.acquire() as conn: async with conn.transaction(): @@ -5108,6 +5172,7 @@ async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest): @app.post("/api/admin/vtg/approve-bulk") async def approve_vtg_bulk(request: BulkVtgRequest): """Kompatibilitets-endepunkt som godkjenner både innhold og kurs.""" + ensure_valid_vtg_course_rows(request.approvals) facility_ids = [approval.facility_id for approval in request.approvals] async with app.state.pool.acquire() as conn: async with conn.transaction(): diff --git a/backend/vtg_courses.py b/backend/vtg_courses.py index 6a939e0..f46c78c 100644 --- a/backend/vtg_courses.py +++ b/backend/vtg_courses.py @@ -200,3 +200,41 @@ def is_upcoming_course(row: dict[str, Any], today: date | None = None) -> bool: def filter_upcoming_courses(rows: Any) -> list[dict[str, Any]]: normalized_rows = normalize_vtg_course_rows(rows) return [row for row in normalized_rows if is_upcoming_course(row)] + + +def get_invalid_vtg_course_labels(rows: Any) -> list[str]: + if not isinstance(rows, list): + return [] + + invalid_labels: list[str] = [] + for row in rows: + if not isinstance(row, dict): + continue + + display_label = normalize_whitespace(str(row.get("dato") or row.get("display_label") or "")) + if not display_label: + continue + + explicit_start = row.get("start_date") + explicit_end = row.get("end_date") + start_date = None + end_date = None + + if explicit_start: + try: + start_date = datetime.fromisoformat(str(explicit_start)).date() + except ValueError: + start_date = None + if explicit_end: + try: + end_date = datetime.fromisoformat(str(explicit_end)).date() + except ValueError: + end_date = None + + if not start_date and not end_date: + start_date, end_date = parse_course_date_range(display_label) + + if not start_date and not end_date: + invalid_labels.append(display_label) + + return invalid_labels diff --git a/frontend/src/app/admin/artikler/page.tsx b/frontend/src/app/admin/artikler/page.tsx index 02db5c8..cff1edf 100644 --- a/frontend/src/app/admin/artikler/page.tsx +++ b/frontend/src/app/admin/artikler/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { API_URL } from "@/config/constants"; import { adminFetch } from "@/config/adminFetch"; +import SeoFieldset, { trimSuggestion } from "@/components/admin/SeoFieldset"; import TiptapHtmlEditor from "@/components/TiptapHtmlEditor"; type ArticleMediaItem = { @@ -20,6 +21,8 @@ type AdminArticle = { section?: "banebesok" | "meninger"; slug: string; title: string; + meta_title?: string | null; + meta_description?: string | null; description?: string | null; excerpt?: string | null; eyebrow?: string | null; @@ -52,6 +55,8 @@ type ArticleFormState = { section: "banebesok" | "meninger"; slug: string; title: string; + meta_title: string; + meta_description: string; description: string; excerpt: string; eyebrow: string; @@ -114,6 +119,8 @@ function createEmptyForm(): ArticleFormState { section: "banebesok", slug: "", title: "", + meta_title: "", + meta_description: "", description: "", excerpt: "", eyebrow: "Banebesøk", @@ -144,6 +151,8 @@ function articleToForm(article: AdminArticle): ArticleFormState { section: article.section || "banebesok", slug: article.slug || "", title: article.title || "", + meta_title: article.meta_title || "", + meta_description: article.meta_description || "", description: article.description || "", excerpt: article.excerpt || "", eyebrow: article.eyebrow || "Banebesøk", @@ -182,6 +191,8 @@ function buildArticlePayload( section: form.section, slug: form.slug.trim(), title: form.title.trim(), + meta_title: form.meta_title.trim(), + meta_description: form.meta_description.trim(), description: form.description.trim(), excerpt: form.excerpt.trim(), eyebrow: form.eyebrow.trim(), @@ -222,6 +233,14 @@ export default function AdminArticlesPage() { const heroImageInputRef = useRef(null); const formSectionRef = useRef(null); const titleInputRef = useRef(null); + const articleSeoTitleSuggestion = trimSuggestion( + form.title.trim() ? `${form.title.trim()} | TeeOff.no` : "", + 60, + ); + const articleSeoDescriptionSuggestion = trimSuggestion( + form.description.trim() || form.excerpt.trim() || form.content_html, + 160, + ); const focusEditor = () => { window.requestAnimationFrame(() => { @@ -797,17 +816,28 @@ export default function AdminArticlesPage() {
+ handleFieldChange("meta_title", value)} + descriptionValue={form.meta_description} + onDescriptionChange={(value) => handleFieldChange("meta_description", value)} + suggestedTitle={articleSeoTitleSuggestion} + suggestedDescription={articleSeoDescriptionSuggestion} + titlePlaceholder="Egen meta title for søkeresultater" + descriptionPlaceholder="Egen meta description for søkeresultater" + helperText="Hvis disse feltene er tomme, faller artikkelen tilbake til vanlig tittel og kort beskrivelse." + />