mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:11:17 -05:00
Extend createEdgeFunction usage to novu-webhook, seed-test-data, and sitemap by removing manual boilerplate (CORS, auth, tracking, error handling) and replacing logging with span-based tracing; wire in EdgeFunctionContext for supabase, user, span, and requestId; preserve core logic including webhook validation, data seeding utilities, and sitemap caching.
346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
|
import { addSpanEvent } 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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, span: any): 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 supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
|
|
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
|
|
|
|
// Dynamic import to avoid circular dependency issues
|
|
const { createClient } = await import('https://esm.sh/@supabase/supabase-js@2.57.4');
|
|
const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
|
|
// 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);
|
|
|
|
addSpanEvent(span, 'sitemap_generated', {
|
|
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
|
|
// ============================================================================
|
|
|
|
serve(createEdgeFunction({
|
|
name: 'sitemap',
|
|
requireAuth: false, // Public endpoint
|
|
corsHeaders: {},
|
|
}, async (req, { span, requestId }: EdgeFunctionContext) => {
|
|
const startTime = Date.now();
|
|
|
|
// Return cached version if valid
|
|
if (isCacheValid()) {
|
|
const duration = Date.now() - startTime;
|
|
addSpanEvent(span, 'sitemap_cache_hit', {
|
|
cacheAge: Date.now() - cacheTimestamp,
|
|
duration,
|
|
});
|
|
|
|
return new Response(cachedSitemap, {
|
|
headers: {
|
|
'Content-Type': 'application/xml; charset=utf-8',
|
|
'X-Cache': 'HIT',
|
|
'X-Generation-Time': `${duration}ms`,
|
|
...cacheHeaders,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Generate fresh sitemap
|
|
const sitemap = await generateSitemap(requestId, span);
|
|
|
|
// Update cache
|
|
cachedSitemap = sitemap.xml;
|
|
cacheTimestamp = Date.now();
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
addSpanEvent(span, 'sitemap_cache_miss', {
|
|
duration,
|
|
stats: sitemap.stats,
|
|
});
|
|
|
|
return new Response(sitemap.xml, {
|
|
headers: {
|
|
'Content-Type': 'application/xml; charset=utf-8',
|
|
'X-Cache': 'MISS',
|
|
'X-Generation-Time': `${duration}ms`,
|
|
...cacheHeaders,
|
|
},
|
|
});
|
|
}));
|