mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 01:31:12 -05:00
436 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|