Files
thrilltrack-explorer/supabase/functions/sitemap/index.ts
gpt-engineer-app[bot] 2d65f13b85 Connect to Lovable Cloud
Add centralized errorFormatter to convert various error types into readable messages, and apply it across edge functions. Replace String(error) usage with formatEdgeError, update relevant imports, fix a throw to use toError, and enhance logger to log formatted errors. Includes new errorFormatter.ts and widespread updates to 18+ edge functions plus logger integration.
2025-11-10 18:09:15 +00:00

369 lines
11 KiB
TypeScript

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
import { edgeLogger } from '../_shared/logger.ts';
import { formatEdgeError } from '../_shared/errorFormatter.ts';
const BASE_URL = 'https://dev.thrillwiki.com';
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
interface SitemapUrl {
loc: string;
lastmod: string;
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority: number;
}
interface SitemapStats {
total_urls: number;
parks: number;
rides: number;
manufacturers: number;
models: number;
designers: number;
operators: number;
owners: number;
static_pages: number;
generation_time_ms: number;
}
// ============================================================================
// IN-MEMORY CACHE
// ============================================================================
let cachedSitemap: string | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const cacheHeaders = {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
'CDN-Cache-Control': 'public, s-maxage=3600',
'Vercel-CDN-Cache-Control': 'public, s-maxage=3600',
};
function isCacheValid(): boolean {
if (!cachedSitemap) return false;
const age = Date.now() - cacheTimestamp;
return age < CACHE_TTL_MS;
}
// ============================================================================
// XML GENERATION UTILITIES
// ============================================================================
function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function calculatePriority(entityType: string, metadata?: { ride_count?: number }): number {
switch (entityType) {
case 'home': return 1.0;
case 'park':
const rideCount = metadata?.ride_count || 0;
return rideCount > 10 ? 0.9 : 0.8;
case 'ride': return 0.9;
case 'manufacturer': return 0.7;
case 'model': return 0.6;
case 'designer': return 0.6;
case 'operator': return 0.6;
case 'owner': return 0.6;
case 'list': return 0.5;
case 'static': return 0.3;
default: return 0.5;
}
}
function getChangeFreq(entityType: string): SitemapUrl['changefreq'] {
switch (entityType) {
case 'home': return 'daily';
case 'park':
case 'ride':
case 'manufacturer': return 'weekly';
case 'model':
case 'designer':
case 'operator':
case 'owner': return 'monthly';
case 'list': return 'daily';
case 'static': return 'yearly';
default: return 'monthly';
}
}
function createUrl(
path: string,
entityType: string,
lastmod: string,
metadata?: { ride_count?: number }
): SitemapUrl {
return {
loc: `${BASE_URL}${path}`,
lastmod: new Date(lastmod).toISOString().split('T')[0],
changefreq: getChangeFreq(entityType),
priority: calculatePriority(entityType, metadata),
};
}
function generateSitemapXml(urls: SitemapUrl[]): string {
const header = '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
const entries = urls.map(url =>
` <url>\n` +
` <loc>${escapeXml(url.loc)}</loc>\n` +
` <lastmod>${url.lastmod}</lastmod>\n` +
` <changefreq>${url.changefreq}</changefreq>\n` +
` <priority>${url.priority.toFixed(1)}</priority>\n` +
` </url>`
).join('\n');
const footer = '\n</urlset>';
return header + entries + footer;
}
// ============================================================================
// SITEMAP GENERATION
// ============================================================================
async function generateSitemap(requestId: string): Promise<{
xml: string;
stats: SitemapStats;
}> {
const startTime = Date.now();
const urls: SitemapUrl[] = [];
const stats: SitemapStats = {
total_urls: 0,
parks: 0,
rides: 0,
manufacturers: 0,
models: 0,
designers: 0,
operators: 0,
owners: 0,
static_pages: 0,
generation_time_ms: 0,
};
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
);
// Static pages
const now = new Date().toISOString();
urls.push(
createUrl('/', 'home', now),
createUrl('/parks', 'list', now),
createUrl('/rides', 'list', now),
createUrl('/manufacturers', 'list', now),
createUrl('/designers', 'list', now),
createUrl('/operators', 'list', now),
createUrl('/owners', 'list', now),
createUrl('/blog', 'list', now),
createUrl('/terms', 'static', now),
createUrl('/privacy', 'static', now),
createUrl('/submission-guidelines', 'static', now),
createUrl('/contact', 'static', now),
);
stats.static_pages = 12;
// Parallel database queries
const [parksResult, ridesResult, companiesResult, modelsResult] = await Promise.all([
supabase
.from('parks')
.select('slug, updated_at')
.not('slug', 'is', null)
.order('updated_at', { ascending: false }),
supabase
.from('rides')
.select('slug, updated_at, park:parks!inner(slug)')
.not('slug', 'is', null)
.order('updated_at', { ascending: false }),
supabase
.from('companies')
.select('slug, updated_at, company_type')
.not('slug', 'is', null)
.order('updated_at', { ascending: false }),
supabase
.from('ride_models')
.select('slug, updated_at, manufacturer:companies!ride_models_manufacturer_id_fkey(slug)')
.not('slug', 'is', null)
.order('updated_at', { ascending: false }),
]);
// Process parks
if (parksResult.data) {
for (const park of parksResult.data) {
urls.push(createUrl(`/parks/${park.slug}`, 'park', park.updated_at));
urls.push(createUrl(`/parks/${park.slug}/rides`, 'list', park.updated_at));
stats.parks++;
}
}
// Process rides
if (ridesResult.data) {
for (const ride of ridesResult.data) {
if (ride.park?.slug) {
urls.push(createUrl(`/parks/${ride.park.slug}/rides/${ride.slug}`, 'ride', ride.updated_at));
stats.rides++;
}
}
}
// Process companies
if (companiesResult.data) {
for (const company of companiesResult.data) {
const type = company.company_type;
if (type === 'manufacturer') {
urls.push(createUrl(`/manufacturers/${company.slug}`, 'manufacturer', company.updated_at));
urls.push(createUrl(`/manufacturers/${company.slug}/rides`, 'list', company.updated_at));
urls.push(createUrl(`/manufacturers/${company.slug}/models`, 'list', company.updated_at));
stats.manufacturers++;
} else if (type === 'designer') {
urls.push(createUrl(`/designers/${company.slug}`, 'designer', company.updated_at));
urls.push(createUrl(`/designers/${company.slug}/rides`, 'list', company.updated_at));
stats.designers++;
} else if (type === 'operator') {
urls.push(createUrl(`/operators/${company.slug}`, 'operator', company.updated_at));
urls.push(createUrl(`/operators/${company.slug}/parks`, 'list', company.updated_at));
stats.operators++;
} else if (type === 'property_owner') {
urls.push(createUrl(`/owners/${company.slug}`, 'owner', company.updated_at));
urls.push(createUrl(`/owners/${company.slug}/parks`, 'list', company.updated_at));
stats.owners++;
}
}
}
// Process ride models
if (modelsResult.data) {
for (const model of modelsResult.data) {
if (model.manufacturer?.slug) {
urls.push(
createUrl(`/manufacturers/${model.manufacturer.slug}/models/${model.slug}`, 'model', model.updated_at)
);
urls.push(
createUrl(`/manufacturers/${model.manufacturer.slug}/models/${model.slug}/rides`, 'list', model.updated_at)
);
stats.models++;
}
}
}
stats.total_urls = urls.length;
stats.generation_time_ms = Date.now() - startTime;
const xml = generateSitemapXml(urls);
edgeLogger.info('Sitemap generated', {
requestId,
stats,
sizeKB: (xml.length / 1024).toFixed(2),
});
return { xml, stats };
}
function generateFallbackSitemap(): string {
const now = new Date().toISOString();
const urls: SitemapUrl[] = [
createUrl('/', 'home', now),
createUrl('/parks', 'list', now),
createUrl('/rides', 'list', now),
createUrl('/manufacturers', 'list', now),
createUrl('/designers', 'list', now),
createUrl('/operators', 'list', now),
createUrl('/owners', 'list', now),
];
return generateSitemapXml(urls);
}
// ============================================================================
// MAIN HANDLER
// ============================================================================
Deno.serve(async (req) => {
const requestId = crypto.randomUUID();
const startTime = Date.now();
try {
// Return cached version if valid
if (isCacheValid()) {
const duration = Date.now() - startTime;
edgeLogger.info('Sitemap cache hit', {
requestId,
cacheAge: Date.now() - cacheTimestamp,
duration,
});
return new Response(cachedSitemap, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'X-Request-ID': requestId,
'X-Cache': 'HIT',
'X-Generation-Time': `${duration}ms`,
...cacheHeaders,
},
});
}
// Generate fresh sitemap
const sitemap = await generateSitemap(requestId);
// Update cache
cachedSitemap = sitemap.xml;
cacheTimestamp = Date.now();
const duration = Date.now() - startTime;
edgeLogger.info('Sitemap cache miss - generated', {
requestId,
duration,
stats: sitemap.stats,
});
return new Response(sitemap.xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'X-Request-ID': requestId,
'X-Cache': 'MISS',
'X-Generation-Time': `${duration}ms`,
...cacheHeaders,
},
});
} catch (error) {
const duration = Date.now() - startTime;
edgeLogger.error('Sitemap generation failed', {
requestId,
error: formatEdgeError(error),
duration,
});
// Return minimal valid sitemap on error (graceful degradation)
const fallbackSitemap = generateFallbackSitemap();
return new Response(fallbackSitemap, {
status: 200, // Still return 200 for SEO
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'X-Request-ID': requestId,
'X-Error': 'true',
'Cache-Control': 'public, max-age=300', // Cache errors for 5min only
},
});
}
});