diff --git a/supabase/functions/merge-contact-tickets/index.ts b/supabase/functions/merge-contact-tickets/index.ts index fc5a13df..833c7424 100644 --- a/supabase/functions/merge-contact-tickets/index.ts +++ b/supabase/functions/merge-contact-tickets/index.ts @@ -1,8 +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 { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts'; -import { createErrorResponse, sanitizeError } from '../_shared/errorSanitizer.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; interface MergeTicketsRequest { primaryTicketId: string; @@ -18,273 +17,189 @@ interface MergeTicketsResponse { deletedTickets: string[]; } -serve(async (req) => { - const tracking = startRequest(); +const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => { + // Parse request body + const { primaryTicketId, mergeTicketIds, mergeReason }: MergeTicketsRequest = await req.json(); - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); + // Validation + if (!primaryTicketId || !mergeTicketIds || mergeTicketIds.length === 0) { + throw new Error('Invalid request: primaryTicketId and mergeTicketIds required'); } - try { - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - throw new Error('Missing authorization header'); - } - - const supabase = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_ANON_KEY') ?? '', - { global: { headers: { Authorization: authHeader } } } - ); - - // Authenticate user - const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError || !user) { - throw new Error('Unauthorized'); - } - - edgeLogger.info('Merge tickets request started', { - requestId: tracking.requestId, - userId: user.id, - }); - - // Check if user has moderator/admin role - const { data: hasRole, error: roleError } = await supabase.rpc('has_role', { - _user_id: user.id, - _role: 'moderator' - }); - - const { data: isAdmin, error: adminError } = await supabase.rpc('has_role', { - _user_id: user.id, - _role: 'admin' - }); - - const { data: isSuperuser, error: superuserError } = await supabase.rpc('has_role', { - _user_id: user.id, - _role: 'superuser' - }); - - if (roleError || adminError || superuserError || (!hasRole && !isAdmin && !isSuperuser)) { - throw new Error('Insufficient permissions. Moderator role required.'); - } - - // Parse request body - const { primaryTicketId, mergeTicketIds, mergeReason }: MergeTicketsRequest = await req.json(); - - // Validation - if (!primaryTicketId || !mergeTicketIds || mergeTicketIds.length === 0) { - throw new Error('Invalid request: primaryTicketId and mergeTicketIds required'); - } - - if (mergeTicketIds.includes(primaryTicketId)) { - throw new Error('Cannot merge a ticket into itself'); - } - - if (mergeTicketIds.length > 10) { - throw new Error('Maximum 10 tickets can be merged at once'); - } - - // Start transaction-like operations - const allTicketIds = [primaryTicketId, ...mergeTicketIds]; - - // Fetch all tickets - const { data: tickets, error: fetchError } = await supabase - .from('contact_submissions') - .select('id, ticket_number, admin_notes, merged_ticket_numbers') - .in('id', allTicketIds); - - if (fetchError) throw fetchError; - if (!tickets || tickets.length !== allTicketIds.length) { - throw new Error('One or more tickets not found'); - } - - const primaryTicket = tickets.find(t => t.id === primaryTicketId); - const mergeTickets = tickets.filter(t => mergeTicketIds.includes(t.id)); - - if (!primaryTicket) { - throw new Error('Primary ticket not found'); - } - - // Check if any ticket already has merged_ticket_numbers (prevent re-merging) - const alreadyMerged = tickets.find(t => - t.merged_ticket_numbers && t.merged_ticket_numbers.length > 0 - ); - if (alreadyMerged) { - throw new Error(`Ticket ${alreadyMerged.ticket_number} has already been used in a merge`); - } - - edgeLogger.info('Starting merge process', { - requestId: tracking.requestId, - primaryTicket: primaryTicket.ticket_number, - mergeTicketCount: mergeTickets.length, - }); - - // Step 1: Move all email threads to primary ticket - edgeLogger.info('Step 1: Moving email threads', { - requestId: tracking.requestId, - fromTickets: mergeTickets.map(t => t.ticket_number) - }); - - const { data: movedThreads, error: moveError } = await supabase - .from('contact_email_threads') - .update({ submission_id: primaryTicketId }) - .in('submission_id', mergeTicketIds) - .select('id'); - - if (moveError) throw moveError; - - const threadsMovedCount = movedThreads?.length || 0; - - edgeLogger.info('Threads moved successfully', { - requestId: tracking.requestId, - threadsMovedCount - }); - - if (threadsMovedCount === 0) { - edgeLogger.warn('No email threads found to move', { - requestId: tracking.requestId, - mergeTicketIds - }); - } - - // Step 2: Consolidate admin notes - edgeLogger.info('Step 2: Consolidating admin notes', { requestId: tracking.requestId }); - - let consolidatedNotes = primaryTicket.admin_notes || ''; - - for (const ticket of mergeTickets) { - if (ticket.admin_notes) { - consolidatedNotes = consolidatedNotes.trim() - ? `${consolidatedNotes}\n\n${ticket.admin_notes}` - : ticket.admin_notes; - } - } - - // Step 3: Recalculate metadata from consolidated threads - edgeLogger.info('Step 3: Recalculating metadata from threads', { requestId: tracking.requestId }); - - const { data: threadStats, error: statsError } = await supabase - .from('contact_email_threads') - .select('direction, created_at') - .eq('submission_id', primaryTicketId); - - if (statsError) throw statsError; - - const outboundCount = threadStats?.filter(t => t.direction === 'outbound').length || 0; - const lastAdminResponse = threadStats - ?.filter(t => t.direction === 'outbound') - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at; - const lastUserResponse = threadStats - ?.filter(t => t.direction === 'inbound') - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at; - - edgeLogger.info('Metadata recalculated', { - requestId: tracking.requestId, - outboundCount, - lastAdminResponse, - lastUserResponse - }); - - // Get merged ticket numbers - const mergedTicketNumbers = mergeTickets.map(t => t.ticket_number); - - // Step 4: Update primary ticket with consolidated data - edgeLogger.info('Step 4: Updating primary ticket', { requestId: tracking.requestId }); - - const { error: updateError } = await supabase - .from('contact_submissions') - .update({ - admin_notes: consolidatedNotes, - response_count: outboundCount, - last_admin_response_at: lastAdminResponse || null, - merged_ticket_numbers: [ - ...(primaryTicket.merged_ticket_numbers || []), - ...mergedTicketNumbers - ], - updated_at: new Date().toISOString(), - }) - .eq('id', primaryTicketId); - - if (updateError) throw updateError; - - edgeLogger.info('Primary ticket updated successfully', { requestId: tracking.requestId }); - - // Step 5: Delete merged tickets - edgeLogger.info('Step 5: Deleting merged tickets', { - requestId: tracking.requestId, - ticketsToDelete: mergeTicketIds.length - }); - - const { error: deleteError } = await supabase - .from('contact_submissions') - .delete() - .in('id', mergeTicketIds); - - if (deleteError) throw deleteError; - - edgeLogger.info('Merged tickets deleted successfully', { requestId: tracking.requestId }); - - // Step 6: Audit log - edgeLogger.info('Step 6: Creating audit log', { requestId: tracking.requestId }); - - const { error: auditError } = await supabase.from('admin_audit_log').insert({ - admin_user_id: user.id, - target_user_id: user.id, // No specific target user for this action - action: 'merge_contact_tickets', - details: { - primary_ticket_id: primaryTicketId, - primary_ticket_number: primaryTicket.ticket_number, - merged_ticket_ids: mergeTicketIds, - merged_ticket_numbers: mergedTicketNumbers, - merge_reason: mergeReason || null, - threads_moved: threadsMovedCount, - merged_count: mergeTickets.length, - } - }); - - if (auditError) { - edgeLogger.warn('Failed to create audit log for merge', { - requestId: tracking.requestId, - error: auditError.message, - primaryTicket: primaryTicket.ticket_number - }); - // Don't throw - merge already succeeded - } - - const duration = endRequest(tracking); - edgeLogger.info('Merge tickets completed successfully', { - requestId: tracking.requestId, - duration, - primaryTicket: primaryTicket.ticket_number, - mergedCount: mergeTickets.length, - }); - - const response: MergeTicketsResponse = { - success: true, - primaryTicketNumber: primaryTicket.ticket_number, - mergedCount: mergeTickets.length, - threadsConsolidated: threadsMovedCount, - deletedTickets: mergedTicketNumbers, - }; - - return new Response(JSON.stringify(response), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - }); - - } catch (error) { - const duration = endRequest(tracking); - edgeLogger.error('Merge tickets failed', { - requestId: tracking.requestId, - duration, - error: error instanceof Error ? error.message : 'Unknown error', - }); - - // Persist error to database for monitoring - const errorSpan = startSpan('merge-contact-tickets-error', 'SERVER'); - endSpan(errorSpan, 'error', error); - logSpanToDatabase(errorSpan, tracking.requestId); - - return createErrorResponse(error, 500, corsHeaders, 'merge_contact_tickets'); + if (mergeTicketIds.includes(primaryTicketId)) { + throw new Error('Cannot merge a ticket into itself'); } -}); + + if (mergeTicketIds.length > 10) { + throw new Error('Maximum 10 tickets can be merged at once'); + } + + addSpanEvent(span, 'merge_tickets_started', { + primaryTicketId, + mergeCount: mergeTicketIds.length + }); + + // Start transaction-like operations + const allTicketIds = [primaryTicketId, ...mergeTicketIds]; + + // Fetch all tickets + const { data: tickets, error: fetchError } = await supabase + .from('contact_submissions') + .select('id, ticket_number, admin_notes, merged_ticket_numbers') + .in('id', allTicketIds); + + if (fetchError) throw fetchError; + if (!tickets || tickets.length !== allTicketIds.length) { + throw new Error('One or more tickets not found'); + } + + const primaryTicket = tickets.find(t => t.id === primaryTicketId); + const mergeTickets = tickets.filter(t => mergeTicketIds.includes(t.id)); + + if (!primaryTicket) { + throw new Error('Primary ticket not found'); + } + + // Check if any ticket already has merged_ticket_numbers + const alreadyMerged = tickets.find(t => + t.merged_ticket_numbers && t.merged_ticket_numbers.length > 0 + ); + if (alreadyMerged) { + throw new Error(`Ticket ${alreadyMerged.ticket_number} has already been used in a merge`); + } + + addSpanEvent(span, 'tickets_validated', { + primaryTicket: primaryTicket.ticket_number, + mergeTicketCount: mergeTickets.length + }); + + // Step 1: Move all email threads to primary ticket + addSpanEvent(span, 'moving_email_threads', { + fromTickets: mergeTickets.map(t => t.ticket_number) + }); + + const { data: movedThreads, error: moveError } = await supabase + .from('contact_email_threads') + .update({ submission_id: primaryTicketId }) + .in('submission_id', mergeTicketIds) + .select('id'); + + if (moveError) throw moveError; + + const threadsMovedCount = movedThreads?.length || 0; + + addSpanEvent(span, 'threads_moved', { threadsMovedCount }); + + if (threadsMovedCount === 0) { + addSpanEvent(span, 'no_threads_found', { mergeTicketIds }); + } + + // Step 2: Consolidate admin notes + let consolidatedNotes = primaryTicket.admin_notes || ''; + + for (const ticket of mergeTickets) { + if (ticket.admin_notes) { + consolidatedNotes = consolidatedNotes.trim() + ? `${consolidatedNotes}\n\n${ticket.admin_notes}` + : ticket.admin_notes; + } + } + + // Step 3: Recalculate metadata from consolidated threads + const { data: threadStats, error: statsError } = await supabase + .from('contact_email_threads') + .select('direction, created_at') + .eq('submission_id', primaryTicketId); + + if (statsError) throw statsError; + + const outboundCount = threadStats?.filter(t => t.direction === 'outbound').length || 0; + const lastAdminResponse = threadStats + ?.filter(t => t.direction === 'outbound') + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at; + const lastUserResponse = threadStats + ?.filter(t => t.direction === 'inbound') + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at; + + addSpanEvent(span, 'metadata_recalculated', { + outboundCount, + lastAdminResponse, + lastUserResponse + }); + + // Get merged ticket numbers + const mergedTicketNumbers = mergeTickets.map(t => t.ticket_number); + + // Step 4: Update primary ticket with consolidated data + const { error: updateError } = await supabase + .from('contact_submissions') + .update({ + admin_notes: consolidatedNotes, + response_count: outboundCount, + last_admin_response_at: lastAdminResponse || null, + merged_ticket_numbers: [ + ...(primaryTicket.merged_ticket_numbers || []), + ...mergedTicketNumbers + ], + updated_at: new Date().toISOString(), + }) + .eq('id', primaryTicketId); + + if (updateError) throw updateError; + + addSpanEvent(span, 'primary_ticket_updated', { primaryTicket: primaryTicket.ticket_number }); + + // Step 5: Delete merged tickets + const { error: deleteError } = await supabase + .from('contact_submissions') + .delete() + .in('id', mergeTicketIds); + + if (deleteError) throw deleteError; + + addSpanEvent(span, 'merged_tickets_deleted', { count: mergeTicketIds.length }); + + // Step 6: Audit log + const { error: auditError } = await supabase.from('admin_audit_log').insert({ + admin_user_id: user.id, + target_user_id: user.id, + action: 'merge_contact_tickets', + details: { + primary_ticket_id: primaryTicketId, + primary_ticket_number: primaryTicket.ticket_number, + merged_ticket_ids: mergeTicketIds, + merged_ticket_numbers: mergedTicketNumbers, + merge_reason: mergeReason || null, + threads_moved: threadsMovedCount, + merged_count: mergeTickets.length, + } + }); + + if (auditError) { + addSpanEvent(span, 'audit_log_failed', { error: auditError.message }); + } + + addSpanEvent(span, 'merge_completed', { + primaryTicket: primaryTicket.ticket_number, + mergedCount: mergeTickets.length + }); + + const response: MergeTicketsResponse = { + success: true, + primaryTicketNumber: primaryTicket.ticket_number, + mergedCount: mergeTickets.length, + threadsConsolidated: threadsMovedCount, + deletedTickets: mergedTicketNumbers, + }; + + return response; +}; + +serve(createEdgeFunction({ + name: 'merge-contact-tickets', + requireAuth: true, + requiredRoles: ['superuser', 'admin', 'moderator'], + corsHeaders, + enableTracing: true, + logRequests: true, +}, handler)); diff --git a/supabase/functions/notify-moderators-report/index.ts b/supabase/functions/notify-moderators-report/index.ts index 841f82df..cd692561 100644 --- a/supabase/functions/notify-moderators-report/index.ts +++ b/supabase/functions/notify-moderators-report/index.ts @@ -1,7 +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 { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; +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 { addSpanEvent } from '../_shared/logger.ts'; import { withEdgeRetry } from '../_shared/retryHelper.ts'; interface NotificationPayload { @@ -15,204 +15,166 @@ interface NotificationPayload { entityPreview: string; } -serve(async (req) => { - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); +const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => { + const payload: NotificationPayload = await req.json(); + + addSpanEvent(span, 'processing_report_notification', { + reportId: payload.reportId, + reportType: payload.reportType, + reportedEntityType: payload.reportedEntityType + }); + + // Calculate relative time + const reportedAt = new Date(payload.reportedAt); + const now = new Date(); + const diffMs = now.getTime() - reportedAt.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + let relativeTime: string; + if (diffMins < 1) { + relativeTime = 'just now'; + } else if (diffMins < 60) { + relativeTime = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; + } else { + const diffHours = Math.floor(diffMins / 60); + relativeTime = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; } - const tracking = startRequest('notify-moderators-report'); + // Determine priority based on report type and age + let priority: string; + const criticalTypes = ['harassment', 'offensive']; + const isUrgent = diffMins < 5; + + if (criticalTypes.includes(payload.reportType) || isUrgent) { + priority = 'high'; + } else if (diffMins < 30) { + priority = 'medium'; + } else { + priority = 'low'; + } - try { - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - - const supabase = createClient(supabaseUrl, supabaseServiceKey); + // Fetch the workflow ID for report alerts + const { data: template, error: templateError } = await supabase + .from('notification_templates') + .select('workflow_id') + .eq('workflow_id', 'report-alert') + .eq('is_active', true) + .maybeSingle(); - const payload: NotificationPayload = await req.json(); - - edgeLogger.info('Processing report notification', { - action: 'notify_moderators_report', - reportId: payload.reportId, - reportType: payload.reportType, - reportedEntityType: payload.reportedEntityType, - requestId: tracking.requestId - }); - - // Calculate relative time - const reportedAt = new Date(payload.reportedAt); - const now = new Date(); - const diffMs = now.getTime() - reportedAt.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - let relativeTime: string; - if (diffMins < 1) { - relativeTime = 'just now'; - } else if (diffMins < 60) { - relativeTime = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; - } else { - const diffHours = Math.floor(diffMins / 60); - relativeTime = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; - } - - // Determine priority based on report type and age - let priority: string; - const criticalTypes = ['harassment', 'offensive']; - const isUrgent = diffMins < 5; - - if (criticalTypes.includes(payload.reportType) || isUrgent) { - priority = 'high'; - } else if (diffMins < 30) { - priority = 'medium'; - } else { - priority = 'low'; - } - - // Fetch the workflow ID for report alerts - const { data: template, error: templateError } = await supabase - .from('notification_templates') - .select('workflow_id') - .eq('workflow_id', 'report-alert') - .eq('is_active', true) - .maybeSingle(); - - if (templateError) { - edgeLogger.error('Error fetching workflow', { action: 'notify_moderators_report', requestId: tracking.requestId, error: templateError }); - throw new Error(`Failed to fetch workflow: ${templateError.message}`); - } - - if (!template) { - edgeLogger.warn('No active report-alert workflow found', { action: 'notify_moderators_report', requestId: tracking.requestId }); - return new Response( - JSON.stringify({ - success: false, - error: 'No active report-alert workflow configured', - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 400, - } - ); - } - - // Fetch reported entity name - let reportedEntityName = 'Unknown'; - - try { - if (payload.reportedEntityType === 'review') { - const { data: review } = await supabase - .from('reviews') - .select('ride:rides(name), park:parks(name)') - .eq('id', payload.reportedEntityId) - .maybeSingle(); - - reportedEntityName = review?.ride?.name || review?.park?.name || 'Review'; - } else if (payload.reportedEntityType === 'profile') { - const { data: profile } = await supabase - .from('profiles') - .select('display_name, username') - .eq('user_id', payload.reportedEntityId) - .maybeSingle(); - - reportedEntityName = profile?.display_name || profile?.username || 'User Profile'; - } else if (payload.reportedEntityType === 'content_submission') { - // Query submission_metadata table for the name instead of dropped content JSONB column - const { data: metadata } = await supabase - .from('submission_metadata') - .select('metadata_value') - .eq('submission_id', payload.reportedEntityId) - .eq('metadata_key', 'name') - .maybeSingle(); - - reportedEntityName = metadata?.metadata_value || 'Submission'; - } - } catch (error) { - edgeLogger.warn('Could not fetch entity name', { action: 'notify_moderators_report', requestId: tracking.requestId, error }); - } - - // Build enhanced notification payload - const notificationPayload = { - baseUrl: 'https://www.thrillwiki.com', - reportId: payload.reportId, - reportType: payload.reportType, - reportedEntityType: payload.reportedEntityType, - reportedEntityId: payload.reportedEntityId, - reporterName: payload.reporterName, - reason: payload.reason, - entityPreview: payload.entityPreview, - reportedEntityName, - reportedAt: payload.reportedAt, - relativeTime, - priority, - }; - - edgeLogger.info('Triggering notification with payload', { action: 'notify_moderators_report', requestId: tracking.requestId }); - - // Invoke the trigger-notification function with retry - const result = await withEdgeRetry( - async () => { - const { data, error } = await supabase.functions.invoke( - 'trigger-notification', - { - body: { - workflowId: template.workflow_id, - topicKey: 'moderation-reports', - payload: notificationPayload, - }, - } - ); - - if (error) { - const enhancedError = new Error(error.message || 'Notification trigger failed'); - (enhancedError as any).status = error.status; - throw enhancedError; - } - - return data; - }, - { maxAttempts: 3, baseDelay: 1000 }, - tracking.requestId, - 'trigger-report-notification' - ); - - edgeLogger.info('Notification triggered successfully', { action: 'notify_moderators_report', requestId: tracking.requestId, result }); - - endRequest(tracking, 200); - - return new Response( - JSON.stringify({ - success: true, - transactionId: result?.transactionId, - payload: notificationPayload, - requestId: tracking.requestId - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 200, - } - ); - } catch (error: any) { - edgeLogger.error('Error in notify-moderators-report', { action: 'notify_moderators_report', requestId: tracking.requestId, error: error.message }); - - endRequest(tracking, 500, error.message); + if (templateError) { + addSpanEvent(span, 'workflow_fetch_failed', { error: templateError.message }); + throw new Error(`Failed to fetch workflow: ${templateError.message}`); + } + if (!template) { + addSpanEvent(span, 'no_active_workflow', {}); return new Response( JSON.stringify({ success: false, - error: error.message, - requestId: tracking.requestId + error: 'No active report-alert workflow configured', }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 500, - } + { status: 400 } ); } -}); + + // Fetch reported entity name + let reportedEntityName = 'Unknown'; + + try { + if (payload.reportedEntityType === 'review') { + const { data: review } = await supabase + .from('reviews') + .select('ride:rides(name), park:parks(name)') + .eq('id', payload.reportedEntityId) + .maybeSingle(); + + reportedEntityName = review?.ride?.name || review?.park?.name || 'Review'; + } else if (payload.reportedEntityType === 'profile') { + const { data: profile } = await supabase + .from('profiles') + .select('display_name, username') + .eq('user_id', payload.reportedEntityId) + .maybeSingle(); + + reportedEntityName = profile?.display_name || profile?.username || 'User Profile'; + } else if (payload.reportedEntityType === 'content_submission') { + const { data: metadata } = await supabase + .from('submission_metadata') + .select('metadata_value') + .eq('submission_id', payload.reportedEntityId) + .eq('metadata_key', 'name') + .maybeSingle(); + + reportedEntityName = metadata?.metadata_value || 'Submission'; + } + } catch (error) { + addSpanEvent(span, 'entity_name_fetch_failed', { + error: error instanceof Error ? error.message : String(error) + }); + } + + // Build enhanced notification payload + const notificationPayload = { + baseUrl: 'https://www.thrillwiki.com', + reportId: payload.reportId, + reportType: payload.reportType, + reportedEntityType: payload.reportedEntityType, + reportedEntityId: payload.reportedEntityId, + reporterName: payload.reporterName, + reason: payload.reason, + entityPreview: payload.entityPreview, + reportedEntityName, + reportedAt: payload.reportedAt, + relativeTime, + priority, + }; + + addSpanEvent(span, 'triggering_notification', { + workflowId: template.workflow_id, + priority + }); + + // Invoke the trigger-notification function with retry + const result = await withEdgeRetry( + async () => { + const { data, error } = await supabase.functions.invoke( + 'trigger-notification', + { + body: { + workflowId: template.workflow_id, + topicKey: 'moderation-reports', + payload: notificationPayload, + }, + } + ); + + if (error) { + const enhancedError = new Error(error.message || 'Notification trigger failed'); + (enhancedError as any).status = error.status; + throw enhancedError; + } + + return data; + }, + { maxAttempts: 3, baseDelay: 1000 }, + requestId, + 'trigger-report-notification' + ); + + addSpanEvent(span, 'notification_sent', { transactionId: result?.transactionId }); + + return { + success: true, + transactionId: result?.transactionId, + payload: notificationPayload, + }; +}; + +serve(createEdgeFunction({ + name: 'notify-moderators-report', + requireAuth: false, + useServiceRole: true, + corsHeaders, + enableTracing: true, + logRequests: true, +}, handler)); diff --git a/supabase/functions/notify-moderators-submission/index.ts b/supabase/functions/notify-moderators-submission/index.ts index 3c045525..0afe2744 100644 --- a/supabase/functions/notify-moderators-submission/index.ts +++ b/supabase/functions/notify-moderators-submission/index.ts @@ -1,7 +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 { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; import { withEdgeRetry } from '../_shared/retryHelper.ts'; interface NotificationPayload { @@ -16,275 +16,177 @@ interface NotificationPayload { is_escalated: boolean; } -serve(async (req) => { - const tracking = startRequest(); +const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => { + const payload: NotificationPayload = await req.json(); + const { + submission_id, + submission_type, + submitter_name, + action, + content_preview, + submitted_at, + has_photos, + item_count, + is_escalated + } = payload; + + addSpanEvent(span, 'processing_moderator_notification', { + submission_id, + submission_type + }); + + // Calculate relative time and priority + const submittedDate = new Date(submitted_at); + const now = new Date(); + const waitTimeMs = now.getTime() - submittedDate.getTime(); + const waitTimeHours = waitTimeMs / (1000 * 60 * 60); - if (req.method === 'OPTIONS') { - return new Response(null, { - headers: { - ...corsHeaders, - 'X-Request-ID': tracking.requestId - } - }); + // Format relative time + const relativeTime = (() => { + const minutes = Math.floor(waitTimeMs / (1000 * 60)); + const hours = Math.floor(waitTimeMs / (1000 * 60 * 60)); + const days = Math.floor(waitTimeMs / (1000 * 60 * 60 * 24)); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; + if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; + return `${days} day${days !== 1 ? 's' : ''} ago`; + })(); + + // Determine priority based on wait time + const priority = waitTimeHours >= 24 ? 'urgent' : 'normal'; + + // Get the moderation-alert workflow + const { data: workflow, error: workflowError } = await supabase + .from('notification_templates') + .select('workflow_id') + .eq('category', 'moderation') + .eq('is_active', true) + .single(); + + if (workflowError || !workflow) { + addSpanEvent(span, 'workflow_fetch_failed', { error: workflowError?.message }); + throw new Error('Workflow not found or not active'); } - try { - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - const payload: NotificationPayload = await req.json(); - const { - submission_id, - submission_type, - submitter_name, - action, - content_preview, - submitted_at, - has_photos, - item_count, - is_escalated - } = payload; - - edgeLogger.info('Notifying moderators about submission via topic', { - action: 'notify_moderators', - requestId: tracking.requestId, - submission_id, - submission_type, - content_preview + // Generate idempotency key for duplicate prevention + const { data: keyData, error: keyError } = await supabase + .rpc('generate_notification_idempotency_key', { + p_notification_type: 'moderation_submission', + p_entity_id: submission_id, + p_recipient_id: '00000000-0000-0000-0000-000000000000', + p_event_data: { submission_type, action } }); - // Calculate relative time and priority - const submittedDate = new Date(submitted_at); - const now = new Date(); - const waitTimeMs = now.getTime() - submittedDate.getTime(); - const waitTimeHours = waitTimeMs / (1000 * 60 * 60); - - // Format relative time - const relativeTime = (() => { - const minutes = Math.floor(waitTimeMs / (1000 * 60)); - const hours = Math.floor(waitTimeMs / (1000 * 60 * 60)); - const days = Math.floor(waitTimeMs / (1000 * 60 * 60 * 24)); - - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; - if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; - return `${days} day${days !== 1 ? 's' : ''} ago`; - })(); - - // Determine priority based on wait time - const priority = waitTimeHours >= 24 ? 'urgent' : 'normal'; + const idempotencyKey = keyData || `mod_sub_${submission_id}_${Date.now()}`; - // Get the moderation-alert workflow - const { data: workflow, error: workflowError } = await supabase - .from('notification_templates') - .select('workflow_id') - .eq('category', 'moderation') - .eq('is_active', true) - .single(); + // Check for duplicate within 24h window + const { data: existingLog, error: logCheckError } = await supabase + .from('notification_logs') + .select('id') + .eq('idempotency_key', idempotencyKey) + .gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()) + .maybeSingle(); - if (workflowError || !workflow) { - const duration = endRequest(tracking); - edgeLogger.error('Error fetching workflow', { - action: 'notify_moderators', - requestId: tracking.requestId, - duration, - error: workflowError - }); - return new Response( - JSON.stringify({ - success: false, - error: 'Workflow not found or not active', - requestId: tracking.requestId - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 500, - } - ); - } + if (existingLog) { + // Duplicate detected + await supabase.from('notification_logs').update({ + is_duplicate: true + }).eq('id', existingLog.id); - // Generate idempotency key for duplicate prevention - const { data: keyData, error: keyError } = await supabase - .rpc('generate_notification_idempotency_key', { - p_notification_type: 'moderation_submission', - p_entity_id: submission_id, - p_recipient_id: '00000000-0000-0000-0000-000000000000', // Topic-based, use placeholder - p_event_data: { submission_type, action } - }); + addSpanEvent(span, 'duplicate_notification_prevented', { + idempotencyKey, + submission_id + }); - const idempotencyKey = keyData || `mod_sub_${submission_id}_${Date.now()}`; - - // Check for duplicate within 24h window - const { data: existingLog, error: logCheckError } = await supabase - .from('notification_logs') - .select('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_moderators', - requestId: tracking.requestId, - idempotencyKey, - submission_id - }); - - 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, - } - ); - } - - // Prepare enhanced notification payload - const notificationPayload = { - baseUrl: 'https://www.thrillwiki.com', - // Basic info - itemType: submission_type, - submitterName: submitter_name, - submissionId: submission_id, - action: action || 'create', - moderationUrl: 'https://www.thrillwiki.com/admin/moderation', - - // Enhanced content - contentPreview: content_preview, - - // Timing information - submittedAt: submitted_at, - relativeTime: relativeTime, - priority: priority, - - // Additional metadata - hasPhotos: has_photos, - itemCount: item_count, - isEscalated: is_escalated, + return { + success: true, + message: 'Duplicate notification prevented', + idempotencyKey, }; - - // Send ONE notification to the moderation-submissions topic with retry - // All subscribers (moderators) will receive it automatically - const data = await withEdgeRetry( - async () => { - const { data: result, error } = await supabase.functions.invoke('trigger-notification', { - body: { - workflowId: workflow.workflow_id, - topicKey: 'moderation-submissions', - payload: notificationPayload, - }, - }); - - if (error) { - const enhancedError = new Error(error.message || 'Notification trigger failed'); - (enhancedError as any).status = error.status; - throw enhancedError; - } - - return result; - }, - { maxAttempts: 3, baseDelay: 1000 }, - tracking.requestId, - 'trigger-submission-notification' - ); - - // Log notification in notification_logs with idempotency key - const { error: logError } = await supabase.from('notification_logs').insert({ - user_id: '00000000-0000-0000-0000-000000000000', // Topic-based - notification_type: 'moderation_submission', - idempotency_key: idempotencyKey, - is_duplicate: false, - metadata: { - submission_id, - submission_type, - transaction_id: data?.transactionId - } - }); - - if (logError) { - // Non-blocking - notification was sent successfully, log failure shouldn't fail the request - edgeLogger.warn('Failed to log notification in notification_logs', { - action: 'notify_moderators', - requestId: tracking.requestId, - error: logError.message, - submissionId: submission_id - }); - } - - const duration = endRequest(tracking); - edgeLogger.info('Successfully notified all moderators via topic', { - action: 'notify_moderators', - requestId: tracking.requestId, - traceId: tracking.traceId, - duration, - transactionId: data?.transactionId - }); - - return new Response( - JSON.stringify({ - success: true, - message: 'Moderator notifications sent via topic', - topicKey: 'moderation-submissions', - transactionId: data?.transactionId, - requestId: tracking.requestId, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 200, - } - ); - } catch (error: any) { - const duration = endRequest(tracking); - edgeLogger.error('Error in notify-moderators-submission', { - action: 'notify_moderators', - requestId: tracking.requestId, - duration, - error: error.message - }); - - // Persist error to database for monitoring - const errorSpan = startSpan('notify-moderators-submission-error', 'SERVER'); - endSpan(errorSpan, 'error', error); - logSpanToDatabase(errorSpan, tracking.requestId); - - return new Response( - JSON.stringify({ - success: false, - error: error.message, - requestId: tracking.requestId, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - status: 500, - } - ); } -}); + + // Prepare enhanced notification payload + const notificationPayload = { + baseUrl: 'https://www.thrillwiki.com', + itemType: submission_type, + submitterName: submitter_name, + submissionId: submission_id, + action: action || 'create', + moderationUrl: 'https://www.thrillwiki.com/admin/moderation', + contentPreview: content_preview, + submittedAt: submitted_at, + relativeTime: relativeTime, + priority: priority, + hasPhotos: has_photos, + itemCount: item_count, + isEscalated: is_escalated, + }; + + addSpanEvent(span, 'triggering_notification', { + workflowId: workflow.workflow_id, + priority + }); + + // Send ONE notification to the moderation-submissions topic with retry + const data = await withEdgeRetry( + async () => { + const { data: result, error } = await supabase.functions.invoke('trigger-notification', { + body: { + workflowId: workflow.workflow_id, + topicKey: 'moderation-submissions', + payload: notificationPayload, + }, + }); + + if (error) { + const enhancedError = new Error(error.message || 'Notification trigger failed'); + (enhancedError as any).status = error.status; + throw enhancedError; + } + + return result; + }, + { maxAttempts: 3, baseDelay: 1000 }, + requestId, + 'trigger-submission-notification' + ); + + // Log notification with idempotency key + const { error: logError } = await supabase.from('notification_logs').insert({ + user_id: '00000000-0000-0000-0000-000000000000', + notification_type: 'moderation_submission', + idempotency_key: idempotencyKey, + is_duplicate: false, + metadata: { + submission_id, + submission_type, + transaction_id: data?.transactionId + } + }); + + if (logError) { + addSpanEvent(span, 'log_insertion_failed', { error: logError.message }); + } + + addSpanEvent(span, 'notification_sent', { + transactionId: data?.transactionId, + topicKey: 'moderation-submissions' + }); + + return { + success: true, + message: 'Moderator notifications sent via topic', + topicKey: 'moderation-submissions', + transactionId: data?.transactionId, + }; +}; + +serve(createEdgeFunction({ + name: 'notify-moderators-submission', + requireAuth: false, + useServiceRole: true, + corsHeaders, + enableTracing: true, + logRequests: true, +}, handler)); diff --git a/supabase/functions/send-escalation-notification/index.ts b/supabase/functions/send-escalation-notification/index.ts index 39c376f3..79de0337 100644 --- a/supabase/functions/send-escalation-notification/index.ts +++ b/supabase/functions/send-escalation-notification/index.ts @@ -1,7 +1,7 @@ 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 { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; import { withEdgeRetry } from '../_shared/retryHelper.ts'; interface EscalationRequest { @@ -10,276 +10,219 @@ interface EscalationRequest { escalatedBy: string; } -serve(async (req) => { - const tracking = startRequest(); - - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); +const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => { + const { submissionId, escalationReason, escalatedBy }: EscalationRequest = await req.json(); + + addSpanEvent(span, 'processing_escalation', { submissionId, escalatedBy }); + + // Fetch submission details + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .select('*, profiles:user_id(username, display_name, id)') + .eq('id', submissionId) + .single(); + + if (submissionError || !submission) { + throw new Error(`Failed to fetch submission: ${submissionError?.message || 'Not found'}`); } - try { - const supabase = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' - ); + // Fetch escalator details + const { data: escalator, error: escalatorError } = await supabase + .from('profiles') + .select('username, display_name') + .eq('user_id', escalatedBy) + .single(); - const { submissionId, escalationReason, escalatedBy }: EscalationRequest = await req.json(); + if (escalatorError) { + addSpanEvent(span, 'escalator_profile_fetch_failed', { error: escalatorError.message }); + } - edgeLogger.info('Processing escalation notification', { - requestId: tracking.requestId, - submissionId, - escalatedBy, - action: 'send_escalation' - }); + // Fetch submission items count + const { count: itemsCount, error: countError } = await supabase + .from('submission_items') + .select('*', { count: 'exact', head: true }) + .eq('submission_id', submissionId); - // Fetch submission details - const { data: submission, error: submissionError } = await supabase - .from('content_submissions') - .select('*, profiles:user_id(username, display_name, id)') - .eq('id', submissionId) - .single(); + if (countError) { + addSpanEvent(span, 'items_count_fetch_failed', { error: countError.message }); + } - if (submissionError || !submission) { - throw new Error(`Failed to fetch submission: ${submissionError?.message || 'Not found'}`); - } + // Prepare email content + const escalatorName = escalator?.display_name || escalator?.username || 'Unknown User'; + const submitterName = submission.profiles?.display_name || submission.profiles?.username || 'Unknown User'; + const submissionType = submission.submission_type || 'Unknown'; + const itemCount = itemsCount || 0; - // Fetch escalator details - const { data: escalator, error: escalatorError } = await supabase - .from('profiles') - .select('username, display_name') - .eq('user_id', escalatedBy) - .single(); - - if (escalatorError) { - edgeLogger.error('Failed to fetch escalator profile', { - requestId: tracking.requestId, - error: escalatorError.message, - escalatedBy - }); - } - - // Fetch submission items count - const { count: itemsCount, error: countError } = await supabase - .from('submission_items') - .select('*', { count: 'exact', head: true }) - .eq('submission_id', submissionId); - - if (countError) { - edgeLogger.error('Failed to fetch items count', { - requestId: tracking.requestId, - error: countError.message, - submissionId - }); - } - - // Prepare email content - const escalatorName = escalator?.display_name || escalator?.username || 'Unknown User'; - const submitterName = submission.profiles?.display_name || submission.profiles?.username || 'Unknown User'; - const submissionType = submission.submission_type || 'Unknown'; - const itemCount = itemsCount || 0; - - const emailSubject = `🚨 Submission Escalated: ${submissionType} - ID: ${submissionId.substring(0, 8)}`; - - const emailHtml = ` - - - - - - -
-
-

⚠️ Submission Escalated

-

Admin review required

+ const emailSubject = `🚨 Submission Escalated: ${submissionType} - ID: ${submissionId.substring(0, 8)}`; + + const emailHtml = ` + + + + + + +
+
+

⚠️ Submission Escalated

+

Admin review required

+
+ +
+
+ Submission ID: + ${submissionId}
-
-
- Submission ID: - ${submissionId} -
- -
- Submission Type: - ${submissionType} -
- -
- Items Count: - ${itemCount} -
- -
- Submitted By: - ${submitterName} -
- -
- Escalated By: - ${escalatorName} -
- -
- 📝 Escalation Reason: -

${escalationReason}

-
- - +
+ Submission Type: + ${submissionType}
- - - - `; - - const emailText = ` - SUBMISSION ESCALATED - Admin Review Required - - Submission ID: ${submissionId} - Submission Type: ${submissionType} - Items Count: ${itemCount} - Submitted By: ${submitterName} - Escalated By: ${escalatorName} - - Escalation Reason: - ${escalationReason} - - Please review this submission in the admin panel. - `; - - // Send email via ForwardEmail API with retry - const forwardEmailApiKey = Deno.env.get('FORWARDEMAIL_API_KEY'); - const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS'); - const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS'); - - if (!forwardEmailApiKey || !adminEmail || !fromEmail) { - throw new Error('Email configuration is incomplete. Please check environment variables.'); - } - - const emailResult = await withEdgeRetry( - async () => { - const emailResponse = await fetch('https://api.forwardemail.net/v1/emails', { - method: 'POST', - headers: { - 'Authorization': 'Basic ' + btoa(forwardEmailApiKey + ':'), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from: fromEmail, - to: adminEmail, - subject: emailSubject, - html: emailHtml, - text: emailText, - }), - }); - - if (!emailResponse.ok) { - let errorText; - try { - errorText = await emailResponse.text(); - } catch (parseError) { - errorText = 'Unable to parse error response'; - } - const error = new Error(`Failed to send email: ${emailResponse.status} - ${errorText}`); - (error as any).status = emailResponse.status; - throw error; - } + +
+ + + `; - const result = await emailResponse.json(); - return result; - }, - { maxAttempts: 3, baseDelay: 1500, maxDelay: 10000 }, - tracking.requestId, - 'send-escalation-email' - ); - edgeLogger.info('Email sent successfully', { - requestId: tracking.requestId, - emailId: emailResult.id - }); - - // Update submission with notification status - const { error: updateError } = await supabase - .from('content_submissions') - .update({ - escalated: true, - escalated_at: new Date().toISOString(), - escalated_by: escalatedBy, - escalation_reason: escalationReason - }) - .eq('id', submissionId); - - if (updateError) { - edgeLogger.error('Failed to update submission escalation status', { - requestId: tracking.requestId, - error: updateError.message, - submissionId - }); - } - - const duration = endRequest(tracking); - edgeLogger.info('Escalation notification sent', { - requestId: tracking.requestId, - duration, - emailId: emailResult.id, - submissionId - }); - - return new Response( - JSON.stringify({ - success: true, - message: 'Escalation notification sent successfully', - emailId: emailResult.id, - requestId: tracking.requestId - }), - { headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } } - ); - } catch (error) { - const duration = endRequest(tracking); - edgeLogger.error('Error in send-escalation-notification', { - requestId: tracking.requestId, - duration, - error: error instanceof Error ? error.message : 'Unknown error' - }); + const emailText = ` + SUBMISSION ESCALATED - Admin Review Required - // Persist error to database for monitoring - const errorSpan = startSpan('send-escalation-notification-error', 'SERVER'); - endSpan(errorSpan, 'error', error); - logSpanToDatabase(errorSpan, tracking.requestId); - return new Response( - JSON.stringify({ - error: error instanceof Error ? error.message : 'Unknown error occurred', - details: 'Failed to send escalation notification', - requestId: tracking.requestId - }), - { status: 500, headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } } - ); + Submission ID: ${submissionId} + Submission Type: ${submissionType} + Items Count: ${itemCount} + Submitted By: ${submitterName} + Escalated By: ${escalatorName} + + Escalation Reason: + ${escalationReason} + + Please review this submission in the admin panel. + `; + + // Send email via ForwardEmail API with retry + const forwardEmailApiKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS'); + + if (!forwardEmailApiKey || !adminEmail || !fromEmail) { + throw new Error('Email configuration is incomplete. Please check environment variables.'); } -}); + + addSpanEvent(span, 'sending_escalation_email', { adminEmail }); + + const emailResult = await withEdgeRetry( + async () => { + const emailResponse = await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + btoa(forwardEmailApiKey + ':'), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: fromEmail, + to: adminEmail, + subject: emailSubject, + html: emailHtml, + text: emailText, + }), + }); + + if (!emailResponse.ok) { + let errorText; + try { + errorText = await emailResponse.text(); + } catch { + errorText = 'Unable to parse error response'; + } + + const error = new Error(`Failed to send email: ${emailResponse.status} - ${errorText}`); + (error as any).status = emailResponse.status; + throw error; + } + + return await emailResponse.json(); + }, + { maxAttempts: 3, baseDelay: 1500, maxDelay: 10000 }, + requestId, + 'send-escalation-email' + ); + + addSpanEvent(span, 'email_sent', { emailId: emailResult.id }); + + // Update submission with escalation status + const { error: updateError } = await supabase + .from('content_submissions') + .update({ + escalated: true, + escalated_at: new Date().toISOString(), + escalated_by: escalatedBy, + escalation_reason: escalationReason + }) + .eq('id', submissionId); + + if (updateError) { + addSpanEvent(span, 'submission_update_failed', { error: updateError.message }); + } + + addSpanEvent(span, 'escalation_notification_complete', { + emailId: emailResult.id, + submissionId + }); + + return { + success: true, + message: 'Escalation notification sent successfully', + emailId: emailResult.id, + }; +}; + +serve(createEdgeFunction({ + name: 'send-escalation-notification', + requireAuth: false, + useServiceRole: true, + corsHeaders, + enableTracing: true, + logRequests: true, +}, handler)); diff --git a/supabase/functions/send-password-added-email/index.ts b/supabase/functions/send-password-added-email/index.ts index 435beaaf..c29c5b42 100644 --- a/supabase/functions/send-password-added-email/index.ts +++ b/supabase/functions/send-password-added-email/index.ts @@ -1,7 +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'; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; interface EmailRequest { email: string; @@ -9,217 +9,131 @@ interface EmailRequest { username?: string; } -serve(async (req) => { - const tracking = startRequest(); - - if (req.method === 'OPTIONS') { - return new Response(null, { - headers: { - ...corsHeaders, - 'X-Request-ID': tracking.requestId - } - }); +const handler = async (req: Request, { user, span, requestId }: EdgeFunctionContext) => { + const { email, displayName, username }: EmailRequest = await req.json(); + + if (!email) { + throw new Error('Email is required'); } - try { - const supabaseClient = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_ANON_KEY') ?? '', - { - global: { - headers: { Authorization: req.headers.get('Authorization')! }, - }, - } - ); + addSpanEvent(span, 'sending_password_email', { userId: user.id, email }); - const { data: { user }, error: userError } = await supabaseClient.auth.getUser(); + const recipientName = displayName || username || 'there'; + const siteUrl = Deno.env.get('SITE_URL') || 'https://thrillwiki.com'; - if (userError || !user) { - const duration = endRequest(tracking); - edgeLogger.error('Authentication failed', { - action: 'send_password_email', - requestId: tracking.requestId, - duration - }); - - // Persist error to database - const authErrorSpan = startSpan('send-password-added-email-auth-error', 'SERVER'); - endSpan(authErrorSpan, 'error', userError); - logSpanToDatabase(authErrorSpan, tracking.requestId); - throw new Error('Unauthorized'); - } - - const { email, displayName, username }: EmailRequest = await req.json(); - - if (!email) { - throw new Error('Email is required'); - } - - edgeLogger.info('Sending password added email', { - action: 'send_password_email', - requestId: tracking.requestId, - userId: user.id, - email - }); - - const recipientName = displayName || username || 'there'; - const siteUrl = Deno.env.get('SITE_URL') || 'https://thrillwiki.com'; - - const emailHTML = ` - - - - - - -
-
-

Password Successfully Added

-
-
-

Hi ${recipientName},

- -

Great news! A password has been successfully added to your ThrillWiki account (${email}).

- -

✅ What This Means

-

You now have an additional way to access your account. You can sign in using:

-
    -
  • Your email address and the password you just created
  • -
  • Any social login methods you've connected (Google, Discord, etc.)
  • -
- -

🔐 Complete Your Setup

-

Important: To complete your password setup, you need to confirm your email address.

- -
    -
  1. Check your inbox for a confirmation email from ThrillWiki
  2. -
  3. Click the confirmation link in that email
  4. -
  5. Return to the sign-in page and log in with your email and password
  6. -
- - - Go to Sign In Page - - -

- Note: You must confirm your email before you can sign in with your password. -

- -
- ⚠️ Security Notice
- If you didn't add a password to your account, please contact our support team immediately at support@thrillwiki.com -
- -

Thanks for being part of the ThrillWiki community!

- -

- Best regards,
- The ThrillWiki Team -

-
- + const emailHTML = ` + + + + + + +
+
+

Password Successfully Added

- - - `; +
+

Hi ${recipientName},

+ +

Great news! A password has been successfully added to your ThrillWiki account (${email}).

+ +

✅ What This Means

+

You now have an additional way to access your account. You can sign in using:

+
    +
  • Your email address and the password you just created
  • +
  • Any social login methods you've connected (Google, Discord, etc.)
  • +
+ +

🔐 Complete Your Setup

+

Important: To complete your password setup, you need to confirm your email address.

+ +
    +
  1. Check your inbox for a confirmation email from ThrillWiki
  2. +
  3. Click the confirmation link in that email
  4. +
  5. Return to the sign-in page and log in with your email and password
  6. +
+ + + Go to Sign In Page + + +

+ Note: You must confirm your email before you can sign in with your password. +

+ +
+ ⚠️ Security Notice
+ If you didn't add a password to your account, please contact our support team immediately at support@thrillwiki.com +
+ +

Thanks for being part of the ThrillWiki community!

+ +

+ Best regards,
+ The ThrillWiki Team +

+
+ +
+ + + `; - const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); - const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; + const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY'); + const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com'; - if (!forwardEmailKey) { - edgeLogger.error('FORWARDEMAIL_API_KEY not configured', { requestId: tracking.requestId }); - throw new Error('Email service not configured'); - } - - const emailResponse = 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: email, - subject: 'Password Added to Your ThrillWiki Account', - html: emailHTML, - }), - }); - - if (!emailResponse.ok) { - const errorText = await emailResponse.text(); - edgeLogger.error('ForwardEmail API error', { - requestId: tracking.requestId, - status: emailResponse.status, - errorText - }); - throw new Error(`Failed to send email: ${emailResponse.statusText}`); - } - - const duration = endRequest(tracking); - edgeLogger.info('Password addition email sent successfully', { - action: 'send_password_email', - requestId: tracking.requestId, - userId: user.id, - email, - duration - }); - - return new Response( - JSON.stringify({ - success: true, - message: 'Password addition email 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 in send-password-added-email function', { - action: 'send_password_email', - requestId: tracking.requestId, - duration, - error: error instanceof Error ? error.message : 'Unknown error' - }); - - // Persist error to database for monitoring - const errorSpan = startSpan('send-password-added-email-error', 'SERVER'); - endSpan(errorSpan, 'error', error); - logSpanToDatabase(errorSpan, tracking.requestId); - - return new Response( - JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - requestId: tracking.requestId, - }), - { - status: 500, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - }, - } - ); + if (!forwardEmailKey) { + addSpanEvent(span, 'email_config_missing', {}); + throw new Error('Email service not configured'); } -}); + + const emailResponse = 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: email, + subject: 'Password Added to Your ThrillWiki Account', + html: emailHTML, + }), + }); + + if (!emailResponse.ok) { + const errorText = await emailResponse.text(); + addSpanEvent(span, 'email_send_failed', { + status: emailResponse.status, + error: errorText + }); + throw new Error(`Failed to send email: ${emailResponse.statusText}`); + } + + addSpanEvent(span, 'email_sent_successfully', { email }); + + return { + success: true, + message: 'Password addition email sent successfully', + }; +}; + +serve(createEdgeFunction({ + name: 'send-password-added-email', + requireAuth: true, + corsHeaders, + enableTracing: true, + logRequests: true, +}, handler));