diff --git a/src/components/moderation/DependencyTreeView.tsx b/src/components/moderation/DependencyTreeView.tsx
new file mode 100644
index 00000000..0ab33678
--- /dev/null
+++ b/src/components/moderation/DependencyTreeView.tsx
@@ -0,0 +1,81 @@
+import { Badge } from '@/components/ui/badge';
+import { CheckCircle2, Clock, XCircle, ChevronRight } from 'lucide-react';
+import { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
+import { cn } from '@/lib/utils';
+
+interface DependencyTreeViewProps {
+ items: SubmissionItemWithDeps[];
+}
+
+export function DependencyTreeView({ items }: DependencyTreeViewProps) {
+ // Build tree structure - root items (no depends_on)
+ const rootItems = items.filter(item => !item.depends_on);
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'approved':
+ return ;
+ case 'rejected':
+ return ;
+ case 'pending':
+ default:
+ return ;
+ }
+ };
+
+ const getItemLabel = (item: SubmissionItemWithDeps): string => {
+ const data = item.item_data;
+ const name = data.name || 'Unnamed';
+ const type = item.item_type.replace('_', ' ');
+ return `${name} (${type})`;
+ };
+
+ const renderItem = (item: SubmissionItemWithDeps, level: number = 0) => {
+ const dependents = items.filter(i => i.depends_on === item.id);
+ const hasChildren = dependents.length > 0;
+
+ return (
+
0 && "ml-6 border-l-2 border-border pl-4")}>
+
+ {level > 0 && }
+ {getStatusIcon(item.status)}
+
+ {getItemLabel(item)}
+
+
+ {item.status}
+
+ {item.depends_on && (
+
+ depends on parent
+
+ )}
+
+ {hasChildren && dependents.map(dep => renderItem(dep, level + 1))}
+
+ );
+ };
+
+ if (items.length <= 1) {
+ return null; // Don't show tree for single items
+ }
+
+ return (
+
+
+ Submission Items ({items.length})
+
+ Composite Submission
+
+
+
+ {rootItems.map(item => renderItem(item))}
+
+
+ );
+}
diff --git a/src/components/moderation/DependencyVisualizer.tsx b/src/components/moderation/DependencyVisualizer.tsx
index e518aba7..d29f40a9 100644
--- a/src/components/moderation/DependencyVisualizer.tsx
+++ b/src/components/moderation/DependencyVisualizer.tsx
@@ -5,6 +5,7 @@ import { ArrowDown, AlertCircle } from 'lucide-react';
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useIsMobile } from '@/hooks/use-mobile';
+import { DependencyTreeView } from './DependencyTreeView';
interface DependencyVisualizerProps {
items: SubmissionItemWithDeps[];
@@ -45,6 +46,9 @@ export function DependencyVisualizer({ items, selectedIds }: DependencyVisualize
return (
+ {/* Compact dependency tree view */}
+
+
{hasCircularDependency && (
diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts
index 6ce2888e..ef60b355 100644
--- a/src/lib/entitySubmissionHelpers.ts
+++ b/src/lib/entitySubmissionHelpers.ts
@@ -9,6 +9,26 @@ import { logger } from './logger';
import { getErrorMessage } from './errorHandler';
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
+// ============================================
+// COMPOSITE SUBMISSION TYPES
+// ============================================
+
+interface CompositeSubmissionDependency {
+ type: 'park' | 'ride' | 'company' | 'ride_model';
+ data: any;
+ tempId: string;
+ companyType?: 'manufacturer' | 'designer' | 'operator' | 'property_owner';
+ parentTempId?: string; // For linking ride_model to manufacturer
+}
+
+interface CompositeSubmissionData {
+ primaryEntity: {
+ type: 'park' | 'ride';
+ data: any;
+ };
+ dependencies: CompositeSubmissionDependency[];
+}
+
/**
* ═══════════════════════════════════════════════════════════════════
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
@@ -154,6 +174,172 @@ export interface RideModelFormData {
card_image_id?: string;
}
+/**
+ * ═══════════════════════════════════════════════════════════════════
+ * COMPOSITE SUBMISSION HANDLER
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * Creates a single submission containing multiple related entities
+ * (e.g., Park + Operator, Ride + Manufacturer + Model)
+ *
+ * All entities are submitted atomically through the moderation queue,
+ * with dependency tracking to ensure correct approval order.
+ *
+ * @param primaryEntity - The main entity being created (park or ride)
+ * @param dependencies - Array of dependent entities (companies, models)
+ * @param userId - The ID of the user submitting
+ * @returns Object containing submitted boolean and submissionId
+ */
+async function submitCompositeCreation(
+ primaryEntity: { type: 'park' | 'ride'; data: any },
+ dependencies: CompositeSubmissionDependency[],
+ userId: string
+): Promise<{ submitted: boolean; submissionId: string }> {
+ // Check if user is banned
+ const { data: profile } = await supabase
+ .from('profiles')
+ .select('banned')
+ .eq('user_id', userId)
+ .single();
+
+ if (profile?.banned) {
+ throw new Error('Account suspended. Contact support for assistance.');
+ }
+
+ // Upload all pending images for all entities
+ const uploadedEntities = await Promise.all([
+ ...dependencies.map(async (dep) => {
+ if (dep.data.images?.uploaded && dep.data.images.uploaded.length > 0) {
+ const uploadedImages = await uploadPendingImages(dep.data.images.uploaded);
+ return {
+ ...dep,
+ data: {
+ ...dep.data,
+ images: { ...dep.data.images, uploaded: uploadedImages }
+ }
+ };
+ }
+ return dep;
+ }),
+ (async () => {
+ if (primaryEntity.data.images?.uploaded && primaryEntity.data.images.uploaded.length > 0) {
+ const uploadedImages = await uploadPendingImages(primaryEntity.data.images.uploaded);
+ return {
+ ...primaryEntity,
+ data: {
+ ...primaryEntity.data,
+ images: { ...primaryEntity.data.images, uploaded: uploadedImages }
+ }
+ };
+ }
+ return primaryEntity;
+ })()
+ ]);
+
+ const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[];
+ const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity;
+
+ // Build submission items array with dependencies first
+ const submissionItems: any[] = [];
+ const tempIdMap = new Map(); // Maps tempId to order_index
+
+ // Add dependency items (companies, models) first
+ let orderIndex = 0;
+ for (const dep of uploadedDependencies) {
+ const itemType = dep.type === 'company' ? dep.companyType : dep.type;
+ tempIdMap.set(dep.tempId, orderIndex);
+
+ const itemData: any = {
+ ...extractChangedFields(dep.data, {}),
+ images: dep.data.images as unknown as Json
+ };
+
+ // Add company_type for company entities
+ if (dep.type === 'company') {
+ itemData.company_type = dep.companyType;
+ }
+
+ // Add manufacturer dependency for ride models
+ if (dep.type === 'ride_model' && dep.parentTempId) {
+ const parentOrderIndex = tempIdMap.get(dep.parentTempId);
+ if (parentOrderIndex !== undefined) {
+ itemData._temp_manufacturer_ref = parentOrderIndex;
+ }
+ }
+
+ submissionItems.push({
+ item_type: itemType,
+ action_type: 'create' as const,
+ item_data: itemData,
+ status: 'pending' as const,
+ order_index: orderIndex,
+ depends_on: null // Dependencies don't have parents
+ });
+
+ orderIndex++;
+ }
+
+ // Add primary entity last
+ const primaryData: any = {
+ ...extractChangedFields(uploadedPrimary.data, {}),
+ images: uploadedPrimary.data.images as unknown as Json
+ };
+
+ // Map temporary IDs to order indices for foreign keys
+ if (uploadedPrimary.type === 'park') {
+ if (uploadedPrimary.data.operator_id?.startsWith('temp-')) {
+ const opIndex = tempIdMap.get('temp-operator');
+ if (opIndex !== undefined) primaryData._temp_operator_ref = opIndex;
+ delete primaryData.operator_id;
+ }
+ if (uploadedPrimary.data.property_owner_id?.startsWith('temp-')) {
+ const ownerIndex = tempIdMap.get('temp-property-owner');
+ if (ownerIndex !== undefined) primaryData._temp_property_owner_ref = ownerIndex;
+ delete primaryData.property_owner_id;
+ }
+ } else if (uploadedPrimary.type === 'ride') {
+ if (uploadedPrimary.data.manufacturer_id?.startsWith('temp-')) {
+ const mfgIndex = tempIdMap.get('temp-manufacturer');
+ if (mfgIndex !== undefined) primaryData._temp_manufacturer_ref = mfgIndex;
+ delete primaryData.manufacturer_id;
+ }
+ if (uploadedPrimary.data.ride_model_id?.startsWith('temp-')) {
+ const modelIndex = tempIdMap.get('temp-ride-model');
+ if (modelIndex !== undefined) primaryData._temp_ride_model_ref = modelIndex;
+ delete primaryData.ride_model_id;
+ }
+ }
+
+ submissionItems.push({
+ item_type: uploadedPrimary.type,
+ action_type: 'create' as const,
+ item_data: primaryData,
+ status: 'pending' as const,
+ order_index: orderIndex,
+ depends_on: null // Will be set by RPC based on refs
+ });
+
+ // Use RPC to create submission with items atomically
+ const { data: result, error } = await supabase.rpc('create_submission_with_items', {
+ p_user_id: userId,
+ p_submission_type: uploadedPrimary.type,
+ p_content: { action: 'create' } as unknown as Json,
+ p_items: submissionItems as unknown as Json[]
+ });
+
+ if (error) {
+ logger.error('Composite submission failed', {
+ action: 'composite_submission',
+ primaryType: uploadedPrimary.type,
+ dependencyCount: dependencies.length,
+ error: getErrorMessage(error)
+ });
+ throw new Error(`Failed to create composite submission: ${getErrorMessage(error)}`);
+ }
+
+ return { submitted: true, submissionId: result };
+}
+
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
@@ -174,9 +360,41 @@ export interface RideModelFormData {
* @returns Object containing submitted boolean and submissionId
*/
export async function submitParkCreation(
- data: ParkFormData,
+ data: ParkFormData & { _compositeSubmission?: any },
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
+ // Check for composite submission with dependencies
+ if (data._compositeSubmission) {
+ const dependencies: CompositeSubmissionDependency[] = [];
+
+ if (data._compositeSubmission.new_operator) {
+ dependencies.push({
+ type: 'company',
+ data: { ...data._compositeSubmission.new_operator, company_type: 'operator' },
+ tempId: 'temp-operator',
+ companyType: 'operator'
+ });
+ }
+
+ if (data._compositeSubmission.new_property_owner) {
+ dependencies.push({
+ type: 'company',
+ data: { ...data._compositeSubmission.new_property_owner, company_type: 'property_owner' },
+ tempId: 'temp-property-owner',
+ companyType: 'property_owner'
+ });
+ }
+
+ if (dependencies.length > 0) {
+ return submitCompositeCreation(
+ { type: 'park', data: data._compositeSubmission.park },
+ dependencies,
+ userId
+ );
+ }
+ }
+
+ // Standard single-entity creation
// Check if user is banned
const { data: profile } = await supabase
.from('profiles')
@@ -368,9 +586,41 @@ export async function submitParkUpdate(
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideCreation(
- data: RideFormData,
+ data: RideFormData & { _tempNewManufacturer?: any; _tempNewRideModel?: any },
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
+ // Check for composite submission with dependencies
+ if (data._tempNewManufacturer || data._tempNewRideModel) {
+ const dependencies: CompositeSubmissionDependency[] = [];
+
+ if (data._tempNewManufacturer) {
+ dependencies.push({
+ type: 'company',
+ data: { ...data._tempNewManufacturer, company_type: 'manufacturer' },
+ tempId: 'temp-manufacturer',
+ companyType: 'manufacturer'
+ });
+ }
+
+ if (data._tempNewRideModel) {
+ dependencies.push({
+ type: 'ride_model',
+ data: data._tempNewRideModel,
+ tempId: 'temp-ride-model',
+ parentTempId: data._tempNewManufacturer ? 'temp-manufacturer' : undefined
+ });
+ }
+
+ if (dependencies.length > 0) {
+ return submitCompositeCreation(
+ { type: 'ride', data },
+ dependencies,
+ userId
+ );
+ }
+ }
+
+ // Standard single-entity creation
// Check if user is banned
const { data: profile } = await supabase
.from('profiles')
diff --git a/src/lib/entityValidationSchemas.ts b/src/lib/entityValidationSchemas.ts
index 37ff6a62..fa9f3203 100644
--- a/src/lib/entityValidationSchemas.ts
+++ b/src/lib/entityValidationSchemas.ts
@@ -46,8 +46,24 @@ export const parkValidationSchema = z.object({
if (!val || val === '') return true;
return z.string().email().safeParse(val).success;
}, 'Invalid email format'),
- operator_id: z.string().uuid().optional().nullable().or(z.literal('')).transform(val => val || undefined),
- property_owner_id: z.string().uuid().optional().nullable().or(z.literal('')).transform(val => val || undefined),
+ operator_id: z.string()
+ .refine(
+ val => !val || val === '' || val.startsWith('temp-') || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(val),
+ 'Must be a valid UUID or temporary placeholder'
+ )
+ .optional()
+ .nullable()
+ .or(z.literal(''))
+ .transform(val => val || undefined),
+ property_owner_id: z.string()
+ .refine(
+ val => !val || val === '' || val.startsWith('temp-') || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(val),
+ 'Must be a valid UUID or temporary placeholder'
+ )
+ .optional()
+ .nullable()
+ .or(z.literal(''))
+ .transform(val => val || undefined),
banner_image_id: z.string().optional(),
banner_image_url: z.string().optional(),
card_image_id: z.string().optional(),
@@ -83,7 +99,13 @@ export const rideValidationSchema = z.object({
ride_sub_type: z.string().trim().max(100, 'Sub type must be less than 100 characters').optional().or(z.literal('')),
status: z.enum(['operating', 'closed_permanently', 'closed_temporarily', 'under_construction', 'relocated', 'stored', 'demolished']),
park_id: z.string().uuid().optional().nullable(),
- designer_id: z.string().uuid().optional().nullable(),
+ designer_id: z.string()
+ .refine(
+ val => !val || val === '' || val.startsWith('temp-') || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(val),
+ 'Must be a valid UUID or temporary placeholder'
+ )
+ .optional()
+ .nullable(),
opening_date: z.string().optional().or(z.literal('')),
opening_date_precision: z.enum(['day', 'month', 'year']).optional(),
closing_date: z.string().optional().or(z.literal('')),
@@ -128,8 +150,20 @@ export const rideValidationSchema = z.object({
(val) => val === '' || val === null || val === undefined ? undefined : Number(val),
z.number().min(-10, 'G-force must be greater than -10').max(10, 'G-force must be less than 10').optional()
),
- manufacturer_id: z.string().uuid().optional().nullable(),
- ride_model_id: z.string().uuid().optional().nullable(),
+ manufacturer_id: z.string()
+ .refine(
+ val => !val || val === '' || val.startsWith('temp-') || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(val),
+ 'Must be a valid UUID or temporary placeholder'
+ )
+ .optional()
+ .nullable(),
+ ride_model_id: z.string()
+ .refine(
+ val => !val || val === '' || val.startsWith('temp-') || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(val),
+ 'Must be a valid UUID or temporary placeholder'
+ )
+ .optional()
+ .nullable(),
coaster_type: z.string().optional(),
seating_type: z.string().optional(),
intensity_level: z.string().optional(),
@@ -285,7 +319,12 @@ export const rideModelValidationSchema = z.object({
category: z.string().min(1, 'Category is required'),
ride_type: z.string().trim().min(1, 'Ride type is required').max(100, 'Ride type must be less than 100 characters'),
description: z.string().trim().max(2000, 'Description must be less than 2000 characters').optional().or(z.literal('')),
- manufacturer_id: z.string().uuid('Invalid manufacturer ID').optional(),
+ manufacturer_id: z.string()
+ .refine(
+ val => !val || val === '' || val.startsWith('temp-') || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(val),
+ 'Must be a valid UUID or temporary placeholder'
+ )
+ .optional(),
source_url: z.string().trim().optional().or(z.literal('')).refine((val) => {
if (!val || val === '') return true;
return z.string().url().safeParse(val).success;
diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts
index 10815a80..99f196b8 100644
--- a/src/lib/submissionItemsService.ts
+++ b/src/lib/submissionItemsService.ts
@@ -227,7 +227,7 @@ export async function approveSubmissionItems(
);
}
- // Add to dependency map for child items
+ // Add to dependency map using item.id as key
dependencyMap.set(item.id, entityId);
} catch (error: unknown) {
@@ -898,8 +898,8 @@ async function updateEntityFeaturedImage(
}
/**
- * Resolve dependency references in submission data
- * Replaces submission item IDs with actual database entity IDs
+ * Resolve dependency references in item_data by looking up approved entity IDs
+ * Replaces temporary references (_temp_*_ref) with actual database entity IDs
*/
function resolveDependencies(data: any, dependencyMap: Map): any {
const resolved = { ...data };
@@ -915,6 +915,43 @@ function resolveDependencies(data: any, dependencyMap: Map): any
'location_id',
];
+ // Resolve temporary references first
+ if (resolved._temp_manufacturer_ref !== undefined) {
+ const refIndex = resolved._temp_manufacturer_ref;
+ const refItemId = Array.from(dependencyMap.keys())[refIndex];
+ if (refItemId && dependencyMap.has(refItemId)) {
+ resolved.manufacturer_id = dependencyMap.get(refItemId);
+ }
+ delete resolved._temp_manufacturer_ref;
+ }
+
+ if (resolved._temp_operator_ref !== undefined) {
+ const refIndex = resolved._temp_operator_ref;
+ const refItemId = Array.from(dependencyMap.keys())[refIndex];
+ if (refItemId && 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;
+ const refItemId = Array.from(dependencyMap.keys())[refIndex];
+ if (refItemId && 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;
+ const refItemId = Array.from(dependencyMap.keys())[refIndex];
+ if (refItemId && 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])) {