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 { addSpanEvent } from '../_shared/logger.ts'; interface ExportOptions { include_reviews: boolean; include_lists: boolean; include_activity_log: boolean; include_preferences: boolean; format: 'json'; } 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 }); } 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 } ); } // 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' }; addSpanEvent(span, 'export_options_parsed', { options }); // 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(); 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) .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 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 }; }) ); } } // 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); 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 supabase .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 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));