diff --git a/supabase/functions/novu-webhook/index.ts b/supabase/functions/novu-webhook/index.ts index e4390837..9b3c6dc4 100644 --- a/supabase/functions/novu-webhook/index.ts +++ b/supabase/functions/novu-webhook/index.ts @@ -1,111 +1,72 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; -// Simple request tracking -const startRequest = () => ({ requestId: crypto.randomUUID(), start: Date.now() }); -const endRequest = (tracking: { start: number }) => Date.now() - tracking.start; +serve(createEdgeFunction({ + name: 'novu-webhook', + requireAuth: false, // Webhooks don't use standard auth + useServiceRole: true, // Need service role to update notification_logs + corsHeaders, +}, async (req, { span, supabase, requestId }: EdgeFunctionContext) => { + const event = await req.json(); -serve(async (req) => { - const tracking = startRequest(); - - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); + addSpanEvent(span, 'received_webhook_event', { + eventType: event.type + }); + + // Handle different webhook events + switch (event.type) { + case 'notification.sent': + await handleNotificationSent(supabase, event, span); + break; + case 'notification.delivered': + await handleNotificationDelivered(supabase, event, span); + break; + case 'notification.read': + await handleNotificationRead(supabase, event, span); + break; + case 'notification.failed': + await handleNotificationFailed(supabase, event, span); + break; + default: + addSpanEvent(span, 'unhandled_event_type', { + eventType: event.type + }); } - try { - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - const event = await req.json(); - - edgeLogger.info('Received Novu webhook event', { - action: 'novu_webhook', - eventType: event.type, - requestId: tracking.requestId - }); - - // Handle different webhook events - switch (event.type) { - case 'notification.sent': - await handleNotificationSent(supabase, event); - break; - case 'notification.delivered': - await handleNotificationDelivered(supabase, event); - break; - case 'notification.read': - await handleNotificationRead(supabase, event); - break; - case 'notification.failed': - await handleNotificationFailed(supabase, event); - break; - default: - edgeLogger.warn('Unhandled Novu event type', { - action: 'novu_webhook', - eventType: event.type, - requestId: tracking.requestId - }); + return new Response( + JSON.stringify({ success: true, requestId }), + { + headers: { + 'Content-Type': 'application/json' + }, + status: 200, } + ); +})); - const duration = endRequest(tracking); - - return new Response( - JSON.stringify({ success: true, requestId: tracking.requestId }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 200, - } - ); - } catch (error: any) { - const duration = endRequest(tracking); - edgeLogger.error('Error processing webhook', { - action: 'novu_webhook', - error: error?.message, - requestId: tracking.requestId, - duration - }); - - // Persist error to database for monitoring - const errorSpan = startSpan('novu-webhook-error', 'SERVER'); - endSpan(errorSpan, 'error', error); - logSpanToDatabase(errorSpan, tracking.requestId); - - return new Response( - JSON.stringify({ - success: false, - error: error.message, - requestId: tracking.requestId - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 500, - } - ); - } -}); - -async function handleNotificationSent(supabase: any, event: any) { +async function handleNotificationSent(supabase: any, event: any, span: any) { const { transactionId, channel } = event.data; + addSpanEvent(span, 'notification_sent_update', { + transactionId, + channel + }); + await supabase .from('notification_logs') .update({ status: 'sent' }) .eq('novu_transaction_id', transactionId); } -async function handleNotificationDelivered(supabase: any, event: any) { +async function handleNotificationDelivered(supabase: any, event: any, span: any) { const { transactionId } = event.data; + addSpanEvent(span, 'notification_delivered_update', { + transactionId + }); + await supabase .from('notification_logs') .update({ @@ -115,9 +76,13 @@ async function handleNotificationDelivered(supabase: any, event: any) { .eq('novu_transaction_id', transactionId); } -async function handleNotificationRead(supabase: any, event: any) { +async function handleNotificationRead(supabase: any, event: any, span: any) { const { transactionId } = event.data; + addSpanEvent(span, 'notification_read_update', { + transactionId + }); + await supabase .from('notification_logs') .update({ @@ -126,9 +91,14 @@ async function handleNotificationRead(supabase: any, event: any) { .eq('novu_transaction_id', transactionId); } -async function handleNotificationFailed(supabase: any, event: any) { +async function handleNotificationFailed(supabase: any, event: any, span: any) { const { transactionId, error } = event.data; + addSpanEvent(span, 'notification_failed_update', { + transactionId, + error + }); + await supabase .from('notification_logs') .update({ diff --git a/supabase/functions/seed-test-data/index.ts b/supabase/functions/seed-test-data/index.ts index f9bfd4f8..6c20f335 100644 --- a/supabase/functions/seed-test-data/index.ts +++ b/supabase/functions/seed-test-data/index.ts @@ -1,6 +1,7 @@ -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; interface SeedOptions { preset: 'small' | 'medium' | 'large' | 'stress'; @@ -181,7 +182,8 @@ async function registerTestEntity( slug: string, entityId: string, submissionItemId: string, - sessionId: string + sessionId: string, + span: any ) { const { error } = await supabase.from('test_data_registry').insert({ entity_type: entityType, @@ -192,14 +194,19 @@ async function registerTestEntity( }); if (error && error.code !== '23505') { // Ignore unique constraint violations - edgeLogger.error(`Error registering ${entityType} ${slug}`, { error: error.message, entityType, slug }); + addSpanEvent(span, 'registry_error', { + error: error.message, + entityType, + slug + }); } } // Validate that submission item IDs still exist in the database async function validateSubmissionItemIds( supabase: any, - itemIds: string[] + itemIds: string[], + span: any ): Promise { if (itemIds.length === 0) return []; @@ -209,7 +216,7 @@ async function validateSubmissionItemIds( .in('id', itemIds); if (error) { - edgeLogger.error('Error validating submission item IDs', { error: error.message }); + addSpanEvent(span, 'validation_error', { error: error.message }); return []; } @@ -218,7 +225,8 @@ async function validateSubmissionItemIds( async function getExistingTestEntities( supabase: any, - entityType: string + entityType: string, + span: any ): Promise> { const { data, error } = await supabase .from('test_data_registry') @@ -226,7 +234,10 @@ async function getExistingTestEntities( .eq('entity_type', entityType); if (error) { - edgeLogger.error(`Error fetching existing ${entityType}`, { error: error.message, entityType }); + addSpanEvent(span, 'fetch_entities_error', { + error: error.message, + entityType + }); return []; } @@ -235,7 +246,8 @@ async function getExistingTestEntities( async function getPendingSubmissionItems( supabase: any, - itemType: string + itemType: string, + span: any ): Promise> { // Determine which FK column to select based on itemType let fkColumn: string; @@ -258,7 +270,10 @@ async function getPendingSubmissionItems( .eq('status', 'pending'); if (error) { - edgeLogger.error(`Error fetching pending ${itemType} items`, { error: error.message, itemType }); + addSpanEvent(span, 'fetch_pending_error', { + error: error.message, + itemType + }); return []; } @@ -273,7 +288,8 @@ async function getPendingSubmissionItems( async function getSubmissionSlug( supabase: any, itemType: string, - itemDataId: string + itemDataId: string, + span: any ): Promise { const tableMap: Record = { park: 'park_submissions', @@ -295,50 +311,22 @@ async function getSubmissionSlug( .maybeSingle(); if (error) { - edgeLogger.error(`Error fetching slug from ${table}`, { error: error.message, itemDataId }); + addSpanEvent(span, 'fetch_slug_error', { + error: error.message, + itemDataId + }); return null; } return data?.slug || null; } -Deno.serve(async (req) => { - const tracking = startRequest(); - - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - try { - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - return new Response(JSON.stringify({ error: 'No authorization header' }), { - status: 401, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - const token = authHeader.replace('Bearer ', ''); - const { data: { user }, error: userError } = await supabase.auth.getUser(token); - - if (userError || !user) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - const { data: isMod, error: modError } = await supabase.rpc('is_moderator', { _user_id: user.id }); - if (modError || !isMod) { - return new Response(JSON.stringify({ error: 'Insufficient permissions. Moderator role required.' }), { - status: 403, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } +serve(createEdgeFunction({ + name: 'seed-test-data', + requiredRoles: ['superuser', 'admin', 'moderator'], + useServiceRole: true, + corsHeaders, +}, async (req, { span, supabase, userId, requestId }: EdgeFunctionContext) => { const { preset = 'small', @@ -352,8 +340,7 @@ Deno.serve(async (req) => { stage }: SeedOptions = await req.json(); - edgeLogger.info('Seed data generation started', { - requestId: tracking.requestId, + addSpanEvent(span, 'seed_generation_started', { entityTypes, preset, fieldDensity, @@ -392,20 +379,20 @@ Deno.serve(async (req) => { // Load existing test entities from registry if (includeDependencies) { - edgeLogger.info('Loading existing test entities from registry', { requestId: tracking.requestId }); + addSpanEvent(span, 'loading_existing_entities'); - const existingOperators = await getExistingTestEntities(supabase, 'operator'); - const existingOwners = await getExistingTestEntities(supabase, 'property_owner'); - const existingManufacturers = await getExistingTestEntities(supabase, 'manufacturer'); - const existingDesigners = await getExistingTestEntities(supabase, 'designer'); - const existingParks = await getExistingTestEntities(supabase, 'park'); + const existingOperators = await getExistingTestEntities(supabase, 'operator', span); + const existingOwners = await getExistingTestEntities(supabase, 'property_owner', span); + const existingManufacturers = await getExistingTestEntities(supabase, 'manufacturer', span); + const existingDesigners = await getExistingTestEntities(supabase, 'designer', span); + const existingParks = await getExistingTestEntities(supabase, 'park', span); - const pendingOperators = await getPendingSubmissionItems(supabase, 'operator'); - const pendingOwners = await getPendingSubmissionItems(supabase, 'property_owner'); - const pendingManufacturers = await getPendingSubmissionItems(supabase, 'manufacturer'); - const pendingDesigners = await getPendingSubmissionItems(supabase, 'designer'); - const pendingParks = await getPendingSubmissionItems(supabase, 'park'); - const pendingRideModels = await getPendingSubmissionItems(supabase, 'ride_model'); + const pendingOperators = await getPendingSubmissionItems(supabase, 'operator', span); + const pendingOwners = await getPendingSubmissionItems(supabase, 'property_owner', span); + const pendingManufacturers = await getPendingSubmissionItems(supabase, 'manufacturer', span); + const pendingDesigners = await getPendingSubmissionItems(supabase, 'designer', span); + const pendingParks = await getPendingSubmissionItems(supabase, 'park', span); + const pendingRideModels = await getPendingSubmissionItems(supabase, 'ride_model', span); // Track approved entities existingOperators.forEach(op => { @@ -522,8 +509,7 @@ Deno.serve(async (req) => { } } - edgeLogger.info('Loaded existing entities', { - requestId: tracking.requestId, + addSpanEvent(span, 'loaded_existing_entities', { operators: existingOperators.length, owners: existingOwners.length, manufacturers: existingManufacturers.length, @@ -564,7 +550,10 @@ Deno.serve(async (req) => { const { error: subError } = await supabase.from('content_submissions').insert(submissionData); if (subError) { - edgeLogger.error('Error inserting content_submission', { type, error: subError.message }); + addSpanEvent(span, 'submission_insert_error', { + type, + error: subError.message + }); throw subError; } @@ -595,7 +584,11 @@ Deno.serve(async (req) => { .single(); if (typeError) { - edgeLogger.error('Error inserting into type table', { table, type, error: typeError.message }); + addSpanEvent(span, 'type_table_insert_error', { + table, + type, + error: typeError.message + }); throw typeError; } @@ -628,7 +621,10 @@ Deno.serve(async (req) => { const { error: itemError } = await supabase.from('submission_items').insert(submissionItemData); if (itemError) { - edgeLogger.error('Error inserting submission_item', { type, error: itemError.message }); + addSpanEvent(span, 'submission_item_insert_error', { + type, + error: itemError.message + }); throw itemError; } @@ -671,8 +667,7 @@ Deno.serve(async (req) => { entityTypes.includes(pluralizeCompanyType(compType)) ); - edgeLogger.info('Company generation started', { - requestId: tracking.requestId, + addSpanEvent(span, 'company_generation_started', { entityTypes, planCompanies: plan.companies, selectedCompanyTypes @@ -685,7 +680,7 @@ Deno.serve(async (req) => { const compType = selectedCompanyTypes[typeIndex]; const count = basePerType + (typeIndex < extras ? 1 : 0); - edgeLogger.info('Creating companies', { requestId: tracking.requestId, compType, count }); + addSpanEvent(span, 'creating_companies', { compType, count }); for (let i = 0; i < count; i++) { const level = getPopulationLevel(fieldDensity, i); @@ -758,7 +753,7 @@ Deno.serve(async (req) => { // Create parks if ((!stage || stage === 'parks') && entityTypes.includes('parks')) { - edgeLogger.info('Creating parks', { requestId: tracking.requestId, count: plan.parks }); + addSpanEvent(span, 'creating_parks', { count: plan.parks }); for (let i = 0; i < plan.parks; i++) { const level = getPopulationLevel(fieldDensity, i); @@ -870,7 +865,7 @@ Deno.serve(async (req) => { // Create rides if ((!stage || stage === 'rides') && entityTypes.includes('rides') && includeDependencies && createdParks.length > 0) { - edgeLogger.info('Creating rides', { requestId: tracking.requestId, count: plan.rides }); + addSpanEvent(span, 'creating_rides', { count: plan.rides }); for (let i = 0; i < plan.rides; i++) { const level = getPopulationLevel(fieldDensity, i); @@ -1045,15 +1040,14 @@ Deno.serve(async (req) => { // Create ride models if ((!stage || stage === 'rides') && entityTypes.includes('ride_models') && includeDependencies && createdSubmissionItems.manufacturer.length > 0) { - edgeLogger.info('Creating ride models', { requestId: tracking.requestId, count: plan.rideModels }); + addSpanEvent(span, 'creating_ride_models', { count: plan.rideModels }); for (let i = 0; i < plan.rideModels; i++) { const level = getPopulationLevel(fieldDensity, i); // Ensure we have valid manufacturer submission items if (createdSubmissionItems.manufacturer.length === 0) { - edgeLogger.error('No valid manufacturers available for ride model', { - requestId: tracking.requestId, + addSpanEvent(span, 'no_manufacturers_available', { modelIndex: i }); continue; // Skip this ride model @@ -1123,7 +1117,7 @@ Deno.serve(async (req) => { // Create photo submissions if ((!stage || stage === 'photos') && entityTypes.includes('photos') && plan.photos > 0) { - edgeLogger.info('Creating photo submissions', { requestId: tracking.requestId, count: plan.photos }); + addSpanEvent(span, 'creating_photos', { count: plan.photos }); const { data: approvedParks } = await supabase.from('parks').select('id').limit(Math.min(20, plan.photos)); const { data: approvedRides } = await supabase.from('rides').select('id, park_id').limit(Math.min(20, plan.photos)); @@ -1199,8 +1193,7 @@ Deno.serve(async (req) => { const executionTime = Date.now() - startTime; - edgeLogger.info('Seed data generation completed', { - requestId: tracking.requestId, + addSpanEvent(span, 'seed_generation_completed', { duration: executionTime, summary, stage: stage || 'all' @@ -1212,21 +1205,8 @@ Deno.serve(async (req) => { summary, time: (executionTime / 1000).toFixed(2), stage: stage || 'all', - requestId: tracking.requestId + requestId }), - { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + { headers: { 'Content-Type': 'application/json' } } ); - - } catch (error) { - const duration = endRequest(tracking); - edgeLogger.error('Seed error', { - requestId: tracking.requestId, - duration, - error: error.message - }); - return new Response( - JSON.stringify({ error: error.message, requestId: tracking.requestId }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } -}); +})); diff --git a/supabase/functions/sitemap/index.ts b/supabase/functions/sitemap/index.ts index 68bbb6f4..fa0855e7 100644 --- a/supabase/functions/sitemap/index.ts +++ b/supabase/functions/sitemap/index.ts @@ -1,5 +1,6 @@ -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; -import { edgeLogger } from '../_shared/logger.ts'; +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'; @@ -131,7 +132,7 @@ function generateSitemapXml(urls: SitemapUrl[]): string { // SITEMAP GENERATION // ============================================================================ -async function generateSitemap(requestId: string): Promise<{ +async function generateSitemap(requestId: string, span: any): Promise<{ xml: string; stats: SitemapStats; }> { @@ -150,10 +151,12 @@ async function generateSitemap(requestId: string): Promise<{ generation_time_ms: 0, }; - const supabase = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_ANON_KEY') ?? '' - ); + 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(); @@ -265,8 +268,7 @@ async function generateSitemap(requestId: string): Promise<{ const xml = generateSitemapXml(urls); - edgeLogger.info('Sitemap generated', { - requestId, + addSpanEvent(span, 'sitemap_generated', { stats, sizeKB: (xml.length / 1024).toFixed(2), }); @@ -293,76 +295,51 @@ function generateFallbackSitemap(): string { // MAIN HANDLER // ============================================================================ -Deno.serve(async (req) => { - const requestId = crypto.randomUUID(); +serve(createEdgeFunction({ + name: 'sitemap', + requireAuth: false, // Public endpoint + corsHeaders: {}, +}, async (req, { span, requestId }: EdgeFunctionContext) => { 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(); - + // Return cached version if valid + if (isCacheValid()) { const duration = Date.now() - startTime; - - edgeLogger.info('Sitemap cache miss - generated', { - requestId, + addSpanEvent(span, 'sitemap_cache_hit', { + cacheAge: Date.now() - cacheTimestamp, duration, - stats: sitemap.stats, }); - return new Response(sitemap.xml, { + return new Response(cachedSitemap, { headers: { 'Content-Type': 'application/xml; charset=utf-8', - 'X-Request-ID': requestId, - 'X-Cache': 'MISS', + 'X-Cache': 'HIT', '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 - }, - }); } -}); + + // 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, + }, + }); +}));