Files
thrilltrack-explorer/supabase/functions/process-selective-approval/index.ts
pac7 2fc78cbde5 Improve error handling and dependency checks for data processing
Address issues in FieldHistoryDialog by clearing changes on error, enhance useEntityVersions to provide default profiles for missing users, add localStorage availability check in useLocationAutoDetect, and improve dependency resolution logic in supabase/functions/process-selective-approval.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 334e80ad-8f74-4e3a-acec-6dd811858e98
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
2025-10-08 12:23:22 +00:00

730 lines
24 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 ApprovalRequest {
itemIds: string[];
submissionId: string;
}
// Allowed database fields for each entity type
const RIDE_FIELDS = [
'name', 'slug', 'description', 'park_id', 'ride_model_id',
'manufacturer_id', 'designer_id', 'category', 'status',
'opening_date', 'closing_date', 'height_requirement', 'age_requirement',
'capacity_per_hour', 'duration_seconds', 'max_speed_kmh',
'max_height_meters', 'length_meters', 'inversions',
'ride_sub_type', 'coaster_type', 'seating_type', 'intensity_level',
'drop_height_meters', 'max_g_force', 'image_url',
'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id'
];
const PARK_FIELDS = [
'name', 'slug', 'description', 'park_type', 'status',
'opening_date', 'closing_date', 'location_id', 'operator_id',
'property_owner_id', 'website_url', 'phone', 'email',
'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id'
];
const COMPANY_FIELDS = [
'name', 'slug', 'description', 'company_type', 'person_type',
'founded_year', 'headquarters_location', 'website_url', 'logo_url',
'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id'
];
const RIDE_MODEL_FIELDS = [
'name', 'slug', 'description', 'category', 'ride_type',
'manufacturer_id', 'banner_image_url', 'banner_image_id',
'card_image_url', 'card_image_id'
];
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
// Verify authentication first with a client that respects RLS
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(
JSON.stringify({ error: 'Authentication required. Please log in.' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Create Supabase client with user's auth token to verify authentication
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
const supabaseAuth = createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: authHeader } }
});
// Verify JWT and get authenticated user
const { data: { user }, error: authError } = await supabaseAuth.auth.getUser();
if (authError || !user) {
console.error('Auth verification failed:', authError);
return new Response(
JSON.stringify({ error: 'Invalid authentication token.' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const authenticatedUserId = user.id;
// Create service role client for privileged operations (including role check)
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
);
// Check if user has moderator permissions using service role to bypass RLS
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('role')
.eq('user_id', authenticatedUserId)
.single();
if (profileError || !profile) {
console.error('Failed to fetch profile:', profileError);
return new Response(
JSON.stringify({ error: 'User profile not found.' }),
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
if (profile.role !== 'moderator' && profile.role !== 'admin') {
return new Response(
JSON.stringify({ error: 'Insufficient permissions. Moderator role required.' }),
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const { itemIds, submissionId }: ApprovalRequest = await req.json();
// UUID validation regex
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Validate itemIds
if (!itemIds || !Array.isArray(itemIds)) {
return new Response(
JSON.stringify({ error: 'itemIds is required and must be an array' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
if (itemIds.length === 0) {
return new Response(
JSON.stringify({ error: 'itemIds must be a non-empty array' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Validate submissionId
if (!submissionId || typeof submissionId !== 'string' || submissionId.trim() === '') {
return new Response(
JSON.stringify({ error: 'submissionId is required and must be a non-empty string' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
if (!uuidRegex.test(submissionId)) {
return new Response(
JSON.stringify({ error: 'submissionId must be a valid UUID format' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
console.log('Processing selective approval:', { itemIds, userId: authenticatedUserId, 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}`);
}
// Get the submitter's user_id from the submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')
.select('user_id')
.eq('id', submissionId)
.single();
if (submissionError || !submission) {
throw new Error(`Failed to fetch submission: ${submissionError?.message}`);
}
const submitterId = submission.user_id;
// Topologically sort items by dependencies
const sortedItems = topologicalSort(items);
const dependencyMap = new Map<string, string>();
const approvalResults: Array<{
itemId: string;
entityId?: string | null;
itemType: string;
success: boolean;
error?: string;
}> = [];
// Process items in order
for (const item of sortedItems) {
try {
console.log(`Processing item ${item.id} of type ${item.item_type}`);
// Set user context for versioning trigger
// This allows auto_create_entity_version() to capture the submitter
const { error: setConfigError } = await supabase.rpc('set_config_value', {
setting_name: 'app.current_user_id',
setting_value: submitterId,
is_local: false
});
if (setConfigError) {
console.error('Failed to set user context:', setConfigError);
}
// Resolve dependencies in item data
const resolvedData = resolveDependencies(item.item_data, dependencyMap);
// Add submitter ID to the data for photo tracking
resolvedData._submitter_id = submitterId;
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;
case 'photo_edit':
await editPhoto(supabase, resolvedData);
entityId = resolvedData.photo_id;
break;
case 'photo_delete':
await deletePhoto(supabase, resolvedData);
entityId = resolvedData.photo_id;
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 instanceof Error ? error.message : 'Unknown error'
});
}
}
// Update submission status
const allApproved = approvalResults.every(r => r.success);
const { error: updateError } = await supabase
.from('content_submissions')
.update({
status: allApproved ? 'approved' : 'partially_approved',
reviewer_id: authenticatedUserId,
reviewed_at: new Date().toISOString()
})
.eq('id', submissionId);
if (updateError) {
console.error('Failed to update submission status:', updateError);
}
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 instanceof Error ? error.message : 'Unknown error' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});
// Helper functions
function topologicalSort(items: any[]): any[] {
const sorted: any[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
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) {
throw new Error(`Missing dependency: item ${item.id} depends on ${item.depends_on} which is not in the submission`);
}
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<string, string>): 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;
}
function sanitizeDateFields(data: any): any {
const dateFields = ['opening_date', 'closing_date', 'date_changed', 'date_taken', 'visit_date'];
const sanitized = { ...data };
for (const field of dateFields) {
if (field in sanitized && sanitized[field] === '') {
sanitized[field] = null;
}
}
return sanitized;
}
function filterDatabaseFields(data: any, allowedFields: string[]): any {
const filtered: any = {};
for (const field of allowedFields) {
if (field in data && data[field] !== undefined) {
filtered[field] = data[field];
}
}
return filtered;
}
function normalizeStatusValue(data: any): any {
if (data.status) {
// Map display values to database values
const statusMap: Record<string, string> = {
'Operating': 'operating',
'Seasonal': 'operating',
'Closed Temporarily': 'maintenance',
'Closed Permanently': 'closed',
'Under Construction': 'under_construction',
'Planned': 'under_construction',
'SBNO': 'sbno',
// Also handle already-lowercase values
'operating': 'operating',
'closed': 'closed',
'under_construction': 'under_construction',
'maintenance': 'maintenance',
'sbno': 'sbno'
};
data.status = statusMap[data.status] || 'operating';
}
return data;
}
async function createPark(supabase: any, data: any): Promise<string> {
const submitterId = data._submitter_id;
let uploadedPhotos: any[] = [];
// Transform images object if present
if (data.images) {
const { uploaded, banner_assignment, card_assignment } = data.images;
if (uploaded && Array.isArray(uploaded)) {
// Store uploaded photos for later insertion into photos table
uploadedPhotos = uploaded;
// Assign banner image
if (banner_assignment !== undefined && uploaded[banner_assignment]) {
data.banner_image_id = uploaded[banner_assignment].cloudflare_id;
data.banner_image_url = uploaded[banner_assignment].url;
}
// Assign card image
if (card_assignment !== undefined && uploaded[card_assignment]) {
data.card_image_id = uploaded[card_assignment].cloudflare_id;
data.card_image_url = uploaded[card_assignment].url;
}
}
// Remove images object
delete data.images;
}
// Remove internal fields
delete data._submitter_id;
let parkId: string;
// Check if this is an edit (has park_id) or a new creation
if (data.park_id) {
console.log(`Updating existing park ${data.park_id}`);
parkId = data.park_id;
delete data.park_id; // Remove ID from update data
const normalizedData = normalizeStatusValue(data);
const sanitizedData = sanitizeDateFields(normalizedData);
const filteredData = filterDatabaseFields(sanitizedData, PARK_FIELDS);
const { error } = await supabase
.from('parks')
.update(filteredData)
.eq('id', parkId);
if (error) throw new Error(`Failed to update park: ${error.message}`);
} else {
console.log('Creating new park');
const normalizedData = normalizeStatusValue(data);
const sanitizedData = sanitizeDateFields(normalizedData);
const filteredData = filterDatabaseFields(sanitizedData, PARK_FIELDS);
const { data: park, error } = await supabase
.from('parks')
.insert(filteredData)
.select('id')
.single();
if (error) throw new Error(`Failed to create park: ${error.message}`);
parkId = park.id;
}
// Insert photos into photos table
if (uploadedPhotos.length > 0 && submitterId) {
console.log(`Inserting ${uploadedPhotos.length} photos for park ${parkId}`);
for (let i = 0; i < uploadedPhotos.length; i++) {
const photo = uploadedPhotos[i];
if (photo.cloudflare_id && photo.url) {
const { error: photoError } = await supabase.from('photos').insert({
entity_id: parkId,
entity_type: 'park',
cloudflare_image_id: photo.cloudflare_id,
cloudflare_image_url: photo.url,
caption: photo.caption || null,
title: null,
submitted_by: submitterId,
approved_at: new Date().toISOString(),
order_index: i,
});
if (photoError) {
console.error(`Failed to insert photo ${i}:`, photoError);
}
}
}
}
return parkId;
}
async function createRide(supabase: any, data: any): Promise<string> {
const submitterId = data._submitter_id;
let uploadedPhotos: any[] = [];
// Transform images object if present
if (data.images) {
const { uploaded, banner_assignment, card_assignment } = data.images;
if (uploaded && Array.isArray(uploaded)) {
// Store uploaded photos for later insertion into photos table
uploadedPhotos = uploaded;
// Assign banner image
if (banner_assignment !== undefined && uploaded[banner_assignment]) {
data.banner_image_id = uploaded[banner_assignment].cloudflare_id;
data.banner_image_url = uploaded[banner_assignment].url;
}
// Assign card image
if (card_assignment !== undefined && uploaded[card_assignment]) {
data.card_image_id = uploaded[card_assignment].cloudflare_id;
data.card_image_url = uploaded[card_assignment].url;
}
}
// Remove images object
delete data.images;
}
// Remove internal fields and store park_id before filtering
delete data._submitter_id;
const parkId = data.park_id;
let rideId: string;
// Check if this is an edit (has ride_id) or a new creation
if (data.ride_id) {
console.log(`Updating existing ride ${data.ride_id}`);
rideId = data.ride_id;
delete data.ride_id; // Remove ID from update data
const normalizedData = normalizeStatusValue(data);
const sanitizedData = sanitizeDateFields(normalizedData);
const filteredData = filterDatabaseFields(sanitizedData, RIDE_FIELDS);
const { error } = await supabase
.from('rides')
.update(filteredData)
.eq('id', rideId);
if (error) throw new Error(`Failed to update ride: ${error.message}`);
// Update park ride counts after successful ride update
if (parkId) {
console.log(`Updating ride counts for park ${parkId}`);
const { error: countError } = await supabase.rpc('update_park_ride_counts', {
target_park_id: parkId
});
if (countError) {
console.error('Failed to update park counts:', countError);
}
}
} else {
console.log('Creating new ride');
const normalizedData = normalizeStatusValue(data);
const sanitizedData = sanitizeDateFields(normalizedData);
const filteredData = filterDatabaseFields(sanitizedData, RIDE_FIELDS);
const { data: ride, error } = await supabase
.from('rides')
.insert(filteredData)
.select('id')
.single();
if (error) throw new Error(`Failed to create ride: ${error.message}`);
rideId = ride.id;
// Update park ride counts after successful ride creation
if (parkId) {
console.log(`Updating ride counts for park ${parkId}`);
const { error: countError } = await supabase.rpc('update_park_ride_counts', {
target_park_id: parkId
});
if (countError) {
console.error('Failed to update park counts:', countError);
}
}
}
// Insert photos into photos table
if (uploadedPhotos.length > 0 && submitterId) {
console.log(`Inserting ${uploadedPhotos.length} photos for ride ${rideId}`);
for (let i = 0; i < uploadedPhotos.length; i++) {
const photo = uploadedPhotos[i];
if (photo.cloudflare_id && photo.url) {
const { error: photoError } = await supabase.from('photos').insert({
entity_id: rideId,
entity_type: 'ride',
cloudflare_image_id: photo.cloudflare_id,
cloudflare_image_url: photo.url,
caption: photo.caption || null,
title: null,
submitted_by: submitterId,
approved_at: new Date().toISOString(),
order_index: i,
});
if (photoError) {
console.error(`Failed to insert photo ${i}:`, photoError);
}
}
}
}
return rideId;
}
async function createCompany(supabase: any, data: any, companyType: string): Promise<string> {
// Transform images object if present
if (data.images) {
const { uploaded, banner_assignment, card_assignment } = data.images;
if (uploaded && Array.isArray(uploaded)) {
// Assign banner image
if (banner_assignment !== undefined && uploaded[banner_assignment]) {
data.banner_image_id = uploaded[banner_assignment].cloudflare_id;
data.banner_image_url = uploaded[banner_assignment].url;
}
// Assign card image
if (card_assignment !== undefined && uploaded[card_assignment]) {
data.card_image_id = uploaded[card_assignment].cloudflare_id;
data.card_image_url = uploaded[card_assignment].url;
}
}
// Remove images object
delete data.images;
}
// Check if this is an edit (has company_id or id) or a new creation
const companyId = data.company_id || data.id;
if (companyId) {
console.log(`Updating existing company ${companyId}`);
const updateData = sanitizeDateFields({ ...data, company_type: companyType });
delete updateData.company_id;
delete updateData.id; // Remove ID from update data
const filteredData = filterDatabaseFields(updateData, COMPANY_FIELDS);
const { error } = await supabase
.from('companies')
.update(filteredData)
.eq('id', companyId);
if (error) throw new Error(`Failed to update company: ${error.message}`);
return companyId;
} else {
console.log('Creating new company');
const companyData = sanitizeDateFields({ ...data, company_type: companyType });
const filteredData = filterDatabaseFields(companyData, COMPANY_FIELDS);
const { data: company, error } = await supabase
.from('companies')
.insert(filteredData)
.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<string> {
const sanitizedData = sanitizeDateFields(data);
const filteredData = filterDatabaseFields(sanitizedData, RIDE_MODEL_FIELDS);
const { data: model, error } = await supabase
.from('ride_models')
.insert(filteredData)
.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<void> {
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;
}
async function editPhoto(supabase: any, data: any): Promise<void> {
console.log(`Editing photo ${data.photo_id}`);
const { error } = await supabase
.from('photos')
.update({
caption: data.new_caption,
})
.eq('id', data.photo_id);
if (error) throw new Error(`Failed to edit photo: ${error.message}`);
}
async function deletePhoto(supabase: any, data: any): Promise<void> {
console.log(`Deleting photo ${data.photo_id}`);
const { error } = await supabase
.from('photos')
.delete()
.eq('id', data.photo_id);
if (error) throw new Error(`Failed to delete photo: ${error.message}`);
}