Files
thrilltrack-explorer/src-old/components/settings/DataExportTab.tsx

436 lines
15 KiB
TypeScript

import { useState, useEffect } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
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 '@/lib/supabaseClient';
import { handleError, handleSuccess, handleNonCriticalError, AppError } from '@/lib/errorHandler';
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 [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 {
// 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);
} catch (error: unknown) {
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,
created_at,
changed_by,
ip_address_hash,
user_agent,
profile_change_fields(field_name, old_value, new_value)
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(10);
if (error) {
throw error;
}
// Transform the data to match our type
const activityData: ActivityLogEntry[] = (data || []).map(item => {
const changes: Record<string, any> = {};
if (item.profile_change_fields) {
for (const field of item.profile_change_fields) {
changes[field.field_name] = {
old: field.old_value,
new: field.new_value
};
}
}
return {
id: item.id,
action: item.action,
changes,
created_at: item.created_at,
changed_by: item.changed_by,
ip_address_hash: item.ip_address_hash || undefined,
user_agent: item.user_agent || undefined
};
});
setRecentActivity(activityData);
} catch (error: unknown) {
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);
// Call edge function for secure export
const { data, error, requestId } = await invokeWithTracking<ExportRequestResult>(
'export-user-data',
validatedOptions,
user.id
);
if (error) {
throw error;
}
if (!data?.success) {
if (data?.rate_limited) {
setRateLimited(true);
setNextAvailableAt(data.next_available_at || null);
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 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);
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: unknown) {
handleError(error, {
action: 'Export data',
userId: user.id
});
} finally {
setExporting(false);
}
};
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'
});
};
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-6">
{/* Statistics + Recent Activity Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Personal Statistics */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
<CardTitle>Personal Statistics</CardTitle>
</div>
<CardDescription>
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="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="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="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="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>
)}
</CardContent>
</Card>
{/* Account Activity */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="w-5 h-5" />
<CardTitle>Recent Activity</CardTitle>
</div>
<CardDescription>
Recent account activity and changes
</CardDescription>
</CardHeader>
<CardContent>
{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>
)}
</CardContent>
</Card>
</div>
{/* Export Your Data - Full Width */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Download className="w-5 h-5" />
<CardTitle>Export Your Data</CardTitle>
</div>
<CardDescription>
Export all your ThrillWiki data in JSON format. This includes your profile, reviews, lists, and activity history.
</CardDescription>
</CardHeader>
<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>
<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>
);
}