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])) {