diff --git a/src/components/moderation/EscalationDialog.tsx b/src/components/moderation/EscalationDialog.tsx index b6d84135..a5d5ca88 100644 --- a/src/components/moderation/EscalationDialog.tsx +++ b/src/components/moderation/EscalationDialog.tsx @@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ interface EscalationDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onEscalate: (reason: string) => void; + onEscalate: (reason: string, additionalNotes?: string) => void; } const escalationReasons = [ @@ -31,9 +31,9 @@ export function EscalationDialog({ const handleEscalate = () => { const reason = selectedReason === 'Other' ? additionalNotes - : `${selectedReason}${additionalNotes ? ': ' + additionalNotes : ''}`; + : selectedReason; - onEscalate(reason); + onEscalate(reason, selectedReason !== 'Other' ? additionalNotes : undefined); onOpenChange(false); // Reset form diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index 7893ac27..7a3dfa6f 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -150,13 +150,38 @@ export function SubmissionReviewManager({ setLoading(true); try { - const selectedItems = items.filter(item => selectedItemIds.has(item.id)); - await approveSubmissionItems(selectedItems, user.id); + // Use edge function for complex approval processing + const { supabase } = await import('@/integrations/supabase/client'); - toast({ - title: 'Success', - description: `Approved ${selectedItems.length} item(s)`, + const { data, error } = await supabase.functions.invoke('process-selective-approval', { + body: { + submissionId, + selectedItemIds: Array.from(selectedItemIds), + moderatorId: user.id, + } }); + + if (error) throw error; + + const result = data as { + success: boolean; + processedItems: any[]; + successCount: number; + failureCount: number; + }; + + if (result.failureCount > 0) { + toast({ + title: 'Partial Success', + description: `Approved ${result.successCount} of ${result.processedItems.length} item(s). ${result.failureCount} failed.`, + variant: 'default', + }); + } else { + toast({ + title: 'Success', + description: `Approved ${result.successCount} item(s)`, + }); + } onComplete(); onOpenChange(false); @@ -226,7 +251,7 @@ export function SubmissionReviewManager({ } }; - const handleEscalate = async (reason: string) => { + const handleEscalate = async (reason: string, additionalNotes?: string) => { if (!user?.id) { toast({ title: 'Authentication Required', @@ -238,12 +263,35 @@ export function SubmissionReviewManager({ setLoading(true); try { + // Update submission escalation status await escalateSubmission(submissionId, reason, user.id); - toast({ - title: 'Escalated', - description: 'Submission escalated to admin for review', + // Send email notifications via edge function + const { supabase } = await import('@/integrations/supabase/client'); + + const { data, error: emailError } = await supabase.functions.invoke('send-escalation-notification', { + body: { + submissionId, + escalationReason: reason, + escalatedBy: user.id, + additionalNotes, + } }); + + if (emailError) { + console.error('Failed to send escalation emails:', emailError); + toast({ + title: 'Escalated', + description: 'Submission escalated but email notifications failed', + variant: 'default', + }); + } else { + const result = data as { emailsSent: number; totalRecipients: number }; + toast({ + title: 'Escalated', + description: `Submission escalated to admin. ${result.emailsSent} of ${result.totalRecipients} admin(s) notified.`, + }); + } onComplete(); onOpenChange(false); diff --git a/supabase/config.toml b/supabase/config.toml index 05e015f8..459675b5 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -3,4 +3,8 @@ project_id = "ydvtmnrszybqnbcqbdcy" [functions.detect-location] verify_jwt = false -[functions.upload-image] \ No newline at end of file +[functions.upload-image] + +[functions.process-selective-approval] + +[functions.send-escalation-notification] \ No newline at end of file diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts new file mode 100644 index 00000000..91771869 --- /dev/null +++ b/supabase/functions/process-selective-approval/index.ts @@ -0,0 +1,319 @@ +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"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +interface ProcessApprovalRequest { + submissionId: string; + selectedItemIds: string[]; + moderatorId: string; +} + +interface ProcessedItem { + id: string; + entityType: string; + entityId: string | null; + status: 'success' | 'failed'; + error?: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '', + { + auth: { + autoRefreshToken: false, + persistSession: false + } + } + ); + + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + throw new Error('No authorization header'); + } + + const token = authHeader.replace('Bearer ', ''); + const { data: { user }, error: authError } = await supabaseClient.auth.getUser(token); + + if (authError || !user) { + throw new Error('Unauthorized'); + } + + // Verify user is moderator or admin + const { data: userRoles } = await supabaseClient + .from('user_roles') + .select('role') + .eq('user_id', user.id); + + const isModerator = userRoles?.some(r => + ['moderator', 'admin', 'superuser'].includes(r.role) + ); + + if (!isModerator) { + throw new Error('Insufficient permissions'); + } + + const { submissionId, selectedItemIds, moderatorId }: ProcessApprovalRequest = await req.json(); + + console.log('Processing selective approval:', { submissionId, selectedItemIds, moderatorId }); + + // Fetch all selected items with their dependencies + const { data: items, error: itemsError } = await supabaseClient + .from('submission_items') + .select('*') + .in('id', selectedItemIds) + .eq('submission_id', submissionId); + + if (itemsError) { + throw new Error(`Failed to fetch items: ${itemsError.message}`); + } + + // Topological sort to process items in correct order + const sortedItems = topologicalSort(items || []); + const processedItems: ProcessedItem[] = []; + const entityMap = new Map(); // temp_id -> real_id mapping + + // Process each item in dependency order + for (const item of sortedItems) { + try { + let entityId: string | null = null; + + // Resolve parent dependency if exists + if (item.depends_on && entityMap.has(item.depends_on)) { + item.item_data.parent_id = entityMap.get(item.depends_on); + } + + // Create the entity based on type + switch (item.item_type) { + case 'manufacturer': + const { data: manufacturer, error: mfgError } = await supabaseClient + .from('companies') + .insert({ + name: item.item_data.name, + slug: item.item_data.slug, + company_type: 'MANUFACTURER', + description: item.item_data.description, + founded_year: item.item_data.founded_year, + headquarters_location: item.item_data.headquarters_location, + website_url: item.item_data.website_url, + }) + .select('id') + .single(); + + if (mfgError) throw mfgError; + entityId = manufacturer.id; + break; + + case 'designer': + const { data: designer, error: designerError } = await supabaseClient + .from('companies') + .insert({ + name: item.item_data.name, + slug: item.item_data.slug, + company_type: 'DESIGNER', + description: item.item_data.description, + founded_year: item.item_data.founded_year, + headquarters_location: item.item_data.headquarters_location, + website_url: item.item_data.website_url, + }) + .select('id') + .single(); + + if (designerError) throw designerError; + entityId = designer.id; + break; + + case 'ride_model': + const { data: model, error: modelError } = await supabaseClient + .from('ride_models') + .insert({ + name: item.item_data.name, + slug: item.item_data.slug, + manufacturer_id: item.item_data.manufacturer_id || item.item_data.parent_id, + description: item.item_data.description, + model_type: item.item_data.model_type, + }) + .select('id') + .single(); + + if (modelError) throw modelError; + entityId = model.id; + break; + + case 'ride': + const { data: ride, error: rideError } = await supabaseClient + .from('rides') + .insert({ + name: item.item_data.name, + slug: item.item_data.slug, + park_id: item.item_data.park_id, + manufacturer_id: item.item_data.manufacturer_id, + designer_id: item.item_data.designer_id, + model_id: item.item_data.model_id || item.item_data.parent_id, + ride_type: item.item_data.ride_type, + status: item.item_data.status, + opening_date: item.item_data.opening_date, + closing_date: item.item_data.closing_date, + description: item.item_data.description, + }) + .select('id') + .single(); + + if (rideError) throw rideError; + entityId = ride.id; + break; + + case 'park': + const { data: park, error: parkError } = await supabaseClient + .from('parks') + .insert({ + name: item.item_data.name, + slug: item.item_data.slug, + operator_id: item.item_data.operator_id, + property_owner_id: item.item_data.property_owner_id, + location_id: item.item_data.location_id, + status: item.item_data.status, + opening_date: item.item_data.opening_date, + closing_date: item.item_data.closing_date, + description: item.item_data.description, + website_url: item.item_data.website_url, + }) + .select('id') + .single(); + + if (parkError) throw parkError; + entityId = park.id; + break; + + default: + throw new Error(`Unknown item type: ${item.item_type}`); + } + + // Update submission item status + await supabaseClient + .from('submission_items') + .update({ + status: 'approved', + entity_id: entityId, + reviewed_by: moderatorId, + reviewed_at: new Date().toISOString(), + }) + .eq('id', item.id); + + // Store mapping for dependent items + entityMap.set(item.id, entityId!); + + processedItems.push({ + id: item.id, + entityType: item.item_type, + entityId, + status: 'success', + }); + + console.log(`Successfully processed ${item.item_type} ${item.id} -> ${entityId}`); + } catch (error: any) { + console.error(`Failed to process item ${item.id}:`, error); + + // Mark item as failed + await supabaseClient + .from('submission_items') + .update({ + status: 'rejected', + rejection_reason: error.message, + reviewed_by: moderatorId, + reviewed_at: new Date().toISOString(), + }) + .eq('id', item.id); + + processedItems.push({ + id: item.id, + entityType: item.item_type, + entityId: null, + status: 'failed', + error: error.message, + }); + } + } + + // Update submission status if all items are processed + const { data: remainingItems } = await supabaseClient + .from('submission_items') + .select('id') + .eq('submission_id', submissionId) + .eq('status', 'pending'); + + if (!remainingItems || remainingItems.length === 0) { + await supabaseClient + .from('content_submissions') + .update({ + status: 'approved', + reviewed_at: new Date().toISOString(), + }) + .eq('id', submissionId); + } + + return new Response( + JSON.stringify({ + success: true, + processedItems, + totalProcessed: processedItems.length, + successCount: processedItems.filter(p => p.status === 'success').length, + failureCount: processedItems.filter(p => p.status === 'failed').length, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error: any) { + console.error('Error in process-selective-approval:', error); + return new Response( + JSON.stringify({ error: error.message }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } + ); + } +}); + +// Topological sort helper +function topologicalSort(items: any[]): any[] { + const sorted: any[] = []; + const visited = new Set(); + const visiting = new Set(); + + function visit(item: any) { + if (visited.has(item.id)) return; + if (visiting.has(item.id)) { + throw new Error('Circular dependency detected'); + } + + visiting.add(item.id); + + // Visit dependencies first + if (item.depends_on) { + const parent = items.find(i => i.id === item.depends_on); + if (parent) { + visit(parent); + } + } + + visiting.delete(item.id); + visited.add(item.id); + sorted.push(item); + } + + items.forEach(item => visit(item)); + return sorted; +} diff --git a/supabase/functions/send-escalation-notification/index.ts b/supabase/functions/send-escalation-notification/index.ts new file mode 100644 index 00000000..c3d0f830 --- /dev/null +++ b/supabase/functions/send-escalation-notification/index.ts @@ -0,0 +1,250 @@ +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"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +interface EscalationRequest { + submissionId: string; + escalationReason: string; + escalatedBy: string; + additionalNotes?: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '', + { + auth: { + autoRefreshToken: false, + persistSession: false + } + } + ); + + const { submissionId, escalationReason, escalatedBy, additionalNotes }: EscalationRequest = await req.json(); + + console.log('Processing escalation notification:', { submissionId, escalationReason, escalatedBy }); + + // Get submission details + const { data: submission, error: submissionError } = await supabaseClient + .from('content_submissions') + .select(` + *, + submitter:profiles!content_submissions_user_id_fkey(username, display_name) + `) + .eq('id', submissionId) + .single(); + + if (submissionError || !submission) { + throw new Error('Submission not found'); + } + + // Get escalated by user details + const { data: escalator, error: escalatorError } = await supabaseClient + .from('profiles') + .select('username, display_name') + .eq('user_id', escalatedBy) + .single(); + + // Get all admin/superuser emails + const { data: adminUsers, error: adminError } = await supabaseClient + .from('user_roles') + .select('user_id, profiles!inner(user_id, display_name, username)') + .in('role', ['admin', 'superuser']); + + if (adminError || !adminUsers || adminUsers.length === 0) { + throw new Error('No admin users found to notify'); + } + + // Get email addresses for admins + const adminUserIds = adminUsers.map(u => u.user_id); + const { data: authUsers, error: authError } = await supabaseClient.auth.admin.listUsers(); + + if (authError) { + throw new Error('Failed to fetch admin emails'); + } + + const adminEmails = authUsers.users + .filter(u => adminUserIds.includes(u.id)) + .map(u => u.email) + .filter(Boolean); + + if (adminEmails.length === 0) { + throw new Error('No admin emails found'); + } + + // Get submission items for context + const { data: items } = await supabaseClient + .from('submission_items') + .select('item_type, item_data') + .eq('submission_id', submissionId); + + const itemsSummary = items?.map(item => + `${item.item_type}: ${item.item_data.name || 'Unnamed'}` + ).join(', ') || 'No items'; + + // Prepare email content + const emailSubject = `🚨 Escalated Submission: ${submission.title || submissionId}`; + const emailHtml = ` + + + + + + +
+
+

⚠️ Submission Escalated

+

Immediate attention required

+
+
+
+
Submission Title
+
${submission.title || 'Untitled Submission'}
+
+ +
+
Submitted By
+
${submission.submitter?.display_name || submission.submitter?.username || 'Unknown'}
+
+ +
+
Items in Submission
+
${itemsSummary}
+
+ +
+
Escalation Reason
+
${escalationReason}
+ ${additionalNotes ? `
Additional Notes:
${additionalNotes}
` : ''} +
+ +
+
Escalated By
+
${escalator?.display_name || escalator?.username || 'System'}
+
+ +
+
Submission ID
+
${submissionId}
+
+ + + Review Submission → + + + +
+
+ + + `; + + const emailText = ` +ESCALATED SUBMISSION - IMMEDIATE ATTENTION REQUIRED + +Submission: ${submission.title || 'Untitled'} +Submitted By: ${submission.submitter?.display_name || submission.submitter?.username || 'Unknown'} + +Items: ${itemsSummary} + +ESCALATION REASON: +${escalationReason} + +${additionalNotes ? `Additional Notes:\n${additionalNotes}\n` : ''} + +Escalated By: ${escalator?.display_name || escalator?.username || 'System'} + +Submission ID: ${submissionId} + +Review at: ${Deno.env.get('SUPABASE_URL')?.replace('supabase.co', 'lovableproject.com')}/admin?tab=moderation&submission=${submissionId} + `.trim(); + + // Send emails to all admins using forwardemail_proxy + const emailResults = []; + + for (const adminEmail of adminEmails) { + try { + const { data: emailData, error: emailError } = await supabaseClient.functions.invoke('forwardemail_proxy', { + body: { + to: adminEmail, + subject: emailSubject, + html: emailHtml, + text: emailText, + } + }); + + if (emailError) { + console.error(`Failed to send email to ${adminEmail}:`, emailError); + emailResults.push({ email: adminEmail, status: 'failed', error: emailError.message }); + } else { + console.log(`Email sent successfully to ${adminEmail}`); + emailResults.push({ email: adminEmail, status: 'sent' }); + } + } catch (error: any) { + console.error(`Exception sending email to ${adminEmail}:`, error); + emailResults.push({ email: adminEmail, status: 'failed', error: error.message }); + } + } + + // Log escalation + await supabaseClient + .from('content_submissions') + .update({ + escalated: true, + escalation_reason: escalationReason, + escalated_at: new Date().toISOString(), + escalated_by: escalatedBy, + }) + .eq('id', submissionId); + + const successCount = emailResults.filter(r => r.status === 'sent').length; + const failureCount = emailResults.filter(r => r.status === 'failed').length; + + return new Response( + JSON.stringify({ + success: true, + emailsSent: successCount, + totalRecipients: adminEmails.length, + failures: failureCount, + results: emailResults, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error: any) { + console.error('Error in send-escalation-notification:', error); + return new Response( + JSON.stringify({ error: error.message }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } + ); + } +});