Files
thrilltrack-explorer/supabase/functions/process-selective-approval/index.ts
2025-09-30 18:33:10 +00:00

320 lines
9.8 KiB
TypeScript

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<string, string>(); // 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<string>();
const visiting = new Set<string>();
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;
}