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:
gpt-engineer-app[bot]
2025-11-11 20:35:45 +00:00
parent de921a5fcf
commit 8ee548fd27
3 changed files with 479 additions and 731 deletions

View File

@@ -1,10 +1,7 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'; import { addSpanEvent } from '../_shared/logger.ts';
import { sanitizeError } from '../_shared/errorSanitizer.ts';
import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts';
import { formatEdgeError } from '../_shared/errorFormatter.ts';
interface ExportOptions { interface ExportOptions {
include_reviews: boolean; include_reviews: boolean;
@@ -14,367 +11,249 @@ interface ExportOptions {
format: 'json'; format: 'json';
} }
// Apply strict rate limiting (5 req/min) for expensive data export operations const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => {
// This prevents abuse and manages server load from large data exports addSpanEvent(span, 'processing_export_request', { userId: user.id });
serve(withRateLimit(async (req) => {
const tracking = startRequest(); // Additional rate limiting - max 1 export per hour
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
// Handle CORS preflight requests const { data: recentExports, error: rateLimitError } = await supabase
if (req.method === 'OPTIONS') { .from('profile_audit_log')
return new Response(null, { .select('created_at')
headers: { .eq('user_id', user.id)
...corsHeaders, .eq('action', 'data_exported')
'X-Request-ID': tracking.requestId .gte('created_at', oneHourAgo)
} .limit(1);
});
if (rateLimitError) {
addSpanEvent(span, 'rate_limit_check_failed', { error: rateLimitError.message });
} }
try { if (recentExports && recentExports.length > 0) {
const supabaseClient = createClient( const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString();
Deno.env.get('SUPABASE_URL') ?? '', addSpanEvent(span, 'rate_limit_exceeded', { nextAvailableAt });
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{ return new Response(
global: { JSON.stringify({
headers: { Authorization: req.headers.get('Authorization')! }, 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 // Parse export options
const { const body = await req.json();
data: { user }, const options: ExportOptions = {
error: authError, include_reviews: body.include_reviews ?? true,
} = await supabaseClient.auth.getUser(); 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) { addSpanEvent(span, 'export_options_parsed', { options });
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
}
}
);
}
edgeLogger.info('Processing export request', { // Fetch profile data
action: 'export_start', const { data: profile, error: profileError } = await supabase
requestId: tracking.requestId, .from('profiles')
userId: user.id .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 if (profileError) {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); addSpanEvent(span, 'profile_fetch_failed', { error: profileError.message });
const { data: recentExports, error: rateLimitError } = await supabaseClient throw new Error('Failed to fetch profile data');
.from('profile_audit_log') }
.select('created_at')
// 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('user_id', user.id)
.eq('action', 'data_exported') .order('created_at', { ascending: false });
.gte('created_at', oneHourAgo)
.limit(1);
if (rateLimitError) { if (!reviewsError && reviewsData) {
edgeLogger.error('Rate limit check failed', { action: 'export_rate_limit', requestId: tracking.requestId, error: rateLimitError }); 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) { // Fetch lists if requested
const duration = endRequest(tracking); let lists = [];
const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString(); if (options.include_lists) {
edgeLogger.warn('Rate limit exceeded for export', { const { data: listsData, error: listsError } = await supabase
action: 'export_rate_limit', .from('user_lists')
requestId: tracking.requestId, .select('id, name, description, is_public, created_at')
userId: user.id, .eq('user_id', user.id)
duration, .order('created_at', { ascending: false });
nextAvailableAt
}); if (!listsError && listsData) {
return new Response( lists = await Promise.all(
JSON.stringify({ listsData.map(async (list) => {
success: false, const { count } = await supabase
error: 'Rate limited. You can export your data once per hour.', .from('user_list_items')
rate_limited: true, .select('*', { count: 'exact', head: true })
next_available_at: nextAvailableAt, .eq('list_id', list.id);
requestId: tracking.requestId
}), return {
{ id: list.id,
status: 429, name: list.name,
headers: { description: list.description,
...corsHeaders, is_public: list.is_public,
'Content-Type': 'application/json', item_count: count || 0,
'X-Request-ID': tracking.requestId created_at: list.created_at
} };
} })
); );
} }
}
// Parse export options // Fetch activity log if requested
const body = await req.json(); let activity_log = [];
const options: ExportOptions = { if (options.include_activity_log) {
include_reviews: body.include_reviews ?? true, const { data: activityData, error: activityError } = await supabase
include_lists: body.include_lists ?? true, .from('profile_audit_log')
include_activity_log: body.include_activity_log ?? true, .select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent')
include_preferences: body.include_preferences ?? true, .eq('user_id', user.id)
format: 'json' .order('created_at', { ascending: false })
}; .limit(100);
edgeLogger.info('Export options', { if (!activityError && activityData) {
action: 'export_options', activity_log = activityData;
requestId: tracking.requestId, }
userId: user.id }
});
// Fetch profile data // Fetch preferences if requested
const { data: profile, error: profileError } = await supabaseClient let preferences = {
.from('profiles') unit_preferences: null,
.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') 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) .eq('user_id', user.id)
.single(); .single();
if (profileError) { if (prefsData) {
edgeLogger.error('Profile fetch failed', { preferences = prefsData;
action: 'export_profile',
requestId: tracking.requestId,
userId: user.id
});
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,
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));

View File

@@ -1,7 +1,7 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { addSpanEvent } from '../_shared/logger.ts';
interface RequestBody { interface RequestBody {
submission_id: string; submission_id: string;
@@ -81,203 +81,151 @@ async function constructEntityURL(
return `${baseURL}`; return `${baseURL}`;
} }
serve(async (req) => { const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => {
if (req.method === 'OPTIONS') { const { submission_id, user_id, submission_type, status, reviewer_notes } = await req.json() as RequestBody;
return new Response(null, { headers: corsHeaders });
addSpanEvent(span, 'notification_request', {
submissionId: submission_id,
userId: user_id,
status
});
// Fetch submission items to get entity data
const { data: items, error: itemsError } = await supabase
.from('submission_items')
.select('item_data')
.eq('submission_id', submission_id)
.order('order_index', { ascending: true })
.limit(1)
.maybeSingle();
if (itemsError) {
throw new Error(`Failed to fetch submission items: ${itemsError.message}`);
} }
const tracking = startRequest('notify-user-submission-status'); if (!items || !items.item_data) {
throw new Error('No submission items found');
}
try { // Extract entity data
const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const entityName = items.item_data.name || 'your submission';
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const entityType = submission_type.replace('_', ' ');
const supabase = createClient(supabaseUrl, supabaseServiceKey); // Construct entity URL
const entityURL = await constructEntityURL(supabase, submission_type, items.item_data);
const { submission_id, user_id, submission_type, status, reviewer_notes } = await req.json() as RequestBody; // Determine workflow and build payload based on status
const workflowId = status === 'approved' ? 'submission-approved' : 'submission-rejected';
// Fetch submission items to get entity data
const { data: items, error: itemsError } = await supabase let payload: Record<string, string>;
.from('submission_items')
.select('item_data') if (status === 'approved') {
.eq('submission_id', submission_id) payload = {
.order('order_index', { ascending: true }) baseUrl: 'https://www.thrillwiki.com',
.limit(1) entityType,
.maybeSingle();
if (itemsError) {
throw new Error(`Failed to fetch submission items: ${itemsError.message}`);
}
if (!items || !items.item_data) {
throw new Error('No submission items found');
}
// Extract entity data
const entityName = items.item_data.name || 'your submission';
const entityType = submission_type.replace('_', ' ');
// Construct entity URL
const entityURL = await constructEntityURL(supabase, submission_type, items.item_data);
// Determine workflow and build payload based on status
const workflowId = status === 'approved' ? 'submission-approved' : 'submission-rejected';
let payload: Record<string, string>;
if (status === 'approved') {
// Approval payload
payload = {
baseUrl: 'https://www.thrillwiki.com',
entityType,
entityName,
submissionId: submission_id,
entityURL,
moderationNotes: reviewer_notes || '',
};
} else {
// Rejection payload
payload = {
baseUrl: 'https://www.thrillwiki.com',
rejectionReason: reviewer_notes || 'No reason provided',
entityType,
entityName,
entityURL,
actualStatus: 'rejected',
};
}
// Generate idempotency key for duplicate prevention
const { data: keyData, error: keyError } = await supabase
.rpc('generate_notification_idempotency_key', {
p_notification_type: `submission_${status}`,
p_entity_id: submission_id,
p_recipient_id: user_id,
});
const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`;
// Check for duplicate within 24h window
const { data: existingLog, error: logCheckError } = await supabase
.from('notification_logs')
.select('id')
.eq('user_id', user_id)
.eq('idempotency_key', idempotencyKey)
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.maybeSingle();
if (existingLog) {
// Duplicate detected - log and skip
await supabase.from('notification_logs').update({
is_duplicate: true
}).eq('id', existingLog.id);
edgeLogger.info('Duplicate notification prevented', {
action: 'notify_user_submission_status',
userId: user_id,
idempotencyKey,
submissionId: submission_id,
requestId: tracking.requestId
});
endRequest(tracking, 200);
return new Response(
JSON.stringify({
success: true,
message: 'Duplicate notification prevented',
idempotencyKey,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 200,
}
);
}
edgeLogger.info('Sending notification to user', {
action: 'notify_user_submission_status',
userId: user_id,
workflowId,
entityName, entityName,
status, submissionId: submission_id,
idempotencyKey, entityURL,
requestId: tracking.requestId moderationNotes: reviewer_notes || '',
}); };
} else {
// Call trigger-notification function payload = {
const { data: notificationResult, error: notificationError } = await supabase.functions.invoke( baseUrl: 'https://www.thrillwiki.com',
'trigger-notification', rejectionReason: reviewer_notes || 'No reason provided',
{ entityType,
body: { entityName,
workflowId, entityURL,
subscriberId: user_id, actualStatus: 'rejected',
payload, };
},
}
);
if (notificationError) {
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
}
// Log notification in notification_logs with idempotency key
await supabase.from('notification_logs').insert({
user_id,
notification_type: `submission_${status}`,
idempotency_key: idempotencyKey,
is_duplicate: false,
metadata: {
submission_id,
submission_type,
transaction_id: notificationResult?.transactionId
}
});
edgeLogger.info('User notification sent successfully', { action: 'notify_user_submission_status', requestId: tracking.requestId, result: notificationResult });
endRequest(tracking, 200);
return new Response(
JSON.stringify({
success: true,
transactionId: notificationResult?.transactionId,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 200,
}
);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
edgeLogger.error('Error notifying user about submission status', { action: 'notify_user_submission_status', requestId: tracking.requestId, error: errorMessage });
endRequest(tracking, 500, errorMessage);
return new Response(
JSON.stringify({
success: false,
error: errorMessage,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 500,
}
);
} }
});
// Generate idempotency key for duplicate prevention
const { data: keyData, error: keyError } = await supabase
.rpc('generate_notification_idempotency_key', {
p_notification_type: `submission_${status}`,
p_entity_id: submission_id,
p_recipient_id: user_id,
});
const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`;
// Check for duplicate within 24h window
const { data: existingLog, error: logCheckError } = await supabase
.from('notification_logs')
.select('id')
.eq('user_id', user_id)
.eq('idempotency_key', idempotencyKey)
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.maybeSingle();
if (existingLog) {
// Duplicate detected - log and skip
await supabase.from('notification_logs').update({
is_duplicate: true
}).eq('id', existingLog.id);
addSpanEvent(span, 'duplicate_notification_prevented', {
idempotencyKey,
submissionId: submission_id
});
return {
success: true,
message: 'Duplicate notification prevented',
idempotencyKey,
};
}
addSpanEvent(span, 'sending_notification', {
workflowId,
entityName,
idempotencyKey
});
// Call trigger-notification function
const { data: notificationResult, error: notificationError } = await supabase.functions.invoke(
'trigger-notification',
{
body: {
workflowId,
subscriberId: user_id,
payload,
},
}
);
if (notificationError) {
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
}
// Log notification in notification_logs with idempotency key
await supabase.from('notification_logs').insert({
user_id,
notification_type: `submission_${status}`,
idempotency_key: idempotencyKey,
is_duplicate: false,
metadata: {
submission_id,
submission_type,
transaction_id: notificationResult?.transactionId
}
});
addSpanEvent(span, 'notification_sent', {
transactionId: notificationResult?.transactionId
});
return {
success: true,
transactionId: notificationResult?.transactionId,
};
};
serve(createEdgeFunction({
name: 'notify-user-submission-status',
requireAuth: false,
useServiceRole: true,
corsHeaders,
enableTracing: true,
logRequests: true,
}, handler));

View File

@@ -1,193 +1,114 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'; import { addSpanEvent } from '../_shared/logger.ts';
import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts';
// Apply moderate rate limiting (10 req/min) to prevent deletion code spam const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => {
// Protects against abuse while allowing legitimate resend requests addSpanEvent(span, 'resending_deletion_code', { userId: user.id });
serve(withRateLimit(async (req) => {
const tracking = startRequest(); // Find pending deletion request
const { data: deletionRequest, error: requestError } = await supabase
if (req.method === 'OPTIONS') { .from('account_deletion_requests')
return new Response(null, { .select('*')
headers: { .eq('user_id', user.id)
...corsHeaders, .eq('status', 'pending')
'X-Request-ID': tracking.requestId .maybeSingle();
}
}); if (requestError || !deletionRequest) {
throw new Error('No pending deletion request found');
} }
try { // Check rate limiting (max 3 resends per hour - ~20 minutes between resends)
const supabaseClient = createClient( const lastSent = new Date(deletionRequest.confirmation_code_sent_at);
Deno.env.get('SUPABASE_URL') ?? '', const now = new Date();
Deno.env.get('SUPABASE_ANON_KEY') ?? '', const hoursSinceLastSend = (now.getTime() - lastSent.getTime()) / (1000 * 60 * 60);
{
global: { if (hoursSinceLastSend < 0.33) {
headers: { Authorization: req.headers.get('Authorization')! }, addSpanEvent(span, 'resend_rate_limited', {
hoursSinceLastSend,
minRequired: 0.33
});
throw new Error('Please wait at least 20 minutes between resend requests');
}
// Generate new confirmation code
const { data: codeData, error: codeError } = await supabase
.rpc('generate_deletion_confirmation_code');
if (codeError) {
throw codeError;
}
const confirmationCode = codeData as string;
// Update deletion request with new code
const { error: updateError } = await supabase
.from('account_deletion_requests')
.update({
confirmation_code: confirmationCode,
confirmation_code_sent_at: now.toISOString(),
})
.eq('id', deletionRequest.id);
if (updateError) {
throw updateError;
}
const scheduledDate = new Date(deletionRequest.scheduled_deletion_at);
addSpanEvent(span, 'new_code_generated', {
scheduledDeletionDate: scheduledDate.toISOString()
});
// Send email with new code
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
if (forwardEmailKey) {
try {
await fetch('https://api.forwardemail.net/v1/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
}, },
} body: JSON.stringify({
); from: fromEmail,
to: user.email,
// Get authenticated user subject: 'Account Deletion - New Confirmation Code',
const { html: `
data: { user }, <h2>New Confirmation Code</h2>
error: userError, <p>You requested a new confirmation code for your account deletion.</p>
} = await supabaseClient.auth.getUser(); <p>Your account will be permanently deleted on <strong>${scheduledDate.toLocaleDateString()}</strong>.</p>
if (userError || !user) { <h3>CONFIRMATION CODE: <strong>${confirmationCode}</strong></h3>
const duration = endRequest(tracking); <p>To confirm deletion after the waiting period, you'll need to enter this 6-digit code.</p>
edgeLogger.error('Authentication failed', {
action: 'resend_deletion_code', <p><strong>Need to cancel?</strong> Log in and visit your account settings to reactivate your account.</p>
requestId: tracking.requestId, `,
duration }),
});
addSpanEvent(span, 'email_sent', { email: user.email });
} catch (emailError) {
addSpanEvent(span, 'email_send_failed', {
error: emailError instanceof Error ? emailError.message : String(emailError)
}); });
// Persist error to database
const authErrorSpan = startSpan('resend-deletion-code-auth-error', 'SERVER');
endSpan(authErrorSpan, 'error', userError);
logSpanToDatabase(authErrorSpan, tracking.requestId);
throw new Error('Unauthorized');
} }
edgeLogger.info('Resending deletion code for user', {
action: 'resend_deletion_code',
requestId: tracking.requestId,
userId: user.id
});
// Find pending deletion request
const { data: deletionRequest, error: requestError } = await supabaseClient
.from('account_deletion_requests')
.select('*')
.eq('user_id', user.id)
.eq('status', 'pending')
.maybeSingle();
if (requestError || !deletionRequest) {
throw new Error('No pending deletion request found');
}
// Check rate limiting (max 3 resends per hour)
const lastSent = new Date(deletionRequest.confirmation_code_sent_at);
const now = new Date();
const hoursSinceLastSend = (now.getTime() - lastSent.getTime()) / (1000 * 60 * 60);
if (hoursSinceLastSend < 0.33) { // ~20 minutes between resends
throw new Error('Please wait at least 20 minutes between resend requests');
}
// Generate new confirmation code
const { data: codeData, error: codeError } = await supabaseClient
.rpc('generate_deletion_confirmation_code');
if (codeError) {
throw codeError;
}
const confirmationCode = codeData as string;
// Update deletion request with new code
const { error: updateError } = await supabaseClient
.from('account_deletion_requests')
.update({
confirmation_code: confirmationCode,
confirmation_code_sent_at: now.toISOString(),
})
.eq('id', deletionRequest.id);
if (updateError) {
throw updateError;
}
const scheduledDate = new Date(deletionRequest.scheduled_deletion_at);
// Send email with new code
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
if (forwardEmailKey) {
try {
await fetch('https://api.forwardemail.net/v1/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
},
body: JSON.stringify({
from: fromEmail,
to: user.email,
subject: 'Account Deletion - New Confirmation Code',
html: `
<h2>New Confirmation Code</h2>
<p>You requested a new confirmation code for your account deletion.</p>
<p>Your account will be permanently deleted on <strong>${scheduledDate.toLocaleDateString()}</strong>.</p>
<h3>CONFIRMATION CODE: <strong>${confirmationCode}</strong></h3>
<p>To confirm deletion after the waiting period, you'll need to enter this 6-digit code.</p>
<p><strong>Need to cancel?</strong> Log in and visit your account settings to reactivate your account.</p>
`,
}),
});
edgeLogger.info('New confirmation code email sent', { requestId: tracking.requestId });
} catch (emailError) {
edgeLogger.error('Failed to send email', {
requestId: tracking.requestId,
error: emailError.message
});
}
}
const duration = endRequest(tracking);
edgeLogger.info('New confirmation code sent successfully', {
action: 'resend_deletion_code',
requestId: tracking.requestId,
userId: user.id,
duration
});
return new Response(
JSON.stringify({
success: true,
message: 'New confirmation code sent successfully',
requestId: tracking.requestId,
}),
{
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
}
);
} catch (error) {
const duration = endRequest(tracking);
edgeLogger.error('Error resending code', {
action: 'resend_deletion_code',
requestId: tracking.requestId,
duration,
error: error.message
});
// Persist error to database for monitoring
const errorSpan = startSpan('resend-deletion-code-error', 'SERVER');
endSpan(errorSpan, 'error', error);
logSpanToDatabase(errorSpan, tracking.requestId);
return new Response(
JSON.stringify({
error: error.message,
requestId: tracking.requestId
}),
{
status: 400,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
}
);
} }
}, rateLimiters.moderate, corsHeaders));
addSpanEvent(span, 'resend_completed', { userId: user.id });
return {
success: true,
message: 'New confirmation code sent successfully',
};
};
serve(createEdgeFunction({
name: 'resend-deletion-code',
requireAuth: true,
corsHeaders,
enableTracing: true,
logRequests: true,
rateLimitTier: 'moderate', // 10 requests per minute
}, handler));