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 = `
-
-
-
-
-
-
-
-