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 { sanitizeError } from '../_shared/errorSanitizer.ts'; import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'; import { formatEdgeError } from '../_shared/errorFormatter.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; interface ExportOptions { include_reviews: boolean; include_lists: boolean; include_activity_log: boolean; include_preferences: boolean; format: 'json'; } serve(async (req) => { const tracking = startRequest(); // Handle CORS preflight requests if (req.method === 'OPTIONS') { return new Response(null, { headers: { ...corsHeaders, 'X-Request-ID': tracking.requestId } }); } try { const supabaseClient = createClient( Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', { global: { headers: { Authorization: req.headers.get('Authorization')! }, }, } ); // Get authenticated user const { data: { user }, error: authError, } = await supabaseClient.auth.getUser(); if (authError || !user) { const duration = endRequest(tracking); edgeLogger.error('Authentication failed', { action: 'export_auth', requestId: tracking.requestId, duration }); 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 } } ); } edgeLogger.info('Processing export request', { action: 'export_start', requestId: tracking.requestId, userId: user.id }); // 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') .eq('user_id', user.id) .eq('action', 'data_exported') .gte('created_at', oneHourAgo) .limit(1); if (rateLimitError) { edgeLogger.error('Rate limit check failed', { action: 'export_rate_limit', requestId: tracking.requestId, error: rateLimitError }); } 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 } } ); } // 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' }; edgeLogger.info('Export options', { action: 'export_options', requestId: tracking.requestId, userId: user.id }); // 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') .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'); } // 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) }); 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 } } ); } });