Refactor: Implement Data & Export tab modernization

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 20:08:43 +00:00
parent 53d8a130f5
commit 2eec20f653
4 changed files with 953 additions and 139 deletions

View File

@@ -1,77 +1,287 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { supabase } from '@/integrations/supabase/client';
import { Download, BarChart3, Upload, FileText, History } from 'lucide-react';
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { Download, Activity, BarChart3, AlertCircle, Clock } from 'lucide-react';
import type {
UserStatistics,
ActivityLogEntry,
ExportOptions,
ExportRequestResult
} from '@/types/data-export';
import {
exportOptionsSchema,
DEFAULT_EXPORT_OPTIONS
} from '@/lib/dataExportValidation';
export function DataExportTab() {
const { user } = useAuth();
const { data: profile } = useProfile(user?.id);
const {
toast
} = useToast();
const [exportLoading, setExportLoading] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const handleDataExport = async () => {
if (!user) return;
setExportLoading(true);
setExportProgress(0);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [statistics, setStatistics] = useState<UserStatistics | null>(null);
const [recentActivity, setRecentActivity] = useState<ActivityLogEntry[]>([]);
const [exportOptions, setExportOptions] = useState<ExportOptions>(DEFAULT_EXPORT_OPTIONS);
const [rateLimited, setRateLimited] = useState(false);
const [nextAvailableAt, setNextAvailableAt] = useState<string | null>(null);
useEffect(() => {
if (user && profile) {
loadStatistics();
loadRecentActivity();
}
}, [user, profile]);
const loadStatistics = async () => {
if (!user || !profile) return;
try {
// Simulate export progress
const steps = ['Collecting profile data...', 'Gathering reviews...', 'Collecting ride credits...', 'Gathering top lists...', 'Packaging data...', 'Preparing download...'];
for (let i = 0; i < steps.length; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
setExportProgress((i + 1) / steps.length * 100);
// Fetch additional counts
const { count: photoCount } = await supabase
.from('photos')
.select('*', { count: 'exact', head: true })
.eq('submitted_by', user.id);
// Note: user_lists table needs to be created for this to work
// For now, setting to 0 as placeholder
const listCount = 0;
const { count: submissionCount } = await supabase
.from('content_submissions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id);
const stats: UserStatistics = {
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
};
setStatistics(stats);
logger.info('User statistics loaded', {
userId: user.id,
action: 'load_statistics'
});
} catch (error) {
logger.error('Failed to load statistics', {
userId: user.id,
action: 'load_statistics',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Load statistics',
userId: user.id
});
}
};
const loadRecentActivity = async () => {
if (!user) return;
try {
const { data, error } = 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(10);
if (error) {
logger.error('Failed to load activity log', {
userId: user.id,
action: 'load_activity_log',
error: error.message,
errorCode: error.code
});
throw error;
}
// In a real implementation, this would:
// 1. Fetch all user data from various tables
// 2. Package it into a structured format (JSON/CSV)
// 3. Create a downloadable file
// Transform the data to match our type
const activityData: ActivityLogEntry[] = (data || []).map(item => ({
id: item.id,
action: item.action,
changes: item.changes as Record<string, any>,
created_at: item.created_at,
changed_by: item.changed_by,
ip_address_hash: item.ip_address_hash || undefined,
user_agent: item.user_agent || undefined
}));
const exportData = {
profile: profile,
export_date: new Date().toISOString(),
data_types: ['profile', 'reviews', 'ride_credits', 'top_lists']
setRecentActivity(activityData);
logger.info('Activity log loaded', {
userId: user.id,
action: 'load_activity_log',
count: activityData.length
});
} catch (error) {
logger.error('Error loading activity log', {
userId: user.id,
action: 'load_activity_log',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Load activity log',
userId: user.id
});
} finally {
setLoading(false);
}
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
const handleDataExport = async () => {
if (!user) return;
setExporting(true);
try {
// Validate export options
const validatedOptions = exportOptionsSchema.parse(exportOptions);
logger.info('Starting data export', {
userId: user.id,
action: 'export_data',
options: validatedOptions
});
// Call edge function for secure export
const { data, error } = await supabase.functions.invoke<ExportRequestResult>(
'export-user-data',
{
body: validatedOptions
}
);
if (error) {
logger.error('Edge function invocation failed', {
userId: user.id,
action: 'export_data',
error: error.message
});
throw error;
}
if (!data?.success) {
if (data?.rate_limited) {
setRateLimited(true);
setNextAvailableAt(data.next_available_at || null);
logger.warn('Export rate limited', {
userId: user.id,
action: 'export_data',
nextAvailableAt: data.next_available_at
});
handleError(
new AppError(
'Rate limited',
'RATE_LIMIT_EXCEEDED',
data.error || 'You can export your data once per hour. Please try again later.'
),
{ action: 'Export data', userId: user.id }
);
return;
}
throw new Error(data?.error || 'Export failed');
}
// Download the data as JSON
const blob = new Blob([JSON.stringify(data.data, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
const link = document.createElement('a');
link.href = url;
link.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast({
title: 'Export complete',
description: 'Your data has been exported and downloaded successfully.'
logger.info('Data export completed', {
userId: user.id,
action: 'export_data',
dataSize: JSON.stringify(data.data).length
});
} catch (error: any) {
toast({
title: 'Export failed',
description: error.message || 'Failed to export your data',
variant: 'destructive'
handleSuccess(
'Data exported successfully',
'Your data has been downloaded as a JSON file.'
);
// Refresh activity log to show the export action
await loadRecentActivity();
} catch (error) {
logger.error('Data export failed', {
userId: user.id,
action: 'export_data',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Export data',
userId: user.id
});
} finally {
setExportLoading(false);
setExportProgress(0);
setExporting(false);
}
};
const handleImportData = () => {
toast({
title: 'Coming soon',
description: 'Data import functionality will be available in a future update.'
const formatActionName = (action: string): string => {
return action
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return <div className="space-y-8">
if (loading) {
return (
<div className="space-y-8">
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-full max-w-md" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8">
{/* Personal Statistics */}
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -82,46 +292,53 @@ export function DataExportTab() {
<Card>
<CardHeader>
<CardDescription>
Overview of your activity and contributions to ThrillWiku.
Your activity and contribution statistics on ThrillWiki
</CardDescription>
</CardHeader>
<CardContent>
{statistics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.review_count || 0}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Reviews</p>
<p className="text-2xl font-bold">{statistics.review_count}</p>
</div>
<div className="text-sm text-muted-foreground">Reviews</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Rides Tracked</p>
<p className="text-2xl font-bold">{statistics.ride_count}</p>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.ride_count || 0}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Coasters</p>
<p className="text-2xl font-bold">{statistics.coaster_count}</p>
</div>
<div className="text-sm text-muted-foreground">Rides</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Parks Visited</p>
<p className="text-2xl font-bold">{statistics.park_count}</p>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.coaster_count || 0}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Photos</p>
<p className="text-2xl font-bold">{statistics.photo_count}</p>
</div>
<div className="text-sm text-muted-foreground">Coasters</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Lists</p>
<p className="text-2xl font-bold">{statistics.list_count}</p>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.park_count || 0}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Submissions</p>
<p className="text-2xl font-bold">{statistics.submission_count}</p>
</div>
<div className="text-sm text-muted-foreground">Parks</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Reputation</p>
<p className="text-2xl font-bold">{statistics.reputation_score}</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
<Separator />
{/* Data Export */}
{/* Export Your Data */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download className="w-5 h-5" />
@@ -132,85 +349,137 @@ export function DataExportTab() {
<CardHeader>
<CardTitle>Download Your Data</CardTitle>
<CardDescription>
Export all your personal data in a machine-readable format. This includes your profile,
reviews, ride credits, top lists, and account activity.
Export all your ThrillWiki data in JSON format. This includes your profile, reviews, lists, and activity history.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Profile Information</Badge>
<Badge variant="secondary">Reviews & Ratings</Badge>
<Badge variant="secondary">Ride Credits</Badge>
<Badge variant="secondary">Top Lists</Badge>
<Badge variant="secondary">Account Activity</Badge>
</div>
{exportLoading && <div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Exporting data...</span>
<span>{Math.round(exportProgress)}%</span>
</div>
<Progress value={exportProgress} />
</div>}
<Button onClick={handleDataExport} disabled={exportLoading} className="w-fit">
<Download className="w-4 h-4 mr-2" />
{exportLoading ? 'Exporting...' : 'Export My Data'}
</Button>
<CardContent className="space-y-6">
{rateLimited && nextAvailableAt && (
<div className="flex items-start gap-3 p-4 border border-yellow-500/20 bg-yellow-500/10 rounded-lg">
<Clock className="w-5 h-5 text-yellow-500 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Rate Limited</p>
<p className="text-sm text-muted-foreground">
Your data will be provided in JSON format. Processing may take a few moments
for accounts with lots of activity.
You can export your data once per hour. Next export available at{' '}
{formatDate(nextAvailableAt)}.
</p>
</div>
</div>
)}
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Choose what to include in your export:
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="include_reviews">Include Reviews</Label>
<Switch
id="include_reviews"
checked={exportOptions.include_reviews}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_reviews: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="include_lists">Include Lists</Label>
<Switch
id="include_lists"
checked={exportOptions.include_lists}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_lists: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="include_activity_log">Include Activity Log</Label>
<Switch
id="include_activity_log"
checked={exportOptions.include_activity_log}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_activity_log: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="include_preferences">Include Preferences</Label>
<Switch
id="include_preferences"
checked={exportOptions.include_preferences}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_preferences: checked })
}
/>
</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 border rounded-lg">
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">GDPR Compliance</p>
<p className="text-sm text-muted-foreground">
This export includes all personal data we store about you. You can use this for backup purposes or to migrate to another service.
</p>
</div>
</div>
<Button
onClick={handleDataExport}
disabled={exporting || rateLimited}
className="w-full"
>
<Download className="w-4 h-4 mr-2" />
{exporting ? 'Exporting Data...' : 'Export My Data'}
</Button>
</CardContent>
</Card>
</div>
{/* Data Import */}
<Separator />
{/* Account Activity */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="w-5 h-5" />
<Activity className="w-5 h-5" />
<h3 className="text-lg font-medium">Account Activity</h3>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Activity Log</CardTitle>
<CardDescription>
Your recent account activities and important events.
Recent account activity and changes
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 border rounded-lg">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium">Profile updated</p>
{recentActivity.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No recent activity to display
</p>
) : (
<div className="space-y-4">
{recentActivity.map((activity) => (
<div key={activity.id} className="flex items-start gap-3 pb-4 border-b last:border-0 last:pb-0">
<Activity className="w-4 h-4 text-muted-foreground mt-1" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">
{formatActionName(activity.action)}
</p>
<p className="text-xs text-muted-foreground">
{profile?.updated_at ? new Date(profile.updated_at).toLocaleString() : 'Recently'}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 border rounded-lg">
<div className="w-2 h-2 bg-muted rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium">Account created</p>
<p className="text-xs text-muted-foreground">
{profile?.created_at ? new Date(profile.created_at).toLocaleString() : 'N/A'}
{formatDate(activity.created_at)}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>;
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { z } from 'zod';
/**
* Validation schemas for data export
*/
/**
* User statistics schema
*/
export const userStatisticsSchema = z.object({
ride_count: z.number().int().min(0),
coaster_count: z.number().int().min(0),
park_count: z.number().int().min(0),
review_count: z.number().int().min(0),
reputation_score: z.number().int().min(0),
photo_count: z.number().int().min(0),
list_count: z.number().int().min(0),
submission_count: z.number().int().min(0),
account_created: z.string(),
last_updated: z.string()
});
/**
* Activity log entry schema
*/
export const activityLogEntrySchema = z.object({
id: z.string().uuid(),
action: z.string(),
changes: z.record(z.string(), z.any()),
created_at: z.string(),
changed_by: z.string().uuid(),
ip_address_hash: z.string().optional(),
user_agent: z.string().optional()
});
/**
* Export options schema
*/
export const exportOptionsSchema = z.object({
include_reviews: z.boolean(),
include_lists: z.boolean(),
include_activity_log: z.boolean(),
include_preferences: z.boolean(),
format: z.literal('json')
});
/**
* Export profile data schema
*/
export const exportProfileDataSchema = z.object({
username: z.string(),
display_name: z.string().nullable(),
bio: z.string().nullable(),
preferred_pronouns: z.string().nullable(),
personal_location: z.string().nullable(),
timezone: z.string(),
preferred_language: z.string(),
theme_preference: z.string(),
privacy_level: z.string(),
created_at: z.string(),
updated_at: z.string()
});
/**
* Export review data schema
*/
export const exportReviewDataSchema = z.object({
id: z.string().uuid(),
rating: z.number().min(1).max(5),
review_text: z.string().nullable(),
ride_name: z.string().optional(),
park_name: z.string().optional(),
created_at: z.string()
});
/**
* Export list data schema
*/
export const exportListDataSchema = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().nullable(),
is_public: z.boolean(),
item_count: z.number().int().min(0),
created_at: z.string()
});
/**
* Complete export data structure schema
*/
export const exportDataSchema = z.object({
export_date: z.string(),
user_id: z.string().uuid(),
profile: exportProfileDataSchema,
statistics: userStatisticsSchema,
reviews: z.array(exportReviewDataSchema),
lists: z.array(exportListDataSchema),
activity_log: z.array(activityLogEntrySchema),
preferences: z.object({
unit_preferences: z.any(),
accessibility_options: z.any(),
notification_preferences: z.any(),
privacy_settings: z.any()
}),
metadata: z.object({
export_version: z.string(),
data_retention_info: z.string(),
instructions: z.string()
})
});
/**
* Default export options
*/
export const DEFAULT_EXPORT_OPTIONS = {
include_reviews: true,
include_lists: true,
include_activity_log: true,
include_preferences: true,
format: 'json' as const
};

135
src/types/data-export.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Data Export Type Definitions
*
* Types for user data export, statistics, and activity logs.
*/
/**
* User statistics aggregated from various tables
*/
export interface UserStatistics {
ride_count: number;
coaster_count: number;
park_count: number;
review_count: number;
reputation_score: number;
photo_count: number;
list_count: number;
submission_count: number;
account_created: string;
last_updated: string;
}
/**
* Individual activity log entry from profile_audit_log
*/
export interface ActivityLogEntry {
id: string;
action: string;
changes: Record<string, any>;
created_at: string;
changed_by: string;
ip_address_hash?: string;
user_agent?: string;
}
/**
* Profile data for export
*/
export interface ExportProfileData {
username: string;
display_name: string | null;
bio: string | null;
preferred_pronouns: string | null;
personal_location: string | null;
timezone: string;
preferred_language: string;
theme_preference: string;
privacy_level: string;
created_at: string;
updated_at: string;
}
/**
* Review data for export
*/
export interface ExportReviewData {
id: string;
rating: number;
review_text: string | null;
ride_name?: string;
park_name?: string;
created_at: string;
}
/**
* User list data for export
*/
export interface ExportListData {
id: string;
name: string;
description: string | null;
is_public: boolean;
item_count: number;
created_at: string;
}
/**
* Complete export data structure
*/
export interface ExportDataStructure {
export_date: string;
user_id: string;
profile: ExportProfileData;
statistics: UserStatistics;
reviews: ExportReviewData[];
lists: ExportListData[];
activity_log: ActivityLogEntry[];
preferences: {
unit_preferences: any;
accessibility_options: any;
notification_preferences: any;
privacy_settings: any;
};
metadata: {
export_version: string;
data_retention_info: string;
instructions: string;
};
}
/**
* Export options
*/
export interface ExportOptions {
include_reviews: boolean;
include_lists: boolean;
include_activity_log: boolean;
include_preferences: boolean;
format: 'json';
}
/**
* Export progress tracking
*/
export interface ExportProgress {
stage: 'starting' | 'fetching_profile' | 'fetching_reviews' | 'fetching_lists' | 'fetching_activity' | 'packaging' | 'complete';
progress: number;
message: string;
}
/**
* Data categories available for export
*/
export type DataCategory = 'profile' | 'reviews' | 'lists' | 'activity_log' | 'preferences';
/**
* Export request result
*/
export interface ExportRequestResult {
success: boolean;
data?: ExportDataStructure;
error?: string;
rate_limited?: boolean;
next_available_at?: string;
}

View File

@@ -0,0 +1,289 @@
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';
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) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
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) {
console.error('[Export] Authentication failed:', authError);
return new Response(
JSON.stringify({ error: 'Unauthorized', success: false }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
console.log(`[Export] Processing export request for user: ${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) {
console.error('[Export] Rate limit check failed:', rateLimitError);
}
if (recentExports && recentExports.length > 0) {
const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString();
console.log(`[Export] Rate limited for user ${user.id}, next available: ${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, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// 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'
};
console.log('[Export] Export options:', options);
// 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) {
console.error('[Export] Profile fetch failed:', profileError);
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
}
}]);
console.log(`[Export] Export completed successfully for user ${user.id}`);
return new Response(
JSON.stringify({ success: true, data: exportData }),
{
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`
}
}
);
} catch (error) {
console.error('[Export] Error:', error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'An unexpected error occurred',
success: false
}),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});