Files
thrilltrack-explorer/src-old/lib/submissionItemsService.ts

1906 lines
58 KiB
TypeScript

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<SubmissionItemWithDeps[]> {
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) {
handleError(error, {
action: 'Fetch Submission Items',
metadata: { submissionId }
});
throw error;
}
// Transform data to include relational data as item_data
return await Promise.all((data || []).map(async item => {
let item_data: unknown;
switch (item.item_type) {
case 'park': {
const parkSub = (item as any).park_submission;
// Fetch location from park_submission_locations if available
let locationData: any = null;
if (parkSub?.id) {
const { data, error: locationError } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', parkSub.id)
.maybeSingle();
if (locationError) {
handleNonCriticalError(locationError, {
action: 'Fetch Park Submission Location',
metadata: { parkSubmissionId: parkSub.id, submissionId }
});
// Continue without location data - non-critical
} else {
locationData = data;
}
}
item_data = {
...parkSub,
// Transform park_submission_location → location for form compatibility
location: locationData || undefined
};
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<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 and data
*/
export async function updateSubmissionItem(
itemId: string,
updates: Partial<SubmissionItemWithDeps>
): Promise<void> {
const { item_data, original_data, ...cleanUpdates } = updates;
// Log submission item update start
console.info('[Submission Flow] Update item start', {
itemId,
hasItemData: !!item_data,
statusUpdate: cleanUpdates.status,
timestamp: new Date().toISOString()
});
// Update submission_items table
const { error } = await supabase
.from('submission_items')
.update(cleanUpdates)
.eq('id', itemId);
if (error) {
handleError(error, {
action: 'Update Submission Item',
metadata: { itemId, updates: cleanUpdates }
});
throw error;
}
// If item_data is provided, update the relational table
if (item_data !== undefined) {
// Fetch the item to get its type and foreign keys
const { data: item, error: fetchError } = await supabase
.from('submission_items')
.select('item_type, park_submission_id, ride_submission_id, company_submission_id, ride_model_submission_id, timeline_event_submission_id, photo_submission_id')
.eq('id', itemId)
.single();
if (fetchError) throw fetchError;
if (!item) throw new Error(`Submission item ${itemId} not found`);
// Update the appropriate relational table
switch (item.item_type) {
case 'park': {
if (!item.park_submission_id) break;
const parkData = item_data as any;
const updateData: any = {
...parkData,
updated_at: new Date().toISOString()
};
// Remove fields that shouldn't be in park_submissions
delete updateData.location;
// Remove undefined fields
Object.keys(updateData).forEach(key => {
if (updateData[key] === undefined) delete updateData[key];
});
console.info('[Submission Flow] Saving park data', {
itemId,
parkSubmissionId: item.park_submission_id,
hasLocation: !!parkData.location,
fields: Object.keys(updateData),
timestamp: new Date().toISOString()
});
// Update park_submissions
const { error: parkError } = await supabase
.from('park_submissions' as any)
.update(updateData)
.eq('id', item.park_submission_id);
if (parkError) {
console.error('[Submission Flow] Park update failed:', parkError);
throw parkError;
}
// Update or insert location if provided
if (parkData.location) {
const locationData = {
park_submission_id: item.park_submission_id,
name: parkData.location.name,
street_address: parkData.location.street_address || null,
city: parkData.location.city || null,
state_province: parkData.location.state_province || null,
country: parkData.location.country,
postal_code: parkData.location.postal_code || null,
latitude: parkData.location.latitude,
longitude: parkData.location.longitude,
timezone: parkData.location.timezone || null,
display_name: parkData.location.display_name || null
};
// Try to update first, if no rows affected, insert
const { error: locationError } = await supabase
.from('park_submission_locations' as any)
.upsert(locationData, {
onConflict: 'park_submission_id'
});
if (locationError) {
console.error('[Submission Flow] Location upsert failed:', locationError);
throw locationError;
}
console.info('[Submission Flow] Location saved', {
parkSubmissionId: item.park_submission_id,
locationName: locationData.name
});
}
console.info('[Submission Flow] Park data saved successfully');
break;
}
case 'ride': {
if (!item.ride_submission_id) break;
const { error: updateError } = await supabase
.from('ride_submissions')
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
.eq('id', item.ride_submission_id);
if (updateError) throw updateError;
break;
}
case 'operator':
case 'manufacturer':
case 'designer':
case 'property_owner': {
if (!item.company_submission_id) break;
const { error: updateError } = await supabase
.from('company_submissions')
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
.eq('id', item.company_submission_id);
if (updateError) throw updateError;
break;
}
case 'ride_model': {
if (!item.ride_model_submission_id) break;
const { error: updateError } = await supabase
.from('ride_model_submissions')
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
.eq('id', item.ride_model_submission_id);
if (updateError) throw updateError;
break;
}
case 'milestone':
case 'timeline_event': {
if (!item.timeline_event_submission_id) break;
const { error: updateError } = await supabase
.from('timeline_event_submissions')
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
.eq('id', item.timeline_event_submission_id);
if (updateError) throw updateError;
break;
}
// Photo submissions handled separately due to complex structure
}
}
}
/**
* 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');
}
console.info('[Submission Flow] Approval process started', {
itemCount: items.length,
itemIds: items.map(i => i.id),
itemTypes: items.map(i => i.item_type),
userId,
timestamp: new Date().toISOString()
});
// 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)
);
console.info('[Submission Flow] Processing item for approval', {
itemId: item.id,
itemType: item.item_type,
isEdit,
hasLocation: !!(itemData as any).location,
locationData: (itemData as any).location,
timestamp: new Date().toISOString()
});
// 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`);
}
console.info('[Submission Flow] Entity created successfully', {
itemId: item.id,
itemType: item.item_type,
entityId,
isEdit,
timestamp: new Date().toISOString()
});
// 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<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
}
/**
* 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) {
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<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,
street_address: locationData.street_address || null,
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<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) {
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<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) {
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<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) {
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<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) {
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<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) {
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<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) {
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<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) {
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<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;
// 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,
street_address: newData.location.street_address || null,
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<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) {
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<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) {
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 [];
}
}