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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/hooks/use-toast'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile'; import { useProfile } from '@/hooks/useProfile';
import { supabase } from '@/integrations/supabase/client'; 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() { export function DataExportTab() {
const { user } = useAuth(); const { user } = useAuth();
const { data: profile } = useProfile(user?.id); const { data: profile } = useProfile(user?.id);
const { const [loading, setLoading] = useState(true);
toast const [exporting, setExporting] = useState(false);
} = useToast(); const [statistics, setStatistics] = useState<UserStatistics | null>(null);
const [exportLoading, setExportLoading] = useState(false); const [recentActivity, setRecentActivity] = useState<ActivityLogEntry[]>([]);
const [exportProgress, setExportProgress] = useState(0); const [exportOptions, setExportOptions] = useState<ExportOptions>(DEFAULT_EXPORT_OPTIONS);
const handleDataExport = async () => { const [rateLimited, setRateLimited] = useState(false);
if (!user) return; const [nextAvailableAt, setNextAvailableAt] = useState<string | null>(null);
setExportLoading(true);
setExportProgress(0); useEffect(() => {
if (user && profile) {
loadStatistics();
loadRecentActivity();
}
}, [user, profile]);
const loadStatistics = async () => {
if (!user || !profile) return;
try { try {
// Simulate export progress // Fetch additional counts
const steps = ['Collecting profile data...', 'Gathering reviews...', 'Collecting ride credits...', 'Gathering top lists...', 'Packaging data...', 'Preparing download...']; const { count: photoCount } = await supabase
for (let i = 0; i < steps.length; i++) { .from('photos')
await new Promise(resolve => setTimeout(resolve, 500)); .select('*', { count: 'exact', head: true })
setExportProgress((i + 1) / steps.length * 100); .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: // Transform the data to match our type
// 1. Fetch all user data from various tables const activityData: ActivityLogEntry[] = (data || []).map(item => ({
// 2. Package it into a structured format (JSON/CSV) id: item.id,
// 3. Create a downloadable file 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 = { setRecentActivity(activityData);
profile: profile,
export_date: new Date().toISOString(), logger.info('Activity log loaded', {
data_types: ['profile', 'reviews', 'ride_credits', 'top_lists'] userId: user.id,
}; action: 'load_activity_log',
const blob = new Blob([JSON.stringify(exportData, null, 2)], { 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 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' type: 'application/json'
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const link = document.createElement('a');
a.href = url; link.href = url;
a.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`; link.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a); document.body.appendChild(link);
a.click(); link.click();
document.body.removeChild(a); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast({
title: 'Export complete', logger.info('Data export completed', {
description: 'Your data has been exported and downloaded successfully.' userId: user.id,
action: 'export_data',
dataSize: JSON.stringify(data.data).length
}); });
} catch (error: any) {
toast({ handleSuccess(
title: 'Export failed', 'Data exported successfully',
description: error.message || 'Failed to export your data', 'Your data has been downloaded as a JSON file.'
variant: 'destructive' );
// 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 { } finally {
setExportLoading(false); setExporting(false);
setExportProgress(0);
} }
}; };
const handleImportData = () => {
toast({ const formatActionName = (action: string): string => {
title: 'Coming soon', return action
description: 'Data import functionality will be available in a future update.' .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 */} {/* Personal Statistics */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -82,46 +292,53 @@ export function DataExportTab() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardDescription> <CardDescription>
Overview of your activity and contributions to ThrillWiku. Your activity and contribution statistics on ThrillWiki
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> {statistics && (
<div className="text-center p-4 bg-muted/50 rounded-lg"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-2xl font-bold text-primary"> <div className="space-y-1">
{profile?.review_count || 0} <p className="text-sm text-muted-foreground">Reviews</p>
<p className="text-2xl font-bold">{statistics.review_count}</p>
</div> </div>
<div className="text-sm text-muted-foreground">Reviews</div> <div className="space-y-1">
</div> <p className="text-sm text-muted-foreground">Rides Tracked</p>
<p className="text-2xl font-bold">{statistics.ride_count}</p>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.ride_count || 0}
</div> </div>
<div className="text-sm text-muted-foreground">Rides</div> <div className="space-y-1">
</div> <p className="text-sm text-muted-foreground">Coasters</p>
<p className="text-2xl font-bold">{statistics.coaster_count}</p>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.coaster_count || 0}
</div> </div>
<div className="text-sm text-muted-foreground">Coasters</div> <div className="space-y-1">
</div> <p className="text-sm text-muted-foreground">Parks Visited</p>
<p className="text-2xl font-bold">{statistics.park_count}</p>
<div className="text-center p-4 bg-muted/50 rounded-lg"> </div>
<div className="text-2xl font-bold text-primary"> <div className="space-y-1">
{profile?.park_count || 0} <p className="text-sm text-muted-foreground">Photos</p>
<p className="text-2xl font-bold">{statistics.photo_count}</p>
</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="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="space-y-1">
<p className="text-sm text-muted-foreground">Reputation</p>
<p className="text-2xl font-bold">{statistics.reputation_score}</p>
</div> </div>
<div className="text-sm text-muted-foreground">Parks</div>
</div> </div>
</div> )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Separator /> <Separator />
{/* Data Export */} {/* Export Your Data */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Download className="w-5 h-5" /> <Download className="w-5 h-5" />
@@ -132,85 +349,137 @@ export function DataExportTab() {
<CardHeader> <CardHeader>
<CardTitle>Download Your Data</CardTitle> <CardTitle>Download Your Data</CardTitle>
<CardDescription> <CardDescription>
Export all your personal data in a machine-readable format. This includes your profile, Export all your ThrillWiki data in JSON format. This includes your profile, reviews, lists, and activity history.
reviews, ride credits, top lists, and account activity.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
<div className="flex flex-wrap gap-2"> {rateLimited && nextAvailableAt && (
<Badge variant="secondary">Profile Information</Badge> <div className="flex items-start gap-3 p-4 border border-yellow-500/20 bg-yellow-500/10 rounded-lg">
<Badge variant="secondary">Reviews & Ratings</Badge> <Clock className="w-5 h-5 text-yellow-500 mt-0.5" />
<Badge variant="secondary">Ride Credits</Badge> <div className="space-y-1">
<Badge variant="secondary">Top Lists</Badge> <p className="text-sm font-medium">Rate Limited</p>
<Badge variant="secondary">Account Activity</Badge> <p className="text-sm text-muted-foreground">
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>
{exportLoading && <div className="space-y-2"> <div className="flex items-start gap-3 p-4 border rounded-lg">
<div className="flex justify-between text-sm"> <AlertCircle className="w-5 h-5 text-blue-500 mt-0.5" />
<span>Exporting data...</span> <div className="space-y-1">
<span>{Math.round(exportProgress)}%</span> <p className="text-sm font-medium">GDPR Compliance</p>
</div> <p className="text-sm text-muted-foreground">
<Progress value={exportProgress} /> This export includes all personal data we store about you. You can use this for backup purposes or to migrate to another service.
</div>} </p>
</div>
</div>
<Button onClick={handleDataExport} disabled={exportLoading} className="w-fit"> <Button
onClick={handleDataExport}
disabled={exporting || rateLimited}
className="w-full"
>
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
{exportLoading ? 'Exporting...' : 'Export My Data'} {exporting ? 'Exporting Data...' : 'Export My Data'}
</Button> </Button>
<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.
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Separator />
{/* Data Import */}
{/* Account Activity */} {/* Account Activity */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <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> <h3 className="text-lg font-medium">Account Activity</h3>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Recent Activity Log</CardTitle>
<CardDescription> <CardDescription>
Your recent account activities and important events. Recent account activity and changes
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> {recentActivity.length === 0 ? (
<div className="flex items-center gap-3 p-3 border rounded-lg"> <p className="text-sm text-muted-foreground text-center py-4">
<div className="w-2 h-2 bg-primary rounded-full"></div> No recent activity to display
<div className="flex-1"> </p>
<p className="text-sm font-medium">Profile updated</p> ) : (
<p className="text-xs text-muted-foreground"> <div className="space-y-4">
{profile?.updated_at ? new Date(profile.updated_at).toLocaleString() : 'Recently'} {recentActivity.map((activity) => (
</p> <div key={activity.id} className="flex items-start gap-3 pb-4 border-b last:border-0 last:pb-0">
</div> <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">
{formatDate(activity.created_at)}
</p>
</div>
</div>
))}
</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'}
</p>
</div>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </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' } }
);
}
});