import { supabase } from '@/lib/supabaseClient'; import { handleError, handleNonCriticalError, getErrorMessage } from './errorHandler'; import { extractCloudflareImageId } from './cloudflareImageUtils'; // Core submission item interface with dependencies // NOTE: item_data and original_data use `unknown` because they contain dynamic structures // that vary by item_type. Use type guards from @/types/submission-item-data.ts to access safely. export interface SubmissionItemWithDeps { id: string; submission_id: string; item_type: string; item_data: unknown; // Dynamic structure - use type guards for safe access original_data?: unknown; // Dynamic structure - use type guards for safe access action_type?: 'create' | 'edit' | 'delete'; status: 'pending' | 'approved' | 'rejected' | 'flagged' | 'skipped'; // Matches ReviewStatus from statuses.ts depends_on: string | null; order_index: number; approved_entity_id: string | null; rejection_reason: string | null; created_at: string; updated_at: string; dependencies?: SubmissionItemWithDeps[]; dependents?: SubmissionItemWithDeps[]; } export interface DependencyConflict { itemId: string; type: 'missing_parent' | 'rejected_parent' | 'circular_dependency'; message: string; suggestions: Array<{ action: 'link_existing' | 'cascade_reject' | 'escalate' | 'create_parent'; label: string; entityId?: string; }>; } export interface ConflictCheckResult { hasConflict: boolean; clientVersion: { last_modified_at: string; }; serverVersion?: { last_modified_at: string; last_modified_by: string; modified_by_profile?: { username: string; display_name?: string; }; } | null; } /** * Fetch all items for a submission with their dependencies * Now joins with relational tables instead of using JSONB */ export async function fetchSubmissionItems(submissionId: string): Promise { const { data, error } = await supabase .from('submission_items') .select(` *, park_submission:park_submissions!submission_items_park_submission_id_fkey(*), ride_submission:ride_submissions!submission_items_ride_submission_id_fkey(*), company_submission:company_submissions!submission_items_company_submission_id_fkey(*), ride_model_submission:ride_model_submissions!submission_items_ride_model_submission_id_fkey(*), timeline_event_submission:timeline_event_submissions!submission_items_timeline_event_submission_id_fkey(*), photo_submission:photo_submissions!submission_items_photo_submission_id_fkey( *, photo_items:photo_submission_items(*) ) `) .eq('submission_id', submissionId) .order('order_index', { ascending: true }); if (error) throw error; // Transform data to include relational data as item_data return (data || []).map(item => { let item_data: unknown; switch (item.item_type) { case 'park': item_data = (item as any).park_submission; break; case 'ride': item_data = (item as any).ride_submission; break; case 'operator': case 'manufacturer': case 'designer': case 'property_owner': item_data = (item as any).company_submission; break; case 'ride_model': item_data = (item as any).ride_model_submission; break; case 'milestone': case 'timeline_event': item_data = (item as any).timeline_event_submission; break; case 'photo': case 'photo_edit': case 'photo_delete': item_data = { ...(item as any).photo_submission, photos: (item as any).photo_submission?.photo_items || [] }; break; default: // Log warning for unknown types but don't crash console.warn(`Unknown item_type: ${item.item_type}`); item_data = null; } return { ...item, item_data, status: item.status as 'pending' | 'approved' | 'rejected', }; }) as SubmissionItemWithDeps[]; } /** * Build dependency tree for submission items */ export function buildDependencyTree(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] { const itemMap = new Map(items.map(item => [item.id, { ...item, dependencies: [] as SubmissionItemWithDeps[], dependents: [] as SubmissionItemWithDeps[] }])); // Build relationships items.forEach(item => { if (item.depends_on) { const parent = itemMap.get(item.depends_on); const child = itemMap.get(item.id); if (parent && child) { parent.dependents = parent.dependents || []; parent.dependents.push(child); child.dependencies = child.dependencies || []; child.dependencies.push(parent); } } }); return Array.from(itemMap.values()); } /** * Detect dependency conflicts for selective approval */ export async function detectDependencyConflicts( items: SubmissionItemWithDeps[], selectedItemIds: string[] ): Promise { const conflicts: DependencyConflict[] = []; const selectedSet = new Set(selectedItemIds); for (const item of items) { // Check if parent is rejected but child is selected if (item.depends_on && selectedSet.has(item.id)) { const parent = items.find(i => i.id === item.depends_on); if (parent && (parent.status === 'rejected' || !selectedSet.has(parent.id))) { // Find existing entities that could be linked const suggestions: DependencyConflict['suggestions'] = []; // Suggest creating parent if (parent.status !== 'rejected') { const parentData = typeof parent.item_data === 'object' && parent.item_data !== null && !Array.isArray(parent.item_data) ? parent.item_data as Record : {}; const parentName = 'name' in parentData && typeof parentData.name === 'string' ? parentData.name : 'Unnamed'; suggestions.push({ action: 'create_parent', label: `Also approve ${parent.item_type}: ${parentName}`, }); } // Suggest linking to existing entity if (parent.item_type === 'park') { const parentData = typeof parent.item_data === 'object' && parent.item_data !== null && !Array.isArray(parent.item_data) ? parent.item_data as Record : {}; const parentName = 'name' in parentData && typeof parentData.name === 'string' ? parentData.name : ''; const { data: parks } = await supabase .from('parks') .select('id, name') .ilike('name', `%${parentName}%`) .limit(3); parks?.forEach(park => { suggestions.push({ action: 'link_existing', label: `Link to existing park: ${park.name}`, entityId: park.id, }); }); } suggestions.push({ action: 'escalate', label: 'Escalate to admin for resolution', }); conflicts.push({ itemId: item.id, type: 'missing_parent', message: `Cannot approve ${item.item_type} without its parent ${parent.item_type}`, suggestions, }); } } } return conflicts; } /** * Update individual submission item status * Note: item_data and original_data are read-only (managed via relational tables) */ export async function updateSubmissionItem( itemId: string, updates: Partial ): Promise { // Remove item_data and original_data from updates (managed via relational tables) const { item_data, original_data, ...cleanUpdates } = updates; const { error } = await supabase .from('submission_items') .update(cleanUpdates) .eq('id', itemId); if (error) throw error; } /** * Approve multiple items with dependency handling */ export async function approveSubmissionItems( items: SubmissionItemWithDeps[], userId: string ): Promise { if (!userId) { throw new Error('User authentication required to approve items'); } // Sort by dependency order (parents first) const sortedItems = topologicalSort(items); // Build dependency resolution map const dependencyMap = new Map(); for (const item of sortedItems) { let entityId: string | null = null; let isEdit = false; try { // Determine if this is an edit by checking for entity_id in item_data const itemData = typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) ? item.item_data as Record : {}; isEdit = !!( ('park_id' in itemData && itemData.park_id) || ('ride_id' in itemData && itemData.ride_id) || ('company_id' in itemData && itemData.company_id) || ('ride_model_id' in itemData && itemData.ride_model_id) ); // Create the entity based on type with dependency resolution // PASS sortedItems to enable correct index-based resolution switch (item.item_type) { case 'park': entityId = await createPark(item.item_data, dependencyMap, sortedItems); break; case 'ride': entityId = await createRide(item.item_data, dependencyMap, sortedItems); break; case 'manufacturer': case 'operator': case 'property_owner': case 'designer': entityId = await createCompany(item.item_data, item.item_type, dependencyMap, sortedItems); break; case 'ride_model': entityId = await createRideModel(item.item_data, dependencyMap, sortedItems); break; case 'photo': entityId = await approvePhotos(item.item_data, dependencyMap, userId, item.submission_id, sortedItems); break; } if (!entityId) { throw new Error(`Failed to create ${item.item_type}: no entity ID returned`); } // Update item status await updateSubmissionItem(item.id, { status: 'approved' as const, approved_entity_id: entityId, }); // Create version history (skip for photo type) if (item.item_type !== 'photo') { await createVersionForApprovedItem( item.item_type, entityId, userId, item.submission_id, isEdit ); } // Add to dependency map using item.id as key dependencyMap.set(item.id, entityId); } catch (error: unknown) { handleError(error, { action: 'Approve Submission Items', userId, metadata: { itemCount: items.length, itemType: item.item_type } }); // Update item with error status await updateSubmissionItem(item.id, { status: 'rejected' as const, rejection_reason: `Failed to create entity: ${getErrorMessage(error)}`, }); throw new Error(`Failed to approve ${item.item_type}: ${getErrorMessage(error)}`); } } } /** * Create version history for approved submission item * * NOTE: Versions are now created automatically via database triggers. * This function is no longer needed since the relational versioning system * handles version creation automatically when entities are inserted/updated. * * The trigger `create_relational_version()` reads session variables set by * the edge function and creates versions in the appropriate `*_versions` table. */ async function createVersionForApprovedItem( itemType: string, entityId: string, userId: string, submissionId: string, isEdit: boolean ): Promise { // No-op: Versions are created automatically by triggers // The edge function sets: // - app.current_user_id = original submitter // - app.submission_id = submission ID // Then the trigger creates the version automatically } /** * Topological sort for dependency-ordered processing */ function topologicalSort(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] { const sorted: SubmissionItemWithDeps[] = []; const visited = new Set(); const temp = new Set(); function visit(item: SubmissionItemWithDeps) { if (temp.has(item.id)) { throw new Error('Circular dependency detected'); } if (visited.has(item.id)) return; temp.add(item.id); if (item.dependencies) { item.dependencies.forEach(dep => visit(dep)); } temp.delete(item.id); visited.add(item.id); sorted.push(item); } items.forEach(item => { if (!visited.has(item.id)) { visit(item); } }); return sorted; } /** * Extract image URLs from ImageAssignments structure */ function extractImageAssignments(images: any) { if (!images || !images.uploaded || !Array.isArray(images.uploaded)) { return { banner_image_url: null, banner_image_id: null, card_image_url: null, card_image_id: null, }; } const bannerImage = images.banner_assignment !== null && images.banner_assignment !== undefined ? images.uploaded[images.banner_assignment] : null; const cardImage = images.card_assignment !== null && images.card_assignment !== undefined ? images.uploaded[images.card_assignment] : null; return { banner_image_url: bannerImage?.url || null, banner_image_id: bannerImage?.cloudflare_id || null, card_image_url: cardImage?.url || null, card_image_id: cardImage?.cloudflare_id || null, }; } /** * Helper functions to create entities with dependency resolution */ async function createPark(data: any, dependencyMap: Map, sortedItems: SubmissionItemWithDeps[]): Promise { const { transformParkData, validateSubmissionData } = await import('./entityTransformers'); const { ensureUniqueSlug } = await import('./slugUtils'); // Check if this is an edit (has park_id) const isEdit = !!data.park_id; if (isEdit) { // Handle park edit const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); // Resolve location_id if location data is provided let locationId = resolvedData.location_id; if (resolvedData.location && !locationId) { locationId = await resolveLocationId(resolvedData.location); } // Extract image assignments from ImageAssignments structure const imageData = extractImageAssignments(resolvedData.images); // Update the park const updateData: any = { name: resolvedData.name, slug: resolvedData.slug, description: resolvedData.description || null, park_type: resolvedData.park_type, status: resolvedData.status, opening_date: resolvedData.opening_date || null, closing_date: resolvedData.closing_date || null, website_url: resolvedData.website_url || null, phone: resolvedData.phone || null, email: resolvedData.email || null, operator_id: resolvedData.operator_id || null, property_owner_id: resolvedData.property_owner_id || null, location_id: locationId || null, ...imageData, updated_at: new Date().toISOString() }; const { error } = await supabase .from('parks') .update(updateData) .eq('id', data.park_id); if (error) { handleError(error, { action: 'Update Park', metadata: { parkId: data.park_id, parkName: resolvedData.name } }); throw new Error(`Database error: ${error.message}`); } return data.park_id; } // Handle park creation validateSubmissionData(data, 'Park'); const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); // Resolve location_id if location data is provided let locationId = resolvedData.location_id; if (resolvedData.location && !locationId) { locationId = await resolveLocationId(resolvedData.location); } // Ensure unique slug const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'parks'); resolvedData.slug = uniqueSlug; // Extract image assignments const imageData = extractImageAssignments(resolvedData.images); // Transform to database format const parkData = { ...transformParkData(resolvedData), ...imageData, location_id: locationId || null, }; // Insert into database const { data: park, error } = await supabase .from('parks') .insert(parkData) .select('id') .single(); if (error) { handleError(error, { action: 'Create Park', metadata: { parkName: resolvedData.name } }); throw new Error(`Database error: ${error.message}`); } return park.id; } /** * Resolve location data to a location_id * * SECURITY NOTE: Locations should go through moderation flow. * Current implementation allows moderators to create locations directly during approval. * This is acceptable as only moderators can call this function (via RLS on content_submissions). * * For user-submitted locations in the future, they should be submitted as separate * submission_items with item_type='location' and go through the moderation queue. */ async function resolveLocationId(locationData: any): Promise { if (!locationData || !locationData.latitude || !locationData.longitude) { return null; } // Check if location already exists by coordinates const { data: existingLocation } = await supabase .from('locations') .select('id') .eq('latitude', locationData.latitude) .eq('longitude', locationData.longitude) .maybeSingle(); if (existingLocation) { return existingLocation.id; } // Create new location (moderator has permission via RLS) // FUTURE TODO: Change this to submission flow for user-submitted locations const { data: newLocation, error } = await supabase .from('locations') .insert({ name: locationData.name, city: locationData.city || null, state_province: locationData.state_province || null, country: locationData.country, postal_code: locationData.postal_code || null, latitude: locationData.latitude, longitude: locationData.longitude, timezone: locationData.timezone || null, }) .select('id') .single(); if (error) { handleError(error, { action: 'Create Location', metadata: { locationData } }); throw new Error(`Failed to create location: ${error.message}`); } return newLocation.id; } async function createRide(data: any, dependencyMap: Map, sortedItems: SubmissionItemWithDeps[]): Promise { const { transformRideData, validateSubmissionData } = await import('./entityTransformers'); const { ensureUniqueSlug } = await import('./slugUtils'); // Check if this is an edit (has ride_id) const isEdit = !!data.ride_id; if (isEdit) { // Handle ride edit const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); // Extract image assignments from ImageAssignments structure const imageData = extractImageAssignments(resolvedData.images); // Update the ride const updateData: any = { name: resolvedData.name, slug: resolvedData.slug, description: resolvedData.description, category: resolvedData.category, ride_sub_type: resolvedData.ride_sub_type, status: resolvedData.status, opening_date: resolvedData.opening_date, closing_date: resolvedData.closing_date, height_requirement: resolvedData.height_requirement, age_requirement: resolvedData.age_requirement, capacity_per_hour: resolvedData.capacity_per_hour, duration_seconds: resolvedData.duration_seconds, max_speed_kmh: resolvedData.max_speed_kmh, max_height_meters: resolvedData.max_height_meters, length_meters: resolvedData.length_meters, inversions: resolvedData.inversions, coaster_type: resolvedData.coaster_type, seating_type: resolvedData.seating_type, intensity_level: resolvedData.intensity_level, drop_height_meters: resolvedData.drop_height_meters, max_g_force: resolvedData.max_g_force, manufacturer_id: resolvedData.manufacturer_id, ride_model_id: resolvedData.ride_model_id, ...imageData, updated_at: new Date().toISOString() }; const { error } = await supabase .from('rides') .update(updateData) .eq('id', data.ride_id); if (error) { handleError(error, { action: 'Update Ride', metadata: { rideId: data.ride_id, rideName: resolvedData.name } }); throw new Error(`Database error: ${error.message}`); } return data.ride_id; } // Handle ride creation validateSubmissionData(data, 'Ride'); const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); if (!resolvedData.park_id) { throw new Error('Ride must be associated with a park'); } const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'rides'); resolvedData.slug = uniqueSlug; // Extract image assignments const imageData = extractImageAssignments(resolvedData.images); // Transform to database format const rideData = { ...transformRideData(resolvedData), ...imageData }; const { data: ride, error } = await supabase .from('rides') .insert(rideData) .select('id') .single(); if (error) { handleError(error, { action: 'Create Ride', metadata: { rideName: resolvedData.name } }); throw new Error(`Database error: ${error.message}`); } return ride.id; } async function createCompany( data: any, companyType: string, dependencyMap: Map, sortedItems: SubmissionItemWithDeps[] ): Promise { const { transformCompanyData, validateSubmissionData } = await import('./entityTransformers'); const { ensureUniqueSlug } = await import('./slugUtils'); // Check if this is an edit (has company_id) const isEdit = !!data.id; if (isEdit) { // Handle company edit const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); // Extract image assignments from ImageAssignments structure const imageData = extractImageAssignments(resolvedData.images); // Update the company const updateData: any = { name: resolvedData.name, slug: resolvedData.slug, description: resolvedData.description || null, website_url: resolvedData.website_url || null, founded_year: resolvedData.founded_year || null, headquarters_location: resolvedData.headquarters_location || null, ...imageData, updated_at: new Date().toISOString() }; const { error } = await supabase .from('companies') .update(updateData) .eq('id', data.id); if (error) { handleError(error, { action: 'Update Company', metadata: { companyId: data.id, companyName: resolvedData.name, companyType } }); throw new Error(`Database error: ${error.message}`); } return data.id; } // Handle company creation validateSubmissionData(data, 'Company'); const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'companies'); resolvedData.slug = uniqueSlug; // Extract image assignments const imageData = extractImageAssignments(resolvedData.images); // Type guard for company type type ValidCompanyType = 'manufacturer' | 'designer' | 'operator' | 'property_owner'; const validCompanyTypes: ValidCompanyType[] = ['manufacturer', 'designer', 'operator', 'property_owner']; if (!validCompanyTypes.includes(companyType as ValidCompanyType)) { throw new Error(`Invalid company type: ${companyType}`); } // Transform to database format const companyData = { ...transformCompanyData(resolvedData, companyType as ValidCompanyType), ...imageData }; const { data: company, error } = await supabase .from('companies') .insert(companyData) .select('id') .single(); if (error) { handleError(error, { action: 'Create Company', metadata: { companyName: resolvedData.name, companyType } }); throw new Error(`Database error: ${error.message}`); } return company.id; } async function createRideModel(data: any, dependencyMap: Map, sortedItems: SubmissionItemWithDeps[]): Promise { const { transformRideModelData, validateSubmissionData } = await import('./entityTransformers'); const { ensureUniqueSlug } = await import('./slugUtils'); // Check if this is an edit (has ride_model_id) const isEdit = !!data.ride_model_id; if (isEdit) { // Handle ride model edit const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); // Extract image assignments from ImageAssignments structure const imageData = extractImageAssignments(resolvedData.images); // Update the ride model const updateData: any = { name: resolvedData.name, slug: resolvedData.slug, category: resolvedData.category, ride_type: resolvedData.ride_type || null, description: resolvedData.description || null, manufacturer_id: resolvedData.manufacturer_id, ...imageData, updated_at: new Date().toISOString() }; const { error } = await supabase .from('ride_models') .update(updateData) .eq('id', data.ride_model_id); if (error) { handleError(error, { action: 'Update Ride Model', metadata: { rideModelId: data.ride_model_id, modelName: resolvedData.name } }); throw new Error(`Database error: ${error.message}`); } return data.ride_model_id; } // Handle ride model creation validateSubmissionData(data, 'Ride Model'); const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); // Validate manufacturer_id is present (required for ride models) if (!resolvedData.manufacturer_id) { throw new Error('Ride model must be associated with a manufacturer'); } // Ensure unique slug const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'ride_models'); resolvedData.slug = uniqueSlug; // Extract image assignments const imageData = extractImageAssignments(resolvedData.images); // Transform to database format const modelData = { ...transformRideModelData(resolvedData), ...imageData }; // Insert into database const { data: model, error } = await supabase .from('ride_models') .insert(modelData) .select('id') .single(); if (error) { handleError(error, { action: 'Create Ride Model', metadata: { modelName: resolvedData.name } }); throw new Error(`Database error: ${error.message}`); } return model.id; } async function approvePhotos(data: any, dependencyMap: Map, userId: string, submissionId: string, sortedItems: SubmissionItemWithDeps[]): Promise { // Photos are already uploaded to Cloudflare // Resolve dependencies for entity associations const resolvedData = resolveDependencies(data, dependencyMap, sortedItems); if (!resolvedData.photos || !Array.isArray(resolvedData.photos) || resolvedData.photos.length === 0) { throw new Error('No photos found in submission'); } const { entity_id, context, park_id, ride_id, company_id } = resolvedData; // Determine entity_id and entity_type let finalEntityId = entity_id; let entityType = context; // Support legacy field names if (!finalEntityId) { if (park_id) { finalEntityId = park_id; entityType = 'park'; } else if (ride_id) { finalEntityId = ride_id; entityType = 'ride'; } else if (company_id) { finalEntityId = company_id; // Need to determine company type from database } } if (!finalEntityId || !entityType) { throw new Error('Missing entity_id or context in photo submission'); } // Insert photos into the photos table const photosToInsert = resolvedData.photos.map((photo: any, index: number) => { // Extract CloudFlare image ID from URL if not provided // Supports both old imagedelivery.net and new cdn.thrillwiki.com URLs let cloudflareImageId = photo.cloudflare_image_id; if (!cloudflareImageId && photo.url) { cloudflareImageId = extractCloudflareImageId(photo.url); // Fallback: parse from URL structure if (!cloudflareImageId) { const urlParts = photo.url.split('/'); cloudflareImageId = urlParts[urlParts.length - 2]; } } return { cloudflare_image_id: cloudflareImageId, cloudflare_image_url: photo.url, entity_type: entityType, entity_id: finalEntityId, title: photo.title || resolvedData.title, caption: photo.caption, photographer_credit: photo.photographer_credit, date_taken: photo.date || photo.date_taken, order_index: photo.order !== undefined ? photo.order : index, is_featured: index === 0, // First photo is featured by default submission_id: submissionId, submitted_by: userId, approved_by: userId, approved_at: new Date().toISOString(), }; }); const { data: insertedPhotos, error } = await supabase .from('photos') .insert(photosToInsert) .select(); if (error) { handleError(error, { action: 'Insert Photos', metadata: { photoCount: photosToInsert.length, entityType, entityId: finalEntityId } }); throw new Error(`Database error: ${error.message}`); } // Update entity's featured image if this is the first photo if (insertedPhotos && insertedPhotos.length > 0) { const firstPhoto = insertedPhotos[0]; await updateEntityFeaturedImage( entityType, finalEntityId, firstPhoto.cloudflare_image_url, firstPhoto.cloudflare_image_id ); } // Return the first photo URL for backwards compatibility return resolvedData.photos[0].url; } /** * Update entity's featured image fields */ async function updateEntityFeaturedImage( entityType: string, entityId: string, imageUrl: string, imageId: string ): Promise { try { // Update based on entity type if (entityType === 'park') { const { data: existingPark } = await supabase .from('parks') .select('card_image_url') .eq('id', entityId) .single(); if (existingPark && !existingPark.card_image_url) { await supabase .from('parks') .update({ card_image_url: imageUrl, card_image_id: imageId, updated_at: new Date().toISOString(), }) .eq('id', entityId); } } else if (entityType === 'ride') { const { data: existingRide } = await supabase .from('rides') .select('card_image_url') .eq('id', entityId) .single(); if (existingRide && !existingRide.card_image_url) { await supabase .from('rides') .update({ card_image_url: imageUrl, card_image_id: imageId, updated_at: new Date().toISOString(), }) .eq('id', entityId); } } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) { const { data: existingCompany } = await supabase .from('companies') .select('logo_url') .eq('id', entityId) .single(); if (existingCompany && !existingCompany.logo_url) { await supabase .from('companies') .update({ logo_url: imageUrl, updated_at: new Date().toISOString(), }) .eq('id', entityId); } } } catch (error) { handleNonCriticalError(error, { action: 'Update Entity Featured Image', metadata: { entityType, entityId } }); } } /** * Resolve dependency references in item_data by looking up approved entity IDs * Replaces temporary references (_temp_*_ref) with actual database entity IDs * * FIXED: Now uses sortedItems array for stable index-based resolution * instead of unreliable Array.from(dependencyMap.keys())[refIndex] */ function resolveDependencies(data: any, dependencyMap: Map, sortedItems: SubmissionItemWithDeps[]): any { const resolved = { ...data }; // List of foreign key fields that may need resolution const foreignKeys = [ 'park_id', 'manufacturer_id', 'designer_id', 'operator_id', 'property_owner_id', 'ride_model_id', 'location_id', ]; // Resolve temporary references using sortedItems array (FIXED) if (resolved._temp_park_ref !== undefined) { const refIndex = resolved._temp_park_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.park_id = dependencyMap.get(refItemId); } } delete resolved._temp_park_ref; } if (resolved._temp_manufacturer_ref !== undefined) { const refIndex = resolved._temp_manufacturer_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.manufacturer_id = dependencyMap.get(refItemId); } } delete resolved._temp_manufacturer_ref; } if (resolved._temp_designer_ref !== undefined) { const refIndex = resolved._temp_designer_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.designer_id = dependencyMap.get(refItemId); } } delete resolved._temp_designer_ref; } if (resolved._temp_operator_ref !== undefined) { const refIndex = resolved._temp_operator_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.operator_id = dependencyMap.get(refItemId); } } delete resolved._temp_operator_ref; } if (resolved._temp_property_owner_ref !== undefined) { const refIndex = resolved._temp_property_owner_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.property_owner_id = dependencyMap.get(refItemId); } } delete resolved._temp_property_owner_ref; } if (resolved._temp_ride_model_ref !== undefined) { const refIndex = resolved._temp_ride_model_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.ride_model_id = dependencyMap.get(refItemId); } } delete resolved._temp_ride_model_ref; } // Resolve each foreign key if it's a submission item ID for (const key of foreignKeys) { if (resolved[key] && dependencyMap.has(resolved[key])) { resolved[key] = dependencyMap.get(resolved[key]); } } return resolved; } /** * Reset rejected items back to pending status */ export async function resetRejectedItemsToPending( submissionId: string ): Promise { // Reset rejected submission items to pending const { error: itemsError } = await supabase .from('submission_items') .update({ status: 'pending' as const, rejection_reason: null, updated_at: new Date().toISOString() }) .eq('submission_id', submissionId) .eq('status', 'rejected'); if (itemsError) { throw new Error(`Failed to reset items: ${itemsError.message}`); } // Reset parent submission to pending const { error: submissionError } = await supabase .from('content_submissions') .update({ status: 'pending' as const, reviewed_at: null, reviewer_id: null, reviewer_notes: null, updated_at: new Date().toISOString() }) .eq('id', submissionId) .in('status', ['rejected', 'partially_approved']); if (submissionError) { throw new Error(`Failed to reset submission: ${submissionError.message}`); } } /** * Reject multiple items with optional cascade to dependents */ export async function rejectSubmissionItems( items: SubmissionItemWithDeps[], reason: string, userId: string, cascade: boolean = true ): Promise { if (!userId) { throw new Error('User authentication required to reject items'); } if (!reason || !reason.trim()) { throw new Error('Rejection reason is required'); } const itemsToReject = new Set(items.map(i => i.id)); // If cascading, collect all dependent items if (cascade) { for (const item of items) { await collectDependents(item, itemsToReject); } } // Update all items to rejected status const updates = Array.from(itemsToReject).map(async (itemId) => { const { error } = await supabase .from('submission_items') .update({ status: 'rejected' as const, rejection_reason: reason, updated_at: new Date().toISOString(), }) .eq('id', itemId); if (error) { handleNonCriticalError(error, { action: 'Reject Submission Item', metadata: { itemId } }); throw error; } }); await Promise.all(updates); // Update parent submission status const submissionId = items[0]?.submission_id; if (submissionId) { await updateSubmissionStatusAfterRejection(submissionId); } } async function collectDependents( item: SubmissionItemWithDeps, rejectedSet: Set ): Promise { if (item.dependents && item.dependents.length > 0) { for (const dependent of item.dependents) { rejectedSet.add(dependent.id); await collectDependents(dependent, rejectedSet); } } } async function updateSubmissionStatusAfterRejection(submissionId: string): Promise { // Get all items for this submission const { data: allItems, error: fetchError } = await supabase .from('submission_items') .select('status') .eq('submission_id', submissionId); if (fetchError) { handleNonCriticalError(fetchError, { action: 'Fetch Submission Items for Status Update', metadata: { submissionId } }); return; } if (!allItems || allItems.length === 0) return; const statuses = allItems.map(i => i.status); const allRejected = statuses.every(s => s === 'rejected'); const allApproved = statuses.every(s => s === 'approved'); const anyPending = statuses.some(s => s === 'pending'); let newStatus: string; if (allRejected) { newStatus = 'rejected'; } else if (allApproved) { newStatus = 'approved'; } else if (anyPending) { newStatus = 'pending'; } else { newStatus = 'partially_approved'; } const { error: updateError } = await supabase .from('content_submissions') .update({ status: newStatus, updated_at: new Date().toISOString(), }) .eq('id', submissionId); if (updateError) { handleNonCriticalError(updateError, { action: 'Update Submission Status After Rejection', metadata: { submissionId, newStatus } }); } } /** * Edit a submission item - moderators edit directly, users auto-escalate */ export async function editSubmissionItem( itemId: string, newData: any, userId: string ): Promise { if (!userId) { throw new Error('User authentication required to edit items'); } // Get current item with relational data const { data: currentItem, error: fetchError } = await supabase .from('submission_items') .select(` *, submission:content_submissions!submission_id(user_id, status) `) .eq('id', itemId) .single(); if (fetchError) throw fetchError; // Check if user has moderator/admin permissions const { data: userRoles } = await supabase .from('user_roles') .select('role') .eq('user_id', userId); const isModerator = userRoles?.some(r => ['moderator', 'admin', 'superuser'].includes(r.role) ); // Determine original action type - preserve submission intent const originalAction: 'create' | 'edit' | 'delete' = (currentItem.action_type as 'create' | 'edit' | 'delete') || 'create'; if (isModerator) { // Track edit in edit history table const changes = { timestamp: new Date().toISOString(), editor: userId }; // Moderators can edit directly - update relational table // Note: item_data and original_data columns have been removed // Updates now go directly to relational tables (park_submissions, ride_submissions, etc.) const { error: updateError } = await supabase .from('submission_items') .update({ action_type: originalAction, updated_at: new Date().toISOString(), }) .eq('id', itemId); if (updateError) throw updateError; // Update relational table with new data based on item type if (currentItem.item_type === 'park') { // For parks, store location in temp_location_data if provided const updateData: any = { ...newData }; // If location object is provided, store it in temp_location_data if (newData.location) { updateData.temp_location_data = { name: newData.location.name, city: newData.location.city || null, state_province: newData.location.state_province || null, country: newData.location.country, latitude: newData.location.latitude, longitude: newData.location.longitude, timezone: newData.location.timezone || null, postal_code: newData.location.postal_code || null, display_name: newData.location.display_name }; delete updateData.location; // Remove the nested object } // Update park_submissions table const { error: parkUpdateError } = await supabase .from('park_submissions') .update(updateData) .eq('submission_id', currentItem.submission_id); if (parkUpdateError) throw parkUpdateError; } else if (currentItem.item_type === 'ride') { const { error: rideUpdateError } = await supabase .from('ride_submissions') .update(newData) .eq('submission_id', currentItem.submission_id); if (rideUpdateError) throw rideUpdateError; } else if (currentItem.item_type === 'manufacturer') { const { error: manufacturerUpdateError } = await supabase .from('company_submissions') .update(newData) .eq('submission_id', currentItem.submission_id) .eq('company_type', 'manufacturer'); if (manufacturerUpdateError) throw manufacturerUpdateError; } else if (currentItem.item_type === 'designer') { const { error: designerUpdateError } = await supabase .from('company_submissions') .update(newData) .eq('submission_id', currentItem.submission_id) .eq('company_type', 'designer'); if (designerUpdateError) throw designerUpdateError; } else if (currentItem.item_type === 'operator') { const { error: operatorUpdateError } = await supabase .from('company_submissions') .update(newData) .eq('submission_id', currentItem.submission_id) .eq('company_type', 'operator'); if (operatorUpdateError) throw operatorUpdateError; } else if (currentItem.item_type === 'property_owner') { const { error: ownerUpdateError } = await supabase .from('company_submissions') .update(newData) .eq('submission_id', currentItem.submission_id) .eq('company_type', 'property_owner'); if (ownerUpdateError) throw ownerUpdateError; } else if (currentItem.item_type === 'ride_model') { const { error: modelUpdateError } = await supabase .from('ride_model_submissions') .update(newData) .eq('submission_id', currentItem.submission_id); if (modelUpdateError) throw modelUpdateError; } // Phase 4: Record edit history const { data: historyData, error: historyError } = await supabase .from('item_edit_history') .insert({ item_id: itemId, edited_by: userId, changed_fields: Object.keys(changes), edit_reason: 'Direct edit by moderator', }) .select('id') .single(); // Insert field changes relationally (NO JSON!) if (!historyError && historyData) { const fieldChanges = Object.entries(changes).map(([fieldName, change]: [string, any]) => ({ edit_history_id: historyData.id, field_name: fieldName, old_value: String(change.old ?? ''), new_value: String(change.new ?? ''), })); const { error: fieldChangesError } = await supabase .from('item_field_changes') .insert(fieldChanges); if (fieldChangesError) { handleNonCriticalError(fieldChangesError, { action: 'Record Field Changes', metadata: { editHistoryId: historyData.id } }); } } if (historyError) { handleNonCriticalError(historyError, { action: 'Record Edit History', metadata: { itemId, editorId: userId } }); // Don't fail the whole operation if history tracking fails } // CRITICAL: Create version history if this is an entity edit (not photo) // Only create version if this item has already been approved (has approved_entity_id) if (currentItem.item_type !== 'photo' && currentItem.approved_entity_id) { try { await createVersionForApprovedItem( currentItem.item_type, currentItem.approved_entity_id, userId, currentItem.submission_id, true // isEdit = true ); } catch (versionError) { handleNonCriticalError(versionError, { action: 'Create Version for Manual Edit', metadata: { itemType: currentItem.item_type, entityId: currentItem.approved_entity_id } }); // Don't fail the entire operation, just log the error // The edit itself is still saved, just without version history } } // Type guard for submission with user_id interface SubmissionWithUser { user_id: string; [key: string]: any; } // Log admin action await supabase .from('admin_audit_log') .insert({ admin_user_id: userId, target_user_id: (currentItem.submission as SubmissionWithUser).user_id, action: 'edit_submission_item', details: { item_id: itemId, item_type: currentItem.item_type, changes: 'Item data updated with version history', version_created: !!(currentItem.approved_entity_id && currentItem.item_type !== 'photo'), }, }); } else { // Regular users: update submission items and auto-escalate // Note: item_data and original_data columns have been removed const { error: updateError } = await supabase .from('submission_items') .update({ action_type: originalAction, updated_at: new Date().toISOString(), }) .eq('id', itemId); if (updateError) throw updateError; // Auto-escalate the parent submission await escalateSubmission( currentItem.submission_id, `User requested edit to ${currentItem.item_type}`, userId ); } } /** * Escalate submission for admin review */ export async function escalateSubmission( submissionId: string, reason: string, userId: string ): Promise { if (!userId) { throw new Error('User authentication required to escalate submission'); } // Fetch submission details for audit log const { data: submission } = await supabase .from('content_submissions') .select('user_id, submission_type') .eq('id', submissionId) .single(); const { error } = await supabase .from('content_submissions') .update({ status: 'pending' as const, escalation_reason: reason, escalated_by: userId, reviewer_notes: `Escalated: ${reason}`, }) .eq('id', submissionId); if (error) throw error; // Log audit trail for escalation if (submission) { try { await supabase.rpc('log_admin_action', { _admin_user_id: userId, _target_user_id: submission.user_id, _action: 'submission_escalated', _details: { submission_id: submissionId, submission_type: submission.submission_type, escalation_reason: reason } }); } catch (auditError) { handleNonCriticalError(auditError, { action: 'Log Escalation Audit', metadata: { submissionId } }); } } } /** * Phase 4: Fetch edit history for a submission item * Returns all edits with editor information */ export async function fetchEditHistory(itemId: string) { try { const { data, error } = await supabase .from('item_edit_history') .select(` id, item_id, edited_at, edit_reason, changed_fields, field_changes:item_field_changes( id, field_name, old_value, new_value ), editor:profiles!item_edit_history_edited_by_fkey( user_id, username, display_name, avatar_url ) `) .eq('item_id', itemId) .order('edited_at', { ascending: false }); if (error) throw error; return data || []; } catch (error: unknown) { handleNonCriticalError(error, { action: 'Fetch Edit History', metadata: { itemId } }); return []; } } /** * Check if a submission has been modified since the client last loaded it * Used for optimistic locking to prevent concurrent edit conflicts */ export async function checkSubmissionConflict( submissionId: string, clientLastModified: string ): Promise { try { const { data, error } = await supabase .from('content_submissions') .select(` last_modified_at, last_modified_by, profiles:last_modified_by ( username, display_name, avatar_url ) `) .eq('id', submissionId) .single(); if (error) throw error; if (!data.last_modified_at) { return { hasConflict: false, clientVersion: { last_modified_at: clientLastModified }, }; } const serverTimestamp = new Date(data.last_modified_at).getTime(); const clientTimestamp = new Date(clientLastModified).getTime(); return { hasConflict: serverTimestamp > clientTimestamp, clientVersion: { last_modified_at: clientLastModified, }, serverVersion: { last_modified_at: data.last_modified_at, last_modified_by: (data.last_modified_by ?? undefined) as string, modified_by_profile: data.profiles as any, }, }; } catch (error: unknown) { handleNonCriticalError(error, { action: 'Check Submission Conflict', metadata: { submissionId } }); throw error; } } /** * Fetch recent versions of submission items for conflict resolution */ export async function fetchSubmissionVersions( submissionId: string, limit: number = 10 ) { try { // Get all item IDs for this submission const { data: items, error: itemsError } = await supabase .from('submission_items') .select('id') .eq('submission_id', submissionId); if (itemsError) throw itemsError; if (!items || items.length === 0) return []; const itemIds = items.map(i => i.id); // Fetch edit history for all items const { data, error } = await supabase .from('item_edit_history') .select(` id, item_id, changes, edited_at, editor:profiles!item_edit_history_editor_id_fkey ( user_id, username, display_name, avatar_url ) `) .in('item_id', itemIds) .order('edited_at', { ascending: false }) .limit(limit); if (error) throw error; return data || []; } catch (error: unknown) { handleNonCriticalError(error, { action: 'Fetch Submission Versions', metadata: { submissionId } }); return []; } }