diff --git a/supabase/functions/export-user-data/index.ts b/supabase/functions/export-user-data/index.ts index 2b244476..ca43dd95 100644 --- a/supabase/functions/export-user-data/index.ts +++ b/supabase/functions/export-user-data/index.ts @@ -1,10 +1,7 @@ -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 { serve } from 'https://deno.land/std@0.190.0/http/server.ts'; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { corsHeaders } from '../_shared/cors.ts'; -import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'; -import { sanitizeError } from '../_shared/errorSanitizer.ts'; -import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts'; -import { formatEdgeError } from '../_shared/errorFormatter.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; interface ExportOptions { include_reviews: boolean; @@ -14,367 +11,249 @@ interface ExportOptions { format: 'json'; } -// Apply strict rate limiting (5 req/min) for expensive data export operations -// This prevents abuse and manages server load from large data exports -serve(withRateLimit(async (req) => { - const tracking = startRequest(); - - // Handle CORS preflight requests - if (req.method === 'OPTIONS') { - return new Response(null, { - headers: { - ...corsHeaders, - 'X-Request-ID': tracking.requestId - } - }); +const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => { + addSpanEvent(span, 'processing_export_request', { userId: user.id }); + + // Additional rate limiting - max 1 export per hour + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const { data: recentExports, error: rateLimitError } = await supabase + .from('profile_audit_log') + .select('created_at') + .eq('user_id', user.id) + .eq('action', 'data_exported') + .gte('created_at', oneHourAgo) + .limit(1); + + if (rateLimitError) { + addSpanEvent(span, 'rate_limit_check_failed', { error: rateLimitError.message }); } - try { - const supabaseClient = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_ANON_KEY') ?? '', - { - global: { - headers: { Authorization: req.headers.get('Authorization')! }, - }, - } + if (recentExports && recentExports.length > 0) { + const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString(); + addSpanEvent(span, 'rate_limit_exceeded', { nextAvailableAt }); + + return new Response( + JSON.stringify({ + success: false, + error: 'Rate limited. You can export your data once per hour.', + rate_limited: true, + next_available_at: nextAvailableAt, + }), + { status: 429 } ); + } - // Get authenticated user - const { - data: { user }, - error: authError, - } = await supabaseClient.auth.getUser(); + // Parse export options + const body = await req.json(); + const options: ExportOptions = { + include_reviews: body.include_reviews ?? true, + include_lists: body.include_lists ?? true, + include_activity_log: body.include_activity_log ?? true, + include_preferences: body.include_preferences ?? true, + format: 'json' + }; - if (authError || !user) { - const duration = endRequest(tracking); - edgeLogger.error('Authentication failed', { - action: 'export_auth', - requestId: tracking.requestId, - duration - }); - - // Persist error to database - const authErrorSpan = startSpan('export-user-data-auth-error', 'SERVER'); - endSpan(authErrorSpan, 'error', authError); - logSpanToDatabase(authErrorSpan, tracking.requestId); - return new Response( - JSON.stringify({ - error: 'Unauthorized', - success: false, - requestId: tracking.requestId - }), - { - status: 401, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } - } - ); - } + addSpanEvent(span, 'export_options_parsed', { options }); - edgeLogger.info('Processing export request', { - action: 'export_start', - requestId: tracking.requestId, - userId: user.id - }); + // Fetch profile data + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('username, display_name, bio, preferred_pronouns, personal_location, timezone, preferred_language, theme_preference, privacy_level, ride_count, coaster_count, park_count, review_count, reputation_score, created_at, updated_at') + .eq('user_id', user.id) + .single(); - // Check rate limiting - max 1 export per hour - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - const { data: recentExports, error: rateLimitError } = await supabaseClient - .from('profile_audit_log') - .select('created_at') + if (profileError) { + addSpanEvent(span, 'profile_fetch_failed', { error: profileError.message }); + throw new Error('Failed to fetch profile data'); + } + + // Fetch statistics + const { count: photoCount } = await supabase + .from('photos') + .select('*', { count: 'exact', head: true }) + .eq('submitted_by', user.id); + + const { count: listCount } = await supabase + .from('user_lists') + .select('*', { count: 'exact', head: true }) + .eq('user_id', user.id); + + const { count: submissionCount } = await supabase + .from('content_submissions') + .select('*', { count: 'exact', head: true }) + .eq('user_id', user.id); + + const statistics = { + ride_count: profile.ride_count || 0, + coaster_count: profile.coaster_count || 0, + park_count: profile.park_count || 0, + review_count: profile.review_count || 0, + reputation_score: profile.reputation_score || 0, + photo_count: photoCount || 0, + list_count: listCount || 0, + submission_count: submissionCount || 0, + account_created: profile.created_at, + last_updated: profile.updated_at + }; + + // Fetch reviews if requested + let reviews = []; + if (options.include_reviews) { + const { data: reviewsData, error: reviewsError } = await supabase + .from('reviews') + .select(` + id, + rating, + review_text, + created_at, + rides(name), + parks(name) + `) .eq('user_id', user.id) - .eq('action', 'data_exported') - .gte('created_at', oneHourAgo) - .limit(1); + .order('created_at', { ascending: false }); - if (rateLimitError) { - edgeLogger.error('Rate limit check failed', { action: 'export_rate_limit', requestId: tracking.requestId, error: rateLimitError }); + if (!reviewsError && reviewsData) { + reviews = reviewsData.map(r => ({ + id: r.id, + rating: r.rating, + review_text: r.review_text, + ride_name: r.rides?.name, + park_name: r.parks?.name, + created_at: r.created_at + })); } + } - if (recentExports && recentExports.length > 0) { - const duration = endRequest(tracking); - const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString(); - edgeLogger.warn('Rate limit exceeded for export', { - action: 'export_rate_limit', - requestId: tracking.requestId, - userId: user.id, - duration, - nextAvailableAt - }); - return new Response( - JSON.stringify({ - success: false, - error: 'Rate limited. You can export your data once per hour.', - rate_limited: true, - next_available_at: nextAvailableAt, - requestId: tracking.requestId - }), - { - status: 429, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } - } + // Fetch lists if requested + let lists = []; + if (options.include_lists) { + const { data: listsData, error: listsError } = await supabase + .from('user_lists') + .select('id, name, description, is_public, created_at') + .eq('user_id', user.id) + .order('created_at', { ascending: false }); + + if (!listsError && listsData) { + lists = await Promise.all( + listsData.map(async (list) => { + const { count } = await supabase + .from('user_list_items') + .select('*', { count: 'exact', head: true }) + .eq('list_id', list.id); + + return { + id: list.id, + name: list.name, + description: list.description, + is_public: list.is_public, + item_count: count || 0, + created_at: list.created_at + }; + }) ); } + } - // Parse export options - const body = await req.json(); - const options: ExportOptions = { - include_reviews: body.include_reviews ?? true, - include_lists: body.include_lists ?? true, - include_activity_log: body.include_activity_log ?? true, - include_preferences: body.include_preferences ?? true, - format: 'json' - }; + // Fetch activity log if requested + let activity_log = []; + if (options.include_activity_log) { + const { data: activityData, error: activityError } = await supabase + .from('profile_audit_log') + .select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(100); - edgeLogger.info('Export options', { - action: 'export_options', - requestId: tracking.requestId, - userId: user.id - }); + if (!activityError && activityData) { + activity_log = activityData; + } + } - // Fetch profile data - const { data: profile, error: profileError } = await supabaseClient - .from('profiles') - .select('username, display_name, bio, preferred_pronouns, personal_location, timezone, preferred_language, theme_preference, privacy_level, ride_count, coaster_count, park_count, review_count, reputation_score, created_at, updated_at') + // Fetch preferences if requested + let preferences = { + unit_preferences: null, + accessibility_options: null, + notification_preferences: null, + privacy_settings: null + }; + + if (options.include_preferences) { + const { data: prefsData } = await supabase + .from('user_preferences') + .select('unit_preferences, accessibility_options, notification_preferences, privacy_settings') .eq('user_id', user.id) .single(); - if (profileError) { - edgeLogger.error('Profile fetch failed', { - action: 'export_profile', - requestId: tracking.requestId, - userId: user.id - }); - throw new Error('Failed to fetch profile data'); + if (prefsData) { + preferences = prefsData; } - - // Fetch statistics - const { count: photoCount } = await supabaseClient - .from('photos') - .select('*', { count: 'exact', head: true }) - .eq('submitted_by', user.id); - - const { count: listCount } = await supabaseClient - .from('user_lists') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id); - - const { count: submissionCount } = await supabaseClient - .from('content_submissions') - .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id); - - const statistics = { - ride_count: profile.ride_count || 0, - coaster_count: profile.coaster_count || 0, - park_count: profile.park_count || 0, - review_count: profile.review_count || 0, - reputation_score: profile.reputation_score || 0, - photo_count: photoCount || 0, - list_count: listCount || 0, - submission_count: submissionCount || 0, - account_created: profile.created_at, - last_updated: profile.updated_at - }; - - // Fetch reviews if requested - let reviews = []; - if (options.include_reviews) { - const { data: reviewsData, error: reviewsError } = await supabaseClient - .from('reviews') - .select(` - id, - rating, - review_text, - created_at, - rides(name), - parks(name) - `) - .eq('user_id', user.id) - .order('created_at', { ascending: false }); - - if (!reviewsError && reviewsData) { - reviews = reviewsData.map(r => ({ - id: r.id, - rating: r.rating, - review_text: r.review_text, - ride_name: r.rides?.name, - park_name: r.parks?.name, - created_at: r.created_at - })); - } - } - - // Fetch lists if requested - let lists = []; - if (options.include_lists) { - const { data: listsData, error: listsError } = await supabaseClient - .from('user_lists') - .select('id, name, description, is_public, created_at') - .eq('user_id', user.id) - .order('created_at', { ascending: false }); - - if (!listsError && listsData) { - lists = await Promise.all( - listsData.map(async (list) => { - const { count } = await supabaseClient - .from('user_list_items') - .select('*', { count: 'exact', head: true }) - .eq('list_id', list.id); - - return { - id: list.id, - name: list.name, - description: list.description, - is_public: list.is_public, - item_count: count || 0, - created_at: list.created_at - }; - }) - ); - } - } - - // Fetch activity log if requested - let activity_log = []; - if (options.include_activity_log) { - const { data: activityData, error: activityError } = await supabaseClient - .from('profile_audit_log') - .select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent') - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - .limit(100); - - if (!activityError && activityData) { - activity_log = activityData; - } - } - - // Fetch preferences if requested - let preferences = { - unit_preferences: null, - accessibility_options: null, - notification_preferences: null, - privacy_settings: null - }; - - if (options.include_preferences) { - const { data: prefsData } = await supabaseClient - .from('user_preferences') - .select('unit_preferences, accessibility_options, notification_preferences, privacy_settings') - .eq('user_id', user.id) - .single(); - - if (prefsData) { - preferences = prefsData; - } - } - - // Build export data structure - const exportData = { - export_date: new Date().toISOString(), - user_id: user.id, - profile: { - username: profile.username, - display_name: profile.display_name, - bio: profile.bio, - preferred_pronouns: profile.preferred_pronouns, - personal_location: profile.personal_location, - timezone: profile.timezone, - preferred_language: profile.preferred_language, - theme_preference: profile.theme_preference, - privacy_level: profile.privacy_level, - created_at: profile.created_at, - updated_at: profile.updated_at - }, - statistics, - reviews, - lists, - activity_log, - preferences, - metadata: { - export_version: '1.0.0', - data_retention_info: 'Your data is retained according to our privacy policy. You can request deletion at any time from your account settings.', - instructions: 'This file contains all your personal data stored in ThrillWiki. You can use this for backup purposes or to migrate to another service. For questions, contact support@thrillwiki.com' - } - }; - - // Log the export action - await supabaseClient.from('profile_audit_log').insert([{ - user_id: user.id, - changed_by: user.id, - action: 'data_exported', - changes: { - export_options: options, - timestamp: new Date().toISOString(), - data_size: JSON.stringify(exportData).length, - requestId: tracking.requestId - } - }]); - - const duration = endRequest(tracking); - edgeLogger.info('Export completed successfully', { - action: 'export_complete', - requestId: tracking.requestId, - traceId: tracking.traceId, - userId: user.id, - duration, - dataSize: JSON.stringify(exportData).length - }); - - return new Response( - JSON.stringify({ - success: true, - data: exportData, - requestId: tracking.requestId - }), - { - status: 200, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`, - 'X-Request-ID': tracking.requestId - } - } - ); - - } catch (error) { - const duration = endRequest(tracking); - edgeLogger.error('Export error', { - action: 'export_error', - requestId: tracking.requestId, - duration, - error: formatEdgeError(error) - }); - - // Persist error to database for monitoring - const errorSpan = startSpan('export-user-data-error', 'SERVER'); - endSpan(errorSpan, 'error', error); - logSpanToDatabase(errorSpan, tracking.requestId); - const sanitized = sanitizeError(error, 'export-user-data'); - return new Response( - JSON.stringify({ - ...sanitized, - success: false, - requestId: tracking.requestId - }), - { - status: 500, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } - } - ); } -}, rateLimiters.strict, corsHeaders)); + + // Build export data structure + const exportData = { + export_date: new Date().toISOString(), + user_id: user.id, + profile: { + username: profile.username, + display_name: profile.display_name, + bio: profile.bio, + preferred_pronouns: profile.preferred_pronouns, + personal_location: profile.personal_location, + timezone: profile.timezone, + preferred_language: profile.preferred_language, + theme_preference: profile.theme_preference, + privacy_level: profile.privacy_level, + created_at: profile.created_at, + updated_at: profile.updated_at + }, + statistics, + reviews, + lists, + activity_log, + preferences, + metadata: { + export_version: '1.0.0', + data_retention_info: 'Your data is retained according to our privacy policy. You can request deletion at any time from your account settings.', + instructions: 'This file contains all your personal data stored in ThrillWiki. You can use this for backup purposes or to migrate to another service. For questions, contact support@thrillwiki.com' + } + }; + + // Log the export action + await supabase.from('profile_audit_log').insert([{ + user_id: user.id, + changed_by: user.id, + action: 'data_exported', + changes: { + export_options: options, + timestamp: new Date().toISOString(), + data_size: JSON.stringify(exportData).length, + requestId + } + }]); + + addSpanEvent(span, 'export_completed', { + dataSize: JSON.stringify(exportData).length + }); + + return new Response( + JSON.stringify({ + success: true, + data: exportData, + }), + { + headers: { + 'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`, + } + } + ); +}; + +serve(createEdgeFunction({ + name: 'export-user-data', + requireAuth: true, + corsHeaders, + enableTracing: true, + logRequests: true, + logResponses: true, + rateLimitTier: 'strict', // 5 requests per minute +}, handler)); diff --git a/supabase/functions/notify-user-submission-status/index.ts b/supabase/functions/notify-user-submission-status/index.ts index 857f0501..887865b4 100644 --- a/supabase/functions/notify-user-submission-status/index.ts +++ b/supabase/functions/notify-user-submission-status/index.ts @@ -1,7 +1,7 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; -import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; +import { corsHeaders } from '../_shared/cors.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; interface RequestBody { submission_id: string; @@ -81,203 +81,151 @@ async function constructEntityURL( return `${baseURL}`; } -serve(async (req) => { - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); +const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => { + const { submission_id, user_id, submission_type, status, reviewer_notes } = await req.json() as RequestBody; + + addSpanEvent(span, 'notification_request', { + submissionId: submission_id, + userId: user_id, + status + }); + + // Fetch submission items to get entity data + const { data: items, error: itemsError } = await supabase + .from('submission_items') + .select('item_data') + .eq('submission_id', submission_id) + .order('order_index', { ascending: true }) + .limit(1) + .maybeSingle(); + + if (itemsError) { + throw new Error(`Failed to fetch submission items: ${itemsError.message}`); } - const tracking = startRequest('notify-user-submission-status'); + if (!items || !items.item_data) { + throw new Error('No submission items found'); + } - try { - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - - const supabase = createClient(supabaseUrl, supabaseServiceKey); + // Extract entity data + const entityName = items.item_data.name || 'your submission'; + const entityType = submission_type.replace('_', ' '); + + // Construct entity URL + const entityURL = await constructEntityURL(supabase, submission_type, items.item_data); - const { submission_id, user_id, submission_type, status, reviewer_notes } = await req.json() as RequestBody; - - // Fetch submission items to get entity data - const { data: items, error: itemsError } = await supabase - .from('submission_items') - .select('item_data') - .eq('submission_id', submission_id) - .order('order_index', { ascending: true }) - .limit(1) - .maybeSingle(); - - if (itemsError) { - throw new Error(`Failed to fetch submission items: ${itemsError.message}`); - } - - if (!items || !items.item_data) { - throw new Error('No submission items found'); - } - - // Extract entity data - const entityName = items.item_data.name || 'your submission'; - const entityType = submission_type.replace('_', ' '); - - // Construct entity URL - const entityURL = await constructEntityURL(supabase, submission_type, items.item_data); - - // Determine workflow and build payload based on status - const workflowId = status === 'approved' ? 'submission-approved' : 'submission-rejected'; - - let payload: Record; - - if (status === 'approved') { - // Approval payload - payload = { - baseUrl: 'https://www.thrillwiki.com', - entityType, - entityName, - submissionId: submission_id, - entityURL, - moderationNotes: reviewer_notes || '', - }; - } else { - // Rejection payload - payload = { - baseUrl: 'https://www.thrillwiki.com', - rejectionReason: reviewer_notes || 'No reason provided', - entityType, - entityName, - entityURL, - actualStatus: 'rejected', - }; - } - - // Generate idempotency key for duplicate prevention - const { data: keyData, error: keyError } = await supabase - .rpc('generate_notification_idempotency_key', { - p_notification_type: `submission_${status}`, - p_entity_id: submission_id, - p_recipient_id: user_id, - }); - - const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`; - - // Check for duplicate within 24h window - const { data: existingLog, error: logCheckError } = await supabase - .from('notification_logs') - .select('id') - .eq('user_id', user_id) - .eq('idempotency_key', idempotencyKey) - .gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()) - .maybeSingle(); - - if (existingLog) { - // Duplicate detected - log and skip - await supabase.from('notification_logs').update({ - is_duplicate: true - }).eq('id', existingLog.id); - - edgeLogger.info('Duplicate notification prevented', { - action: 'notify_user_submission_status', - userId: user_id, - idempotencyKey, - submissionId: submission_id, - requestId: tracking.requestId - }); - - endRequest(tracking, 200); - - return new Response( - JSON.stringify({ - success: true, - message: 'Duplicate notification prevented', - idempotencyKey, - requestId: tracking.requestId - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 200, - } - ); - } - - edgeLogger.info('Sending notification to user', { - action: 'notify_user_submission_status', - userId: user_id, - workflowId, + // Determine workflow and build payload based on status + const workflowId = status === 'approved' ? 'submission-approved' : 'submission-rejected'; + + let payload: Record; + + if (status === 'approved') { + payload = { + baseUrl: 'https://www.thrillwiki.com', + entityType, entityName, - status, - idempotencyKey, - requestId: tracking.requestId - }); - - // Call trigger-notification function - const { data: notificationResult, error: notificationError } = await supabase.functions.invoke( - 'trigger-notification', - { - body: { - workflowId, - subscriberId: user_id, - payload, - }, - } - ); - - if (notificationError) { - throw new Error(`Failed to trigger notification: ${notificationError.message}`); - } - - // Log notification in notification_logs with idempotency key - await supabase.from('notification_logs').insert({ - user_id, - notification_type: `submission_${status}`, - idempotency_key: idempotencyKey, - is_duplicate: false, - metadata: { - submission_id, - submission_type, - transaction_id: notificationResult?.transactionId - } - }); - - edgeLogger.info('User notification sent successfully', { action: 'notify_user_submission_status', requestId: tracking.requestId, result: notificationResult }); - - endRequest(tracking, 200); - - return new Response( - JSON.stringify({ - success: true, - transactionId: notificationResult?.transactionId, - requestId: tracking.requestId - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 200, - } - ); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - edgeLogger.error('Error notifying user about submission status', { action: 'notify_user_submission_status', requestId: tracking.requestId, error: errorMessage }); - - endRequest(tracking, 500, errorMessage); - - return new Response( - JSON.stringify({ - success: false, - error: errorMessage, - requestId: tracking.requestId - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 500, - } - ); + submissionId: submission_id, + entityURL, + moderationNotes: reviewer_notes || '', + }; + } else { + payload = { + baseUrl: 'https://www.thrillwiki.com', + rejectionReason: reviewer_notes || 'No reason provided', + entityType, + entityName, + entityURL, + actualStatus: 'rejected', + }; } -}); + + // Generate idempotency key for duplicate prevention + const { data: keyData, error: keyError } = await supabase + .rpc('generate_notification_idempotency_key', { + p_notification_type: `submission_${status}`, + p_entity_id: submission_id, + p_recipient_id: user_id, + }); + + const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`; + + // Check for duplicate within 24h window + const { data: existingLog, error: logCheckError } = await supabase + .from('notification_logs') + .select('id') + .eq('user_id', user_id) + .eq('idempotency_key', idempotencyKey) + .gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()) + .maybeSingle(); + + if (existingLog) { + // Duplicate detected - log and skip + await supabase.from('notification_logs').update({ + is_duplicate: true + }).eq('id', existingLog.id); + + addSpanEvent(span, 'duplicate_notification_prevented', { + idempotencyKey, + submissionId: submission_id + }); + + return { + success: true, + message: 'Duplicate notification prevented', + idempotencyKey, + }; + } + + addSpanEvent(span, 'sending_notification', { + workflowId, + entityName, + idempotencyKey + }); + + // Call trigger-notification function + const { data: notificationResult, error: notificationError } = await supabase.functions.invoke( + 'trigger-notification', + { + body: { + workflowId, + subscriberId: user_id, + payload, + }, + } + ); + + if (notificationError) { + throw new Error(`Failed to trigger notification: ${notificationError.message}`); + } + + // Log notification in notification_logs with idempotency key + await supabase.from('notification_logs').insert({ + user_id, + notification_type: `submission_${status}`, + idempotency_key: idempotencyKey, + is_duplicate: false, + metadata: { + submission_id, + submission_type, + transaction_id: notificationResult?.transactionId + } + }); + + addSpanEvent(span, 'notification_sent', { + transactionId: notificationResult?.transactionId + }); + + return { + success: true, + transactionId: notificationResult?.transactionId, + }; +}; + +serve(createEdgeFunction({ + name: 'notify-user-submission-status', + requireAuth: false, + useServiceRole: true, + corsHeaders, + enableTracing: true, + logRequests: true, +}, handler)); diff --git a/supabase/functions/resend-deletion-code/index.ts b/supabase/functions/resend-deletion-code/index.ts index 8f0a4f87..744d64f9 100644 --- a/supabase/functions/resend-deletion-code/index.ts +++ b/supabase/functions/resend-deletion-code/index.ts @@ -1,193 +1,114 @@ -import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { serve } from 'https://deno.land/std@0.190.0/http/server.ts'; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { corsHeaders } from '../_shared/cors.ts'; -import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'; -import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; -// Apply moderate rate limiting (10 req/min) to prevent deletion code spam -// Protects against abuse while allowing legitimate resend requests -serve(withRateLimit(async (req) => { - const tracking = startRequest(); - - if (req.method === 'OPTIONS') { - return new Response(null, { - headers: { - ...corsHeaders, - 'X-Request-ID': tracking.requestId - } - }); +const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => { + addSpanEvent(span, 'resending_deletion_code', { userId: user.id }); + + // Find pending deletion request + const { data: deletionRequest, error: requestError } = await supabase + .from('account_deletion_requests') + .select('*') + .eq('user_id', user.id) + .eq('status', 'pending') + .maybeSingle(); + + if (requestError || !deletionRequest) { + throw new Error('No pending deletion request found'); } - try { - const supabaseClient = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_ANON_KEY') ?? '', - { - global: { - headers: { Authorization: req.headers.get('Authorization')! }, + // Check rate limiting (max 3 resends per hour - ~20 minutes between resends) + const lastSent = new Date(deletionRequest.confirmation_code_sent_at); + const now = new Date(); + const hoursSinceLastSend = (now.getTime() - lastSent.getTime()) / (1000 * 60 * 60); + + if (hoursSinceLastSend < 0.33) { + addSpanEvent(span, 'resend_rate_limited', { + hoursSinceLastSend, + minRequired: 0.33 + }); + throw new Error('Please wait at least 20 minutes between resend requests'); + } + + // Generate new confirmation code + const { data: codeData, error: codeError } = await supabase + .rpc('generate_deletion_confirmation_code'); + + if (codeError) { + throw codeError; + } + + const confirmationCode = codeData as string; + + // Update deletion request with new code + const { error: updateError } = await supabase + .from('account_deletion_requests') + .update({ + confirmation_code: confirmationCode, + confirmation_code_sent_at: now.toISOString(), + }) + .eq('id', deletionRequest.id); + + if (updateError) { + throw updateError; + } + + const scheduledDate = new Date(deletionRequest.scheduled_deletion_at); + + addSpanEvent(span, 'new_code_generated', { + scheduledDeletionDate: scheduledDate.toISOString() + }); + + // Send email with new code + const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; + + if (forwardEmailKey) { + try { + await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, }, - } - ); - - // Get authenticated user - const { - data: { user }, - error: userError, - } = await supabaseClient.auth.getUser(); - - if (userError || !user) { - const duration = endRequest(tracking); - edgeLogger.error('Authentication failed', { - action: 'resend_deletion_code', - requestId: tracking.requestId, - duration + body: JSON.stringify({ + from: fromEmail, + to: user.email, + subject: 'Account Deletion - New Confirmation Code', + html: ` +

New Confirmation Code

+

You requested a new confirmation code for your account deletion.

+

Your account will be permanently deleted on ${scheduledDate.toLocaleDateString()}.

+ +

CONFIRMATION CODE: ${confirmationCode}

+

To confirm deletion after the waiting period, you'll need to enter this 6-digit code.

+ +

Need to cancel? Log in and visit your account settings to reactivate your account.

+ `, + }), + }); + addSpanEvent(span, 'email_sent', { email: user.email }); + } catch (emailError) { + addSpanEvent(span, 'email_send_failed', { + error: emailError instanceof Error ? emailError.message : String(emailError) }); - - // Persist error to database - const authErrorSpan = startSpan('resend-deletion-code-auth-error', 'SERVER'); - endSpan(authErrorSpan, 'error', userError); - logSpanToDatabase(authErrorSpan, tracking.requestId); - throw new Error('Unauthorized'); } - - edgeLogger.info('Resending deletion code for user', { - action: 'resend_deletion_code', - requestId: tracking.requestId, - userId: user.id - }); - - // Find pending deletion request - const { data: deletionRequest, error: requestError } = await supabaseClient - .from('account_deletion_requests') - .select('*') - .eq('user_id', user.id) - .eq('status', 'pending') - .maybeSingle(); - - if (requestError || !deletionRequest) { - throw new Error('No pending deletion request found'); - } - - // Check rate limiting (max 3 resends per hour) - const lastSent = new Date(deletionRequest.confirmation_code_sent_at); - const now = new Date(); - const hoursSinceLastSend = (now.getTime() - lastSent.getTime()) / (1000 * 60 * 60); - - if (hoursSinceLastSend < 0.33) { // ~20 minutes between resends - throw new Error('Please wait at least 20 minutes between resend requests'); - } - - // Generate new confirmation code - const { data: codeData, error: codeError } = await supabaseClient - .rpc('generate_deletion_confirmation_code'); - - if (codeError) { - throw codeError; - } - - const confirmationCode = codeData as string; - - // Update deletion request with new code - const { error: updateError } = await supabaseClient - .from('account_deletion_requests') - .update({ - confirmation_code: confirmationCode, - confirmation_code_sent_at: now.toISOString(), - }) - .eq('id', deletionRequest.id); - - if (updateError) { - throw updateError; - } - - const scheduledDate = new Date(deletionRequest.scheduled_deletion_at); - - // Send email with new code - const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); - const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; - - if (forwardEmailKey) { - try { - await fetch('https://api.forwardemail.net/v1/emails', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, - }, - body: JSON.stringify({ - from: fromEmail, - to: user.email, - subject: 'Account Deletion - New Confirmation Code', - html: ` -

New Confirmation Code

-

You requested a new confirmation code for your account deletion.

-

Your account will be permanently deleted on ${scheduledDate.toLocaleDateString()}.

- -

CONFIRMATION CODE: ${confirmationCode}

-

To confirm deletion after the waiting period, you'll need to enter this 6-digit code.

- -

Need to cancel? Log in and visit your account settings to reactivate your account.

- `, - }), - }); - edgeLogger.info('New confirmation code email sent', { requestId: tracking.requestId }); - } catch (emailError) { - edgeLogger.error('Failed to send email', { - requestId: tracking.requestId, - error: emailError.message - }); - } - } - - const duration = endRequest(tracking); - edgeLogger.info('New confirmation code sent successfully', { - action: 'resend_deletion_code', - requestId: tracking.requestId, - userId: user.id, - duration - }); - - return new Response( - JSON.stringify({ - success: true, - message: 'New confirmation code sent successfully', - requestId: tracking.requestId, - }), - { - status: 200, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - } - ); - } catch (error) { - const duration = endRequest(tracking); - edgeLogger.error('Error resending code', { - action: 'resend_deletion_code', - requestId: tracking.requestId, - duration, - error: error.message - }); - - // Persist error to database for monitoring - const errorSpan = startSpan('resend-deletion-code-error', 'SERVER'); - endSpan(errorSpan, 'error', error); - logSpanToDatabase(errorSpan, tracking.requestId); - return new Response( - JSON.stringify({ - error: error.message, - requestId: tracking.requestId - }), - { - status: 400, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - } - ); } -}, rateLimiters.moderate, corsHeaders)); + + addSpanEvent(span, 'resend_completed', { userId: user.id }); + + return { + success: true, + message: 'New confirmation code sent successfully', + }; +}; + +serve(createEdgeFunction({ + name: 'resend-deletion-code', + requireAuth: true, + corsHeaders, + enableTracing: true, + logRequests: true, + rateLimitTier: 'moderate', // 10 requests per minute +}, handler));