Files
thrilltrack-explorer/src/lib/submissionItemsService.ts
2025-11-03 18:47:59 +00:00

1536 lines
46 KiB
TypeScript

import { supabase } from '@/integrations/supabase/client';
import { getErrorMessage } from './errorHandler';
import { logger } from './logger';
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<SubmissionItemWithDeps[]> {
const { data, error } = await supabase
.from('submission_items')
.select(`
*,
park_submission:park_submissions!item_data_id(*),
ride_submission:ride_submissions!item_data_id(*),
photo_submission:photo_submissions!item_data_id(
*,
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 'photo':
item_data = {
...(item as any).photo_submission,
photos: (item as any).photo_submission?.photo_items || []
};
break;
default:
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<DependencyConflict[]> {
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<string, unknown>
: {};
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<string, unknown>
: {};
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<SubmissionItemWithDeps>
): Promise<void> {
// 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<void> {
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<string, string>();
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<string, unknown>
: {};
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) {
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<void> {
// 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
logger.debug('Version will be created automatically by trigger', { itemType, entityId });
}
/**
* Topological sort for dependency-ordered processing
*/
function topologicalSort(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] {
const sorted: SubmissionItemWithDeps[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
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<string, string>, sortedItems: SubmissionItemWithDeps[]): Promise<string> {
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) {
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, 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) {
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<string | null> {
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<string, string>, sortedItems: SubmissionItemWithDeps[]): Promise<string> {
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) {
logger.error('Error updating ride', { error: error.message, rideId: data.ride_id });
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) {
logger.error('Error creating ride', { error: error.message, rideName: resolvedData.name });
throw new Error(`Database error: ${error.message}`);
}
return ride.id;
}
async function createCompany(
data: any,
companyType: string,
dependencyMap: Map<string, string>,
sortedItems: SubmissionItemWithDeps[]
): Promise<string> {
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) {
logger.error('Error updating company', { error: error.message, companyId: data.id });
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) {
logger.error('Error creating company', { error: error.message, companyName: resolvedData.name, companyType });
throw new Error(`Database error: ${error.message}`);
}
return company.id;
}
async function createRideModel(data: any, dependencyMap: Map<string, string>, sortedItems: SubmissionItemWithDeps[]): Promise<string> {
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) {
logger.error('Error updating ride model', { error: error.message, rideModelId: data.ride_model_id });
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) {
logger.error('Error creating ride model', { error: error.message, modelName: resolvedData.name });
throw new Error(`Database error: ${error.message}`);
}
return model.id;
}
async function approvePhotos(data: any, dependencyMap: Map<string, string>, userId: string, submissionId: string, sortedItems: SubmissionItemWithDeps[]): Promise<string> {
// 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) {
logger.error('Error inserting photos', { error: error.message, 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<void> {
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) {
logger.error('Error updating entity featured image', { error, 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<string, string>, 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<void> {
// 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<void> {
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<string>(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) {
logger.error('Error rejecting item', { error, 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<string>
): Promise<void> {
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<void> {
// Get all items for this submission
const { data: allItems, error: fetchError } = await supabase
.from('submission_items')
.select('status')
.eq('submission_id', submissionId);
if (fetchError) {
logger.error('Error fetching submission items', { error: fetchError, 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) {
logger.error('Error updating submission status', { error: updateError, submissionId });
}
}
/**
* Edit a submission item - moderators edit directly, users auto-escalate
*/
export async function editSubmissionItem(
itemId: string,
newData: any,
userId: string
): Promise<void> {
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;
// Phase 4: Record edit history
const { error: historyError } = await supabase
.from('item_edit_history')
.insert({
item_id: itemId,
editor_id: userId,
changes: changes,
});
if (historyError) {
logger.error('Failed to record edit history', {
itemId,
editorId: userId,
error: historyError.message,
});
// 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) {
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 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<void> {
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 });
}
}
}
/**
* 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,
changes,
edited_at,
editor:profiles!item_edit_history_editor_id_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) {
logger.error('Error fetching edit history', {
itemId,
error: getErrorMessage(error),
});
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<ConflictCheckResult> {
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) {
logger.error('Error checking submission conflict', {
submissionId,
error: getErrorMessage(error),
});
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) {
logger.error('Error fetching submission versions', {
submissionId,
error: getErrorMessage(error),
});
return [];
}
}