feat: Implement composite submission system

This commit is contained in:
gpt-engineer-app[bot]
2025-11-02 19:51:20 +00:00
parent 0f742f36b6
commit 3c6d6a3bdf
5 changed files with 422 additions and 11 deletions

View File

@@ -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 <CheckCircle2 className="w-4 h-4 text-green-600" />;
case 'rejected':
return <XCircle className="w-4 h-4 text-destructive" />;
case 'pending':
default:
return <Clock className="w-4 h-4 text-muted-foreground" />;
}
};
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 (
<div key={item.id} className={cn("space-y-2", level > 0 && "ml-6 border-l-2 border-border pl-4")}>
<div className="flex items-center gap-2">
{level > 0 && <ChevronRight className="w-4 h-4 text-muted-foreground" />}
{getStatusIcon(item.status)}
<span className={cn(
"text-sm",
item.status === 'approved' && "text-green-700 dark:text-green-400",
item.status === 'rejected' && "text-destructive",
item.status === 'pending' && "text-foreground"
)}>
{getItemLabel(item)}
</span>
<Badge variant={item.status === 'approved' ? 'default' : item.status === 'rejected' ? 'destructive' : 'secondary'} className="text-xs">
{item.status}
</Badge>
{item.depends_on && (
<Badge variant="outline" className="text-xs">
depends on parent
</Badge>
)}
</div>
{hasChildren && dependents.map(dep => renderItem(dep, level + 1))}
</div>
);
};
if (items.length <= 1) {
return null; // Don't show tree for single items
}
return (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">Submission Items ({items.length})</span>
<Badge variant="outline" className="text-xs">
Composite Submission
</Badge>
</div>
<div className="space-y-2">
{rootItems.map(item => renderItem(item))}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
{/* Compact dependency tree view */}
<DependencyTreeView items={items} />
{hasCircularDependency && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />

View File

@@ -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<string, number>(); // 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')

View File

@@ -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;

View File

@@ -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<string, string>): any {
const resolved = { ...data };
@@ -915,6 +915,43 @@ function resolveDependencies(data: any, dependencyMap: Map<string, string>): 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])) {