diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index 7893ac27..ccc91740 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -150,17 +150,41 @@ export function SubmissionReviewManager({ setLoading(true); try { + const { supabase } = await import('@/integrations/supabase/client'); const selectedItems = items.filter(item => selectedItemIds.has(item.id)); - await approveSubmissionItems(selectedItems, user.id); + // Call the edge function for backend processing + const { data, error } = await supabase.functions.invoke('process-selective-approval', { + body: { + itemIds: Array.from(selectedItemIds), + userId: user.id, + submissionId + } + }); + + if (error) { + throw new Error(error.message || 'Failed to process approval'); + } + + if (!data?.success) { + throw new Error(data?.error || 'Approval processing failed'); + } + + const successCount = data.results.filter((r: any) => r.success).length; + const failCount = data.results.filter((r: any) => !r.success).length; + toast({ - title: 'Success', - description: `Approved ${selectedItems.length} item(s)`, + title: 'Approval Complete', + description: failCount > 0 + ? `Approved ${successCount} item(s), ${failCount} failed` + : `Successfully approved ${successCount} item(s)`, + variant: failCount > 0 ? 'destructive' : 'default', }); onComplete(); onOpenChange(false); } catch (error: any) { + console.error('Error approving items:', error); toast({ title: 'Error', description: error.message || 'Failed to approve items', @@ -238,16 +262,37 @@ export function SubmissionReviewManager({ setLoading(true); try { - await escalateSubmission(submissionId, reason, user.id); - - toast({ - title: 'Escalated', - description: 'Submission escalated to admin for review', + const { supabase } = await import('@/integrations/supabase/client'); + + // Call the escalation notification edge function + const { data, error } = await supabase.functions.invoke('send-escalation-notification', { + body: { + submissionId, + escalationReason: reason, + escalatedBy: user.id + } }); + + if (error) { + console.error('Edge function error:', error); + // Fallback to direct database update if email fails + await escalateSubmission(submissionId, reason, user.id); + toast({ + title: 'Escalated (Email Failed)', + description: 'Submission escalated but notification email failed to send', + variant: 'default', + }); + } else { + toast({ + title: 'Escalated Successfully', + description: 'Submission escalated and admin notified via email', + }); + } onComplete(); onOpenChange(false); } catch (error: any) { + console.error('Error escalating submission:', error); toast({ title: 'Error', description: error.message || 'Failed to escalate submission', diff --git a/supabase/config.toml b/supabase/config.toml index 05e015f8..ed0edb88 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -3,4 +3,11 @@ project_id = "ydvtmnrszybqnbcqbdcy" [functions.detect-location] verify_jwt = false -[functions.upload-image] \ No newline at end of file +[functions.upload-image] +verify_jwt = false + +[functions.process-selective-approval] +verify_jwt = true + +[functions.send-escalation-notification] +verify_jwt = true \ 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..45bdc918 --- /dev/null +++ b/supabase/functions/process-selective-approval/index.ts @@ -0,0 +1,271 @@ +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 ApprovalRequest { + itemIds: string[]; + userId: string; + submissionId: string; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ); + + const { itemIds, userId, submissionId }: ApprovalRequest = await req.json(); + + console.log('Processing selective approval:', { itemIds, userId, submissionId }); + + // Fetch all items for the submission + const { data: items, error: fetchError } = await supabase + .from('submission_items') + .select('*') + .in('id', itemIds); + + if (fetchError) { + throw new Error(`Failed to fetch items: ${fetchError.message}`); + } + + // Topologically sort items by dependencies + const sortedItems = topologicalSort(items); + const dependencyMap = new Map(); + const approvalResults = []; + + // Process items in order + for (const item of sortedItems) { + try { + console.log(`Processing item ${item.id} of type ${item.item_type}`); + + // Resolve dependencies in item data + const resolvedData = resolveDependencies(item.item_data, dependencyMap); + + let entityId: string | null = null; + + // Create entity based on type + switch (item.item_type) { + case 'park': + entityId = await createPark(supabase, resolvedData); + break; + case 'ride': + entityId = await createRide(supabase, resolvedData); + break; + case 'manufacturer': + case 'operator': + case 'property_owner': + case 'designer': + entityId = await createCompany(supabase, resolvedData, item.item_type); + break; + case 'ride_model': + entityId = await createRideModel(supabase, resolvedData); + break; + case 'photo': + await approvePhotos(supabase, resolvedData, item.id); + entityId = item.id; // Use item ID as entity ID for photos + break; + default: + throw new Error(`Unknown item type: ${item.item_type}`); + } + + if (entityId) { + dependencyMap.set(item.id, entityId); + } + + // Update item status + const { error: updateError } = await supabase + .from('submission_items') + .update({ + status: 'approved', + approved_entity_id: entityId, + updated_at: new Date().toISOString() + }) + .eq('id', item.id); + + if (updateError) { + throw new Error(`Failed to update item status: ${updateError.message}`); + } + + approvalResults.push({ + itemId: item.id, + entityId, + itemType: item.item_type, + success: true + }); + + console.log(`Successfully approved item ${item.id} -> entity ${entityId}`); + } catch (error) { + console.error(`Error processing item ${item.id}:`, error); + approvalResults.push({ + itemId: item.id, + itemType: item.item_type, + success: false, + error: error.message + }); + } + } + + // Update submission status + const allApproved = approvalResults.every(r => r.success); + const { error: submissionError } = await supabase + .from('content_submissions') + .update({ + status: allApproved ? 'approved' : 'partially_approved', + reviewer_id: userId, + reviewed_at: new Date().toISOString() + }) + .eq('id', submissionId); + + if (submissionError) { + console.error('Failed to update submission status:', submissionError); + } + + return new Response( + JSON.stringify({ + success: true, + results: approvalResults, + submissionStatus: allApproved ? 'approved' : 'partially_approved' + }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Error in process-selective-approval:', error); + return new Response( + JSON.stringify({ error: error.message }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +}); + +// Helper functions +function topologicalSort(items: any[]): any[] { + const sorted: any[] = []; + const visited = new Set(); + const visiting = new Set(); + + const visit = (item: any) => { + if (visited.has(item.id)) return; + if (visiting.has(item.id)) { + throw new Error(`Circular dependency detected for item ${item.id}`); + } + + visiting.add(item.id); + + 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; +} + +function resolveDependencies(data: any, dependencyMap: Map): any { + if (typeof data !== 'object' || data === null) { + return data; + } + + if (Array.isArray(data)) { + return data.map(item => resolveDependencies(item, dependencyMap)); + } + + const resolved: any = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && dependencyMap.has(value)) { + resolved[key] = dependencyMap.get(value); + } else { + resolved[key] = resolveDependencies(value, dependencyMap); + } + } + return resolved; +} + +async function createPark(supabase: any, data: any): Promise { + const { data: park, error } = await supabase + .from('parks') + .insert(data) + .select('id') + .single(); + + if (error) throw new Error(`Failed to create park: ${error.message}`); + return park.id; +} + +async function createRide(supabase: any, data: any): Promise { + const { data: ride, error } = await supabase + .from('rides') + .insert(data) + .select('id') + .single(); + + if (error) throw new Error(`Failed to create ride: ${error.message}`); + return ride.id; +} + +async function createCompany(supabase: any, data: any, companyType: string): Promise { + const companyData = { ...data, company_type: companyType }; + const { data: company, error } = await supabase + .from('companies') + .insert(companyData) + .select('id') + .single(); + + if (error) throw new Error(`Failed to create company: ${error.message}`); + return company.id; +} + +async function createRideModel(supabase: any, data: any): Promise { + const { data: model, error } = await supabase + .from('ride_models') + .insert(data) + .select('id') + .single(); + + if (error) throw new Error(`Failed to create ride model: ${error.message}`); + return model.id; +} + +async function approvePhotos(supabase: any, data: any, submissionItemId: string): Promise { + const photos = data.photos || []; + + for (const photo of photos) { + const photoData = { + entity_id: data.entity_id, + entity_type: data.context, + cloudflare_image_id: extractImageId(photo.url), + cloudflare_image_url: photo.url, + title: photo.title, + caption: photo.caption, + date_taken: photo.date, + order_index: photo.order, + submission_id: submissionItemId + }; + + const { error } = await supabase.from('photos').insert(photoData); + if (error) { + console.error('Failed to insert photo:', error); + throw new Error(`Failed to insert photo: ${error.message}`); + } + } +} + +function extractImageId(url: string): string { + const matches = url.match(/\/([^\/]+)\/public$/); + return matches ? matches[1] : url; +} diff --git a/supabase/functions/send-escalation-notification/index.ts b/supabase/functions/send-escalation-notification/index.ts new file mode 100644 index 00000000..04b5b778 --- /dev/null +++ b/supabase/functions/send-escalation-notification/index.ts @@ -0,0 +1,222 @@ +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; +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ); + + const { submissionId, escalationReason, escalatedBy }: EscalationRequest = await req.json(); + + console.log('Processing escalation notification:', { submissionId, escalationReason, 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'}`); + } + + // Fetch escalator details + const { data: escalator, error: escalatorError } = await supabase + .from('profiles') + .select('username, display_name') + .eq('user_id', escalatedBy) + .single(); + + if (escalatorError) { + console.error('Failed to fetch escalator profile:', escalatorError); + } + + // 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) { + console.error('Failed to fetch items count:', countError); + } + + // 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

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

${escalationReason}

+
+ + +
+ + +
+ + + `; + + 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 + 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 emailResponse = await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${forwardEmailApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: fromEmail, + to: adminEmail, + subject: emailSubject, + html: emailHtml, + text: emailText, + }), + }); + + if (!emailResponse.ok) { + const errorText = await emailResponse.text(); + console.error('ForwardEmail API error:', errorText); + throw new Error(`Failed to send email: ${emailResponse.status} - ${errorText}`); + } + + const emailResult = await emailResponse.json(); + console.log('Email sent successfully:', emailResult); + + // 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) { + console.error('Failed to update submission escalation status:', updateError); + } + + return new Response( + JSON.stringify({ + success: true, + message: 'Escalation notification sent successfully', + emailId: emailResult.id + }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Error in send-escalation-notification:', error); + return new Response( + JSON.stringify({ + error: error.message, + details: 'Failed to send escalation notification' + }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +});