diff --git a/backend/main.py b/backend/main.py index fee17d5..9c53983 100644 --- a/backend/main.py +++ b/backend/main.py @@ -816,6 +816,7 @@ FACILITY_VIEW_SEARCH_FIELDS = { 'course_statuses', 'weather_forecast', } FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | { + 'has_golfpakker', 'greenfee', 'standard_medlemskap', 'total_hole_count', 'hole_par_counts', 'shortest_hole_meters', 'longest_hole_meters', } @@ -1063,6 +1064,29 @@ async def resolve_cooperating_club_slugs( return resolved_slugs +async def ensure_public_query_indexes(conn) -> None: + await conn.execute(""" + CREATE INDEX IF NOT EXISTS facilities_is_published_name_idx + ON facilities (is_published, name) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS courses_facility_id_idx + ON courses (facility_id) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx + ON courses (facility_id, is_main_course DESC, id ASC) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS holes_course_id_idx + ON holes (course_id) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS facility_weather_forecast_facility_day_idx + ON facility_weather_forecast (facility_id, day_offset) + """) + + async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]: rows = await conn.fetch( """ @@ -1779,7 +1803,7 @@ async def get_published_facility_by_slug(conn, slug: str): SELECT id, name, slug FROM facilities WHERE slug = $1 - AND COALESCE(is_published, TRUE) = TRUE + AND is_published IS DISTINCT FROM FALSE LIMIT 1 """, slug, @@ -2218,6 +2242,7 @@ async def lifespan(app: FastAPI): await ensure_scrape_jobs_table(conn) await ensure_course_status_history_table(conn) await ensure_weather_forecast_table(conn) + await ensure_public_query_indexes(conn) app.state.weather_sync_stop_event = asyncio.Event() app.state.weather_sync_task = asyncio.create_task( weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event) @@ -2897,142 +2922,158 @@ async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsert def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]: normalized_view = (view or "").strip().lower() - - course_statuses_sql = """ - ( - SELECT jsonb_agg(cs) FROM ( - SELECT id, name, status FROM courses - WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' - ORDER BY is_main_course DESC, id ASC - ) cs - ) as course_statuses + published_facilities_cte = """ + published_facilities AS ( + SELECT * + FROM facilities + WHERE is_published IS DISTINCT FROM FALSE + ) """ - weather_compact_sql = """ - ( - SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( - SELECT - day_offset, - dry_daylight - FROM facility_weather_forecast - WHERE facility_id = f.id - ORDER BY day_offset ASC - ) w_data - ) as weather_forecast + course_statuses_cte = """ + course_statuses AS ( + SELECT + c.facility_id, + jsonb_agg( + jsonb_build_object( + 'id', c.id, + 'name', c.name, + 'status', c.status + ) + ORDER BY c.is_main_course DESC, c.id ASC + ) AS course_statuses + FROM courses c + JOIN published_facilities pf ON pf.id = c.facility_id + WHERE c.status != 'finnes_ingen_bane_to' + GROUP BY c.facility_id + ) """ - weather_full_sql = """ - ( - SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( - SELECT - forecast_date, - day_offset, - dry_all_day, - dry_daylight, - precip_mm, - precip_probability_max, - daylight_precip_mm, - daylight_precip_probability_max, - confidence, - source_updated_at, - source_expires_at, - calculated_at - FROM facility_weather_forecast - WHERE facility_id = f.id - ORDER BY day_offset ASC - ) w_data - ) as weather_forecast + weather_compact_cte = """ + weather_compact AS ( + SELECT + facility_id, + jsonb_agg( + jsonb_build_object( + 'day_offset', day_offset, + 'dry_daylight', dry_daylight + ) + ORDER BY day_offset ASC + ) AS weather_forecast + FROM facility_weather_forecast + WHERE facility_id IN (SELECT id FROM published_facilities) + GROUP BY facility_id + ) + """ + weather_full_cte = """ + weather_full AS ( + SELECT + facility_id, + jsonb_agg( + jsonb_build_object( + 'forecast_date', forecast_date, + 'day_offset', day_offset, + 'dry_all_day', dry_all_day, + 'dry_daylight', dry_daylight, + 'precip_mm', precip_mm, + 'precip_probability_max', precip_probability_max, + 'daylight_precip_mm', daylight_precip_mm, + 'daylight_precip_probability_max', daylight_precip_probability_max, + 'confidence', confidence, + 'source_updated_at', source_updated_at, + 'source_expires_at', source_expires_at, + 'calculated_at', calculated_at + ) + ORDER BY day_offset ASC + ) AS weather_forecast + FROM facility_weather_forecast + WHERE facility_id IN (SELECT id FROM published_facilities) + GROUP BY facility_id + ) + """ + hole_counts_cte = """ + hole_counts AS ( + SELECT + c.facility_id, + COUNT(h.id) AS total_hole_count, + jsonb_build_object( + '3', COUNT(*) FILTER (WHERE h.par = 3), + '4', COUNT(*) FILTER (WHERE h.par = 4), + '5', COUNT(*) FILTER (WHERE h.par = 5), + '6', COUNT(*) FILTER (WHERE h.par = 6) + ) AS hole_par_counts + FROM courses c + JOIN holes h ON h.course_id = c.id + JOIN published_facilities pf ON pf.id = c.facility_id + GROUP BY c.facility_id + ) + """ + hole_lengths_cte = """ + hole_lengths AS ( + SELECT + c.facility_id, + MIN((length_value.value)::int) AS shortest_hole_meters, + MAX((length_value.value)::int) AS longest_hole_meters + FROM courses c + JOIN holes h ON h.course_id = c.id + JOIN published_facilities pf ON pf.id = c.facility_id + CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value) + WHERE length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst') + AND length_value.value ~ '^[0-9]+$' + AND (length_value.value)::int BETWEEN 30 AND 900 + GROUP BY c.facility_id + ) """ has_golfpakker_sql = """ CASE - WHEN jsonb_typeof(f.golfpakker) = 'array' AND jsonb_array_length(f.golfpakker) > 0 THEN TRUE - WHEN NULLIF(BTRIM(COALESCE(f.golfpakker_url, '')), '') IS NOT NULL THEN TRUE + WHEN jsonb_typeof(pf.golfpakker) = 'array' AND jsonb_array_length(pf.golfpakker) > 0 THEN TRUE + WHEN NULLIF(BTRIM(COALESCE(pf.golfpakker_url, '')), '') IS NOT NULL THEN TRUE ELSE FALSE - END as has_golfpakker - """ - total_hole_count_sql = """ - ( - SELECT COUNT(*) - FROM holes h - JOIN courses c ON c.id = h.course_id - WHERE c.facility_id = f.id - ) as total_hole_count - """ - hole_par_counts_sql = """ - ( - SELECT jsonb_build_object( - '3', COUNT(*) FILTER (WHERE h.par = 3), - '4', COUNT(*) FILTER (WHERE h.par = 4), - '5', COUNT(*) FILTER (WHERE h.par = 5), - '6', COUNT(*) FILTER (WHERE h.par = 6) - ) - FROM holes h - JOIN courses c ON c.id = h.course_id - WHERE c.facility_id = f.id - ) as hole_par_counts - """ - shortest_hole_sql = """ - ( - SELECT MIN((length_value.value)::int) - FROM holes h - JOIN courses c ON c.id = h.course_id - CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value) - WHERE c.facility_id = f.id - AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst') - AND length_value.value ~ '^[0-9]+$' - AND (length_value.value)::int BETWEEN 30 AND 900 - ) as shortest_hole_meters - """ - longest_hole_sql = """ - ( - SELECT MAX((length_value.value)::int) - FROM holes h - JOIN courses c ON c.id = h.course_id - CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value) - WHERE c.facility_id = f.id - AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst') - AND length_value.value ~ '^[0-9]+$' - AND (length_value.value)::int BETWEEN 30 AND 900 - ) as longest_hole_meters + END AS has_golfpakker """ if normalized_view in {"search", "home"}: return ( f""" + WITH + {published_facilities_cte}, + {course_statuses_cte}, + {weather_compact_cte} SELECT - f.id, - f.slug, - f.name, - f.architect, - f.description, - f.city, - f.county, - f.banetype, - f.image_url, - f.phone, - f.website_url, - f.golfbox_booking_url, - f.golfbox_tournament_url, - f.weather_url, - f.lat, - f.lng, - f.golfamore, - f.golfamore_url, - f.nsg_url, + pf.id, + pf.slug, + pf.name, + pf.architect, + pf.description, + pf.city, + pf.county, + pf.banetype, + pf.image_url, + pf.phone, + pf.website_url, + pf.golfbox_booking_url, + pf.golfbox_tournament_url, + pf.weather_url, + pf.lat, + pf.lng, + pf.golfamore, + pf.golfamore_url, + pf.nsg_url, {has_golfpakker_sql}, - f.vtg_pris, - f.vtg_lenke, - f.vtg_beskrivelse, - f.amenities, - f.golfamore_data, - f.nsg_data, - f.vtg_datoer, - f.footnote, - f.footnote_updated_at, - f.status_updated_at, - {course_statuses_sql}, - {weather_compact_sql} - FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE - ORDER BY f.name ASC + pf.vtg_pris, + pf.vtg_lenke, + pf.vtg_beskrivelse, + pf.amenities, + pf.golfamore_data, + pf.nsg_data, + pf.vtg_datoer, + pf.footnote, + pf.footnote_updated_at, + pf.status_updated_at, + COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, + COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast + FROM published_facilities pf + LEFT JOIN course_statuses cs ON cs.facility_id = pf.id + LEFT JOIN weather_compact wc ON wc.facility_id = pf.id + ORDER BY pf.name ASC """, FACILITY_VIEW_SEARCH_FIELDS, ) @@ -3040,48 +3081,57 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non if normalized_view == "place": return ( f""" + WITH + {published_facilities_cte}, + {course_statuses_cte}, + {weather_compact_cte}, + {hole_counts_cte}, + {hole_lengths_cte} SELECT - f.id, - f.slug, - f.name, - f.architect, - f.description, - f.city, - f.county, - f.banetype, - f.image_url, - f.phone, - f.website_url, - f.golfbox_booking_url, - f.golfbox_tournament_url, - f.weather_url, - f.lat, - f.lng, - f.golfamore, - f.golfamore_url, - f.nsg_url, + pf.id, + pf.slug, + pf.name, + pf.architect, + pf.description, + pf.city, + pf.county, + pf.banetype, + pf.image_url, + pf.phone, + pf.website_url, + pf.golfbox_booking_url, + pf.golfbox_tournament_url, + pf.weather_url, + pf.lat, + pf.lng, + pf.golfamore, + pf.golfamore_url, + pf.nsg_url, {has_golfpakker_sql}, - f.greenfee, - f.standard_medlemskap, - f.vtg_pris, - f.vtg_lenke, - f.vtg_beskrivelse, - f.amenities, - f.golfamore_data, - f.nsg_data, - f.vtg_datoer, - f.footnote, - f.footnote_updated_at, - f.status_updated_at, - {course_statuses_sql}, - {total_hole_count_sql}, - {hole_par_counts_sql}, - {shortest_hole_sql}, - {longest_hole_sql}, - {weather_compact_sql} - FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE - ORDER BY f.name ASC + pf.greenfee, + pf.standard_medlemskap, + pf.vtg_pris, + pf.vtg_lenke, + pf.vtg_beskrivelse, + pf.amenities, + pf.golfamore_data, + pf.nsg_data, + pf.vtg_datoer, + pf.footnote, + pf.footnote_updated_at, + pf.status_updated_at, + COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, + COALESCE(hc.total_hole_count, 0) AS total_hole_count, + COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts, + hl.shortest_hole_meters, + hl.longest_hole_meters, + COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast + FROM published_facilities pf + LEFT JOIN course_statuses cs ON cs.facility_id = pf.id + LEFT JOIN weather_compact wc ON wc.facility_id = pf.id + LEFT JOIN hole_counts hc ON hc.facility_id = pf.id + LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id + ORDER BY pf.name ASC """, FACILITY_VIEW_PLACE_FIELDS, ) @@ -3103,7 +3153,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non f.navn_rimeligste_alternativ, f.rimeligste_alternativ FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE + WHERE f.is_published IS DISTINCT FROM FALSE ORDER BY f.name ASC """, FACILITY_VIEW_MEMBERSHIP_FIELDS, @@ -3127,7 +3177,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non f.vtg_datoer, f.vtg_updated_at FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE + WHERE f.is_published IS DISTINCT FROM FALSE ORDER BY f.name ASC """, FACILITY_VIEW_VTG_FIELDS, @@ -3144,7 +3194,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non f.county, f.ngf_number FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE + WHERE f.is_published IS DISTINCT FROM FALSE ORDER BY f.name ASC """, FACILITY_VIEW_CLUBNUMBERS_FIELDS, @@ -3158,7 +3208,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non f.status_updated_at, f.vtg_updated_at FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE + WHERE f.is_published IS DISTINCT FROM FALSE ORDER BY f.name ASC """, FACILITY_VIEW_SITEMAP_FIELDS, @@ -3171,7 +3221,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non f.slug, f.name FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE + WHERE f.is_published IS DISTINCT FROM FALSE ORDER BY f.name ASC """, FACILITY_VIEW_ALIASES_FIELDS, @@ -3179,17 +3229,26 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non return ( f""" + WITH + {published_facilities_cte}, + {course_statuses_cte}, + {weather_full_cte}, + {hole_counts_cte}, + {hole_lengths_cte} SELECT - f.*, - {course_statuses_sql}, - {total_hole_count_sql}, - {hole_par_counts_sql}, - {shortest_hole_sql}, - {longest_hole_sql}, - {weather_full_sql} - FROM facilities f - WHERE COALESCE(f.is_published, TRUE) = TRUE - ORDER BY f.name ASC + pf.*, + COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, + COALESCE(hc.total_hole_count, 0) AS total_hole_count, + COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts, + hl.shortest_hole_meters, + hl.longest_hole_meters, + COALESCE(wf.weather_forecast, '[]'::jsonb) AS weather_forecast + FROM published_facilities pf + LEFT JOIN course_statuses cs ON cs.facility_id = pf.id + LEFT JOIN hole_counts hc ON hc.facility_id = pf.id + LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id + LEFT JOIN weather_full wf ON wf.facility_id = pf.id + ORDER BY pf.name ASC """, None, ) @@ -3266,7 +3325,7 @@ async def get_facility(slug: str, response: Response): ) as weather_forecast FROM facilities f WHERE f.slug = $1 - AND COALESCE(f.is_published, TRUE) = TRUE + AND f.is_published IS DISTINCT FROM FALSE """, slug) if not row: diff --git a/frontend/src/app/facilityAliases.ts b/frontend/src/app/facilityAliases.ts index 5acca04..f5b4721 100644 --- a/frontend/src/app/facilityAliases.ts +++ b/frontend/src/app/facilityAliases.ts @@ -1,5 +1,6 @@ import { API_URL } from "@/config/constants"; import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData"; +import { cache } from "react"; type FacilityAliasSource = { slug?: string | null; @@ -114,9 +115,9 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) { return Object.fromEntries(aliases); } -async function getFacilityAliasMap() { +const getFacilityAliasMap = cache(async () => { const response = await fetch(`${API_URL}/facilities?view=aliases`, { - next: { revalidate: 3600 }, + cache: "no-store", }); if (!response.ok) { @@ -126,7 +127,7 @@ async function getFacilityAliasMap() { const data = await response.json(); const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : []; return buildFacilityAliasMap(facilities); -} +}); export async function resolveFacilityAlias(alias: string) { const normalizedAlias = slugify(alias); diff --git a/frontend/src/app/golfbaner/[slug]/page.tsx b/frontend/src/app/golfbaner/[slug]/page.tsx index f3b73a3..6eca09e 100755 --- a/frontend/src/app/golfbaner/[slug]/page.tsx +++ b/frontend/src/app/golfbaner/[slug]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; import { notFound, permanentRedirect } from "next/navigation"; +import { cache } from "react"; import { API_URL } from "@/config/constants"; import { resolveFacilityAlias } from "@/app/facilityAliases"; import type { FacilityRecord } from "@/app/facilityData"; @@ -23,7 +24,7 @@ type FacilityPageData = FacilityRecord & { [key: string]: unknown; }; -async function getFacility(slug: string) { +const getFacility = cache(async (slug: string) => { const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: "no-store" }); if (!res.ok) { return null; @@ -35,7 +36,7 @@ async function getFacility(slug: string) { } return facility as FacilityPageData; -} +}); async function getCanonicalSlug(slug: string) { const canonicalSlug = await resolveFacilityAlias(slug); diff --git a/frontend/src/app/publicFacilities.ts b/frontend/src/app/publicFacilities.ts index f73cf7c..f0e8f7d 100644 --- a/frontend/src/app/publicFacilities.ts +++ b/frontend/src/app/publicFacilities.ts @@ -4,7 +4,7 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export async function fetchPublicFacilities( view: string, - revalidateSeconds: number, + _revalidateSeconds: number, { allowEmpty = false, attempts = 3, @@ -18,7 +18,7 @@ export async function fetchPublicFacilities( for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const res = await fetch(`${API_URL}/facilities?view=${view}`, { - next: { revalidate: revalidateSeconds }, + cache: "no-store", }); if (!res.ok) { diff --git a/frontend/src/app/sted/[slug]/page.tsx b/frontend/src/app/sted/[slug]/page.tsx index 3c381f4..1697df7 100755 --- a/frontend/src/app/sted/[slug]/page.tsx +++ b/frontend/src/app/sted/[slug]/page.tsx @@ -131,7 +131,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st try { const res = await fetch(`${API_URL}/place-pages/${slug}`, { - next: { revalidate }, + cache: "no-store", }); if (!res.ok) {