mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 14:51:13 -05:00
feat: Implement composite submission system
This commit is contained in:
81
src/components/moderation/DependencyTreeView.tsx
Normal file
81
src/components/moderation/DependencyTreeView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ArrowDown, AlertCircle } from 'lucide-react';
|
|||||||
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { DependencyTreeView } from './DependencyTreeView';
|
||||||
|
|
||||||
interface DependencyVisualizerProps {
|
interface DependencyVisualizerProps {
|
||||||
items: SubmissionItemWithDeps[];
|
items: SubmissionItemWithDeps[];
|
||||||
@@ -45,6 +46,9 @@ export function DependencyVisualizer({ items, selectedIds }: DependencyVisualize
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Compact dependency tree view */}
|
||||||
|
<DependencyTreeView items={items} />
|
||||||
|
|
||||||
{hasCircularDependency && (
|
{hasCircularDependency && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ import { logger } from './logger';
|
|||||||
import { getErrorMessage } from './errorHandler';
|
import { getErrorMessage } from './errorHandler';
|
||||||
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
|
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
|
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
|
||||||
@@ -154,6 +174,172 @@ export interface RideModelFormData {
|
|||||||
card_image_id?: string;
|
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 ⚠️
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
||||||
*
|
*
|
||||||
@@ -174,9 +360,41 @@ export interface RideModelFormData {
|
|||||||
* @returns Object containing submitted boolean and submissionId
|
* @returns Object containing submitted boolean and submissionId
|
||||||
*/
|
*/
|
||||||
export async function submitParkCreation(
|
export async function submitParkCreation(
|
||||||
data: ParkFormData,
|
data: ParkFormData & { _compositeSubmission?: any },
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ submitted: boolean; submissionId: 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
|
// Check if user is banned
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
@@ -368,9 +586,41 @@ export async function submitParkUpdate(
|
|||||||
* @returns Object containing submitted boolean and submissionId
|
* @returns Object containing submitted boolean and submissionId
|
||||||
*/
|
*/
|
||||||
export async function submitRideCreation(
|
export async function submitRideCreation(
|
||||||
data: RideFormData,
|
data: RideFormData & { _tempNewManufacturer?: any; _tempNewRideModel?: any },
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ submitted: boolean; submissionId: 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
|
// Check if user is banned
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
|
|||||||
@@ -46,8 +46,24 @@ export const parkValidationSchema = z.object({
|
|||||||
if (!val || val === '') return true;
|
if (!val || val === '') return true;
|
||||||
return z.string().email().safeParse(val).success;
|
return z.string().email().safeParse(val).success;
|
||||||
}, 'Invalid email format'),
|
}, 'Invalid email format'),
|
||||||
operator_id: z.string().uuid().optional().nullable().or(z.literal('')).transform(val => val || undefined),
|
operator_id: z.string()
|
||||||
property_owner_id: z.string().uuid().optional().nullable().or(z.literal('')).transform(val => val || undefined),
|
.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_id: z.string().optional(),
|
||||||
banner_image_url: z.string().optional(),
|
banner_image_url: z.string().optional(),
|
||||||
card_image_id: 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('')),
|
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']),
|
status: z.enum(['operating', 'closed_permanently', 'closed_temporarily', 'under_construction', 'relocated', 'stored', 'demolished']),
|
||||||
park_id: z.string().uuid().optional().nullable(),
|
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: z.string().optional().or(z.literal('')),
|
||||||
opening_date_precision: z.enum(['day', 'month', 'year']).optional(),
|
opening_date_precision: z.enum(['day', 'month', 'year']).optional(),
|
||||||
closing_date: z.string().optional().or(z.literal('')),
|
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),
|
(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()
|
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(),
|
manufacturer_id: z.string()
|
||||||
ride_model_id: z.string().uuid().optional().nullable(),
|
.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(),
|
coaster_type: z.string().optional(),
|
||||||
seating_type: z.string().optional(),
|
seating_type: z.string().optional(),
|
||||||
intensity_level: 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'),
|
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'),
|
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('')),
|
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) => {
|
source_url: z.string().trim().optional().or(z.literal('')).refine((val) => {
|
||||||
if (!val || val === '') return true;
|
if (!val || val === '') return true;
|
||||||
return z.string().url().safeParse(val).success;
|
return z.string().url().safeParse(val).success;
|
||||||
|
|||||||
@@ -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);
|
dependencyMap.set(item.id, entityId);
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -898,8 +898,8 @@ async function updateEntityFeaturedImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve dependency references in submission data
|
* Resolve dependency references in item_data by looking up approved entity IDs
|
||||||
* Replaces submission item IDs with actual database entity IDs
|
* Replaces temporary references (_temp_*_ref) with actual database entity IDs
|
||||||
*/
|
*/
|
||||||
function resolveDependencies(data: any, dependencyMap: Map<string, string>): any {
|
function resolveDependencies(data: any, dependencyMap: Map<string, string>): any {
|
||||||
const resolved = { ...data };
|
const resolved = { ...data };
|
||||||
@@ -915,6 +915,43 @@ function resolveDependencies(data: any, dependencyMap: Map<string, string>): any
|
|||||||
'location_id',
|
'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
|
// Resolve each foreign key if it's a submission item ID
|
||||||
for (const key of foreignKeys) {
|
for (const key of foreignKeys) {
|
||||||
if (resolved[key] && dependencyMap.has(resolved[key])) {
|
if (resolved[key] && dependencyMap.has(resolved[key])) {
|
||||||
|
|||||||
Reference in New Issue
Block a user