mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 10:51:12 -05:00
Refactor: Implement Data & Export tab modernization
This commit is contained in:
@@ -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']
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
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 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>
|
||||
<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}
|
||||
{statistics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<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>
|
||||
|
||||
<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">Rides Tracked</p>
|
||||
<p className="text-2xl font-bold">{statistics.ride_count}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Rides</div>
|
||||
</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">Coasters</p>
|
||||
<p className="text-2xl font-bold">{statistics.coaster_count}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Coasters</div>
|
||||
</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">Parks Visited</p>
|
||||
<p className="text-2xl font-bold">{statistics.park_count}</p>
|
||||
</div>
|
||||
<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="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 className="text-sm text-muted-foreground">Parks</div>
|
||||
</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>
|
||||
<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">
|
||||
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>
|
||||
|
||||
{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>}
|
||||
<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={exportLoading} className="w-fit">
|
||||
<Button
|
||||
onClick={handleDataExport}
|
||||
disabled={exporting || rateLimited}
|
||||
className="w-full"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{exportLoading ? 'Exporting...' : 'Export My Data'}
|
||||
{exporting ? 'Exporting Data...' : 'Export My Data'}
|
||||
</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>
|
||||
</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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{profile?.updated_at ? new Date(profile.updated_at).toLocaleString() : 'Recently'}
|
||||
</p>
|
||||
</div>
|
||||
{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">
|
||||
{formatDate(activity.created_at)}
|
||||
</p>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/lib/dataExportValidation.ts
Normal file
121
src/lib/dataExportValidation.ts
Normal 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
135
src/types/data-export.ts
Normal 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;
|
||||
}
|
||||
289
supabase/functions/export-user-data/index.ts
Normal file
289
supabase/functions/export-user-data/index.ts
Normal 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' } }
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user