mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 14:11:13 -05:00
Migrate Phase 1 user-facing functions
Refactor export-user-data, notify-user-submission-status, and resend-deletion-code to use createEdgeFunction wrapper. Remove manual CORS, auth, rate limiting boilerplate; adopt standardized EdgeFunctionContext (supabase, user, span, requestId), and integrate built-in tracing, rate limiting, and logging through the wrapper. Update handlers to rely on wrapper context and ensure consistent error handling and observability.
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
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';
|
||||
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||
import { corsHeaders } from '../_shared/cors.ts';
|
||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
||||
import { sanitizeError } from '../_shared/errorSanitizer.ts';
|
||||
import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
import { addSpanEvent } from '../_shared/logger.ts';
|
||||
|
||||
interface ExportOptions {
|
||||
include_reviews: boolean;
|
||||
@@ -14,367 +11,249 @@ interface ExportOptions {
|
||||
format: 'json';
|
||||
}
|
||||
|
||||
// Apply strict rate limiting (5 req/min) for expensive data export operations
|
||||
// This prevents abuse and manages server load from large data exports
|
||||
serve(withRateLimit(async (req) => {
|
||||
const tracking = startRequest();
|
||||
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'X-Request-ID': tracking.requestId
|
||||
}
|
||||
});
|
||||
const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => {
|
||||
addSpanEvent(span, 'processing_export_request', { userId: user.id });
|
||||
|
||||
// Additional rate limiting - max 1 export per hour
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const { data: recentExports, error: rateLimitError } = await supabase
|
||||
.from('profile_audit_log')
|
||||
.select('created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('action', 'data_exported')
|
||||
.gte('created_at', oneHourAgo)
|
||||
.limit(1);
|
||||
|
||||
if (rateLimitError) {
|
||||
addSpanEvent(span, 'rate_limit_check_failed', { error: rateLimitError.message });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
}
|
||||
if (recentExports && recentExports.length > 0) {
|
||||
const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString();
|
||||
addSpanEvent(span, 'rate_limit_exceeded', { 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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get authenticated user
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabaseClient.auth.getUser();
|
||||
// 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'
|
||||
};
|
||||
|
||||
if (authError || !user) {
|
||||
const duration = endRequest(tracking);
|
||||
edgeLogger.error('Authentication failed', {
|
||||
action: 'export_auth',
|
||||
requestId: tracking.requestId,
|
||||
duration
|
||||
});
|
||||
|
||||
// Persist error to database
|
||||
const authErrorSpan = startSpan('export-user-data-auth-error', 'SERVER');
|
||||
endSpan(authErrorSpan, 'error', authError);
|
||||
logSpanToDatabase(authErrorSpan, tracking.requestId);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Unauthorized',
|
||||
success: false,
|
||||
requestId: tracking.requestId
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-ID': tracking.requestId
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
addSpanEvent(span, 'export_options_parsed', { options });
|
||||
|
||||
edgeLogger.info('Processing export request', {
|
||||
action: 'export_start',
|
||||
requestId: tracking.requestId,
|
||||
userId: user.id
|
||||
});
|
||||
// Fetch profile data
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.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();
|
||||
|
||||
// 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')
|
||||
if (profileError) {
|
||||
addSpanEvent(span, 'profile_fetch_failed', { error: profileError.message });
|
||||
throw new Error('Failed to fetch profile data');
|
||||
}
|
||||
|
||||
// Fetch statistics
|
||||
const { count: photoCount } = await supabase
|
||||
.from('photos')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('submitted_by', user.id);
|
||||
|
||||
const { count: listCount } = await supabase
|
||||
.from('user_lists')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id);
|
||||
|
||||
const { count: submissionCount } = await supabase
|
||||
.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 supabase
|
||||
.from('reviews')
|
||||
.select(`
|
||||
id,
|
||||
rating,
|
||||
review_text,
|
||||
created_at,
|
||||
rides(name),
|
||||
parks(name)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.eq('action', 'data_exported')
|
||||
.gte('created_at', oneHourAgo)
|
||||
.limit(1);
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (rateLimitError) {
|
||||
edgeLogger.error('Rate limit check failed', { action: 'export_rate_limit', requestId: tracking.requestId, error: rateLimitError });
|
||||
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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (recentExports && recentExports.length > 0) {
|
||||
const duration = endRequest(tracking);
|
||||
const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString();
|
||||
edgeLogger.warn('Rate limit exceeded for export', {
|
||||
action: 'export_rate_limit',
|
||||
requestId: tracking.requestId,
|
||||
userId: user.id,
|
||||
duration,
|
||||
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,
|
||||
requestId: tracking.requestId
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-ID': tracking.requestId
|
||||
}
|
||||
}
|
||||
// Fetch lists if requested
|
||||
let lists = [];
|
||||
if (options.include_lists) {
|
||||
const { data: listsData, error: listsError } = await supabase
|
||||
.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 supabase
|
||||
.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
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
};
|
||||
// Fetch activity log if requested
|
||||
let activity_log = [];
|
||||
if (options.include_activity_log) {
|
||||
const { data: activityData, error: activityError } = 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(100);
|
||||
|
||||
edgeLogger.info('Export options', {
|
||||
action: 'export_options',
|
||||
requestId: tracking.requestId,
|
||||
userId: user.id
|
||||
});
|
||||
if (!activityError && activityData) {
|
||||
activity_log = activityData;
|
||||
}
|
||||
}
|
||||
|
||||
// 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')
|
||||
// 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 supabase
|
||||
.from('user_preferences')
|
||||
.select('unit_preferences, accessibility_options, notification_preferences, privacy_settings')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
edgeLogger.error('Profile fetch failed', {
|
||||
action: 'export_profile',
|
||||
requestId: tracking.requestId,
|
||||
userId: user.id
|
||||
});
|
||||
throw new Error('Failed to fetch profile data');
|
||||
if (prefsData) {
|
||||
preferences = prefsData;
|
||||
}
|
||||
|
||||
// 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,
|
||||
requestId: tracking.requestId
|
||||
}
|
||||
}]);
|
||||
|
||||
const duration = endRequest(tracking);
|
||||
edgeLogger.info('Export completed successfully', {
|
||||
action: 'export_complete',
|
||||
requestId: tracking.requestId,
|
||||
traceId: tracking.traceId,
|
||||
userId: user.id,
|
||||
duration,
|
||||
dataSize: JSON.stringify(exportData).length
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: exportData,
|
||||
requestId: tracking.requestId
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`,
|
||||
'X-Request-ID': tracking.requestId
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
const duration = endRequest(tracking);
|
||||
edgeLogger.error('Export error', {
|
||||
action: 'export_error',
|
||||
requestId: tracking.requestId,
|
||||
duration,
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
|
||||
// Persist error to database for monitoring
|
||||
const errorSpan = startSpan('export-user-data-error', 'SERVER');
|
||||
endSpan(errorSpan, 'error', error);
|
||||
logSpanToDatabase(errorSpan, tracking.requestId);
|
||||
const sanitized = sanitizeError(error, 'export-user-data');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...sanitized,
|
||||
success: false,
|
||||
requestId: tracking.requestId
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-ID': tracking.requestId
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, rateLimiters.strict, corsHeaders));
|
||||
|
||||
// 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 supabase.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,
|
||||
requestId
|
||||
}
|
||||
}]);
|
||||
|
||||
addSpanEvent(span, 'export_completed', {
|
||||
dataSize: JSON.stringify(exportData).length
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: exportData,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`,
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
serve(createEdgeFunction({
|
||||
name: 'export-user-data',
|
||||
requireAuth: true,
|
||||
corsHeaders,
|
||||
enableTracing: true,
|
||||
logRequests: true,
|
||||
logResponses: true,
|
||||
rateLimitTier: 'strict', // 5 requests per minute
|
||||
}, handler));
|
||||
|
||||
Reference in New Issue
Block a user