import { supabase } from '@/integrations/supabase/client'; import { getErrorMessage } from './errorHandler'; import { logger } from './logger'; // Core submission item interface with dependencies // Type safety for item_data will be added in Phase 5 after fixing components export interface SubmissionItemWithDeps { id: string; submission_id: string; item_type: string; item_data: any; // Complex nested structure - will be typed properly in Phase 5 original_data: any; // Complex nested structure - will be typed properly in Phase 5 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; }>; } /** * Fetch all items for a submission with their dependencies */ export async function fetchSubmissionItems(submissionId: string): Promise { const { data, error } = await supabase .from('submission_items') .select('*') .eq('submission_id', submissionId) .order('order_index', { ascending: true }); if (error) throw error; // Cast the data to the correct type return (data || []).map(item => ({ ...item, 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: [], dependents: [] }])); // 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') { suggestions.push({ action: 'create_parent', label: `Also approve ${parent.item_type}: ${parent.item_data.name}`, }); } // Suggest linking to existing entity if (parent.item_type === 'park') { const { data: parks } = await supabase .from('parks') .select('id, name') .ilike('name', `%${parent.item_data.name}%`) .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 */ export async function updateSubmissionItem( itemId: string, updates: Partial ): Promise { const { error } = await supabase .from('submission_items') .update(updates) .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 isEdit = !!( item.item_data.park_id || item.item_data.ride_id || item.item_data.company_id || item.item_data.ride_model_id ); // Create the entity based on type with dependency resolution switch (item.item_type) { case 'park': entityId = await createPark(item.item_data, dependencyMap); break; case 'ride': entityId = await createRide(item.item_data, dependencyMap); break; case 'manufacturer': case 'operator': case 'property_owner': case 'designer': entityId = await createCompany(item.item_data, item.item_type, dependencyMap); break; case 'ride_model': entityId = await createRideModel(item.item_data, dependencyMap); break; case 'photo': entityId = await approvePhotos(item.item_data, dependencyMap, userId, item.submission_id); 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 for child items dependencyMap.set(item.id, entityId); } catch (error: unknown) { const errorMsg = getErrorMessage(error); logger.error('Error approving items', { action: 'approve_submission_items', error: errorMsg, userId, itemCount: items.length }); // Update item with error status await updateSubmissionItem(item.id, { status: 'rejected' as const, rejection_reason: `Failed to create entity: ${errorMsg}`, }); throw new Error(`Failed to approve ${item.item_type}: ${errorMsg}`); } } } /** * 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 console.debug( `Version will be created automatically by trigger for ${itemType} ${entityId}` ); } /** * 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): 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); // 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) { logger.error('Error updating park', { action: 'update_park', parkId: data.park_id, error: error.message }); throw new Error(`Database error: ${error.message}`); } return data.park_id; } // Handle park creation validateSubmissionData(data, 'Park'); const resolvedData = resolveDependencies(data, dependencyMap); // 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) { logger.error('Error creating park', { action: 'create_park', parkName: resolvedData.name, error: error.message }); 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) { logger.error('Error creating location', { action: 'create_location', locationData, error: error.message }); throw new Error(`Failed to create location: ${error.message}`); } return newLocation.id; } async function createRide(data: any, dependencyMap: Map): 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); // 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) { console.error('Error updating ride:', error); throw new Error(`Database error: ${error.message}`); } return data.ride_id; } // Handle ride creation validateSubmissionData(data, 'Ride'); const resolvedData = resolveDependencies(data, dependencyMap); 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) { console.error('Error creating ride:', error); throw new Error(`Database error: ${error.message}`); } return ride.id; } async function createCompany( data: any, companyType: string, dependencyMap: Map ): 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); // 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) { console.error('Error updating company:', error); throw new Error(`Database error: ${error.message}`); } return data.id; } // Handle company creation validateSubmissionData(data, 'Company'); const resolvedData = resolveDependencies(data, dependencyMap); 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) { console.error('Error creating company:', error); throw new Error(`Database error: ${error.message}`); } return company.id; } async function createRideModel(data: any, dependencyMap: Map): Promise { const { transformRideModelData, validateSubmissionData } = await import('./entityTransformers'); const { ensureUniqueSlug } = await import('./slugUtils'); // Validate input data validateSubmissionData(data, 'Ride Model'); // Resolve dependencies const resolvedData = resolveDependencies(data, dependencyMap); // 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; // Transform to database format const modelData = transformRideModelData(resolvedData); // Insert into database const { data: model, error } = await supabase .from('ride_models') .insert(modelData) .select('id') .single(); if (error) { console.error('Error creating ride model:', error); throw new Error(`Database error: ${error.message}`); } return model.id; } async function approvePhotos(data: any, dependencyMap: Map, userId: string, submissionId: string): Promise { // Photos are already uploaded to Cloudflare // Resolve dependencies for entity associations const resolvedData = resolveDependencies(data, dependencyMap); 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 let cloudflareImageId = photo.cloudflare_image_id; if (!cloudflareImageId && photo.url) { // URL format: https://imagedelivery.net/{account_hash}/{image_id}/{variant} 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) { console.error('Error inserting photos:', error); 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) { console.error(`Error updating ${entityType} featured image:`, error); } } /** * Resolve dependency references in submission data * Replaces submission item IDs with actual database entity IDs */ function resolveDependencies(data: any, dependencyMap: Map): 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 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) { console.error(`Error rejecting item ${itemId}:`, error); 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) { console.error('Error fetching submission items:', fetchError); 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) { console.error('Error updating submission status:', updateError); } } /** * 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 to preserve original_data const { data: currentItem, error: fetchError } = await supabase .from('submission_items') .select('*, submission:content_submissions(user_id)') .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) ); // Preserve original_data if not already set const originalData = currentItem.original_data || currentItem.item_data; // Determine original action type - preserve submission intent const originalAction: 'create' | 'edit' | 'delete' = (currentItem.action_type as 'create' | 'edit' | 'delete') || ((currentItem.original_data && Object.keys(currentItem.original_data).length > 0) ? 'edit' : 'create'); if (isModerator) { // Moderators can edit directly const { error: updateError } = await supabase .from('submission_items') .update({ item_data: newData, original_data: originalData, action_type: originalAction, // Preserve original submission intent updated_at: new Date().toISOString(), }) .eq('id', itemId); if (updateError) throw updateError; // 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) { logger.error('Failed to create version for manual edit', { action: 'create_version_for_edit', 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 data and auto-escalate const { error: updateError } = await supabase .from('submission_items') .update({ item_data: newData, original_data: originalData, action_type: originalAction, // Preserve original submission intent 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) { logger.error('Failed to log escalation audit', { error: auditError }); } } }