mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 02:07:04 -05:00
Compare commits
3 Commits
ed9d17bf10
...
1a4e30674f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a4e30674f | ||
|
|
4d7b00e4e7 | ||
|
|
bd4f75bfb2 |
@@ -6,6 +6,7 @@ import { RichParkDisplay } from './displays/RichParkDisplay';
|
||||
import { RichRideDisplay } from './displays/RichRideDisplay';
|
||||
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
||||
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
||||
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -13,6 +14,7 @@ import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import type { SubmissionItemData } from '@/types/submissions';
|
||||
import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data';
|
||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||
|
||||
@@ -270,6 +272,29 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
);
|
||||
}
|
||||
|
||||
if ((item.item_type === 'milestone' || item.item_type === 'timeline_event') && entityData) {
|
||||
return (
|
||||
<>
|
||||
{itemMetadata}
|
||||
<RichTimelineEventDisplay
|
||||
data={entityData as unknown as TimelineSubmissionData}
|
||||
actionType={actionType}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to SubmissionChangesDisplay
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,38 +1,93 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Calendar, Tag } from 'lucide-react';
|
||||
import { Calendar, Tag, Building2, MapPin } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
|
||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
|
||||
interface TimelineEventPreviewProps {
|
||||
data: TimelineSubmissionData;
|
||||
}
|
||||
|
||||
export function TimelineEventPreview({ data }: TimelineEventPreviewProps) {
|
||||
const [entityName, setEntityName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.entity_id || !data?.entity_type) return;
|
||||
|
||||
const fetchEntityName = async () => {
|
||||
const table = data.entity_type === 'park' ? 'parks' : 'rides';
|
||||
const { data: entity } = await supabase
|
||||
.from(table)
|
||||
.select('name')
|
||||
.eq('id', data.entity_id)
|
||||
.single();
|
||||
setEntityName(entity?.name || null);
|
||||
};
|
||||
|
||||
fetchEntityName();
|
||||
}, [data?.entity_id, data?.entity_type]);
|
||||
|
||||
const formatEventType = (type: string) => {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
opening: 'bg-green-600',
|
||||
closure: 'bg-red-600',
|
||||
reopening: 'bg-blue-600',
|
||||
renovation: 'bg-purple-600',
|
||||
expansion: 'bg-indigo-600',
|
||||
acquisition: 'bg-amber-600',
|
||||
name_change: 'bg-cyan-600',
|
||||
operator_change: 'bg-orange-600',
|
||||
owner_change: 'bg-orange-600',
|
||||
location_change: 'bg-pink-600',
|
||||
status_change: 'bg-yellow-600',
|
||||
milestone: 'bg-emerald-600',
|
||||
};
|
||||
return colors[type] || 'bg-gray-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4" />
|
||||
Timeline Event: {data.title}
|
||||
<Calendar className="h-4 w-4" />
|
||||
{data.title}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<Badge className={`${getEventTypeColor(data.event_type)} text-white text-xs`}>
|
||||
{formatEventType(data.event_type)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{data.entity_type}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{entityName && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Entity:</span>
|
||||
<span className="text-foreground">{entityName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Event Type:</span>
|
||||
<p className="text-muted-foreground">
|
||||
{formatEventType(data.event_type)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Date:</span>
|
||||
<p className="text-muted-foreground flex items-center gap-1">
|
||||
<span className="font-medium">Event Date:</span>
|
||||
<p className="text-muted-foreground flex items-center gap-1 mt-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(data.event_date).toLocaleDateString()}
|
||||
({data.event_date_precision})
|
||||
<FlexibleDateDisplay
|
||||
date={data.event_date}
|
||||
precision={data.event_date_precision}
|
||||
/>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Precision: {data.event_date_precision}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,6 +101,20 @@ export function TimelineEventPreview({ data }: TimelineEventPreviewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data.from_entity_id || data.to_entity_id) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Tag className="h-3 w-3 inline mr-1" />
|
||||
Related entities: {data.from_entity_id ? 'From entity' : ''} {data.to_entity_id ? 'To entity' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data.from_location_id || data.to_location_id) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3 inline mr-1" />
|
||||
Location change involved
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.description && (
|
||||
<div>
|
||||
<span className="font-medium text-sm">Description:</span>
|
||||
|
||||
266
src/components/moderation/displays/RichTimelineEventDisplay.tsx
Normal file
266
src/components/moderation/displays/RichTimelineEventDisplay.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Calendar, Tag, ArrowRight, MapPin, Building2, Clock } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { FlexibleDateDisplay } from '@/components/ui/flexible-date-display';
|
||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
|
||||
interface RichTimelineEventDisplayProps {
|
||||
data: TimelineSubmissionData;
|
||||
actionType: 'create' | 'edit' | 'delete';
|
||||
}
|
||||
|
||||
export function RichTimelineEventDisplay({ data, actionType }: RichTimelineEventDisplayProps) {
|
||||
const [entityName, setEntityName] = useState<string | null>(null);
|
||||
const [parkContext, setParkContext] = useState<string | null>(null);
|
||||
const [fromEntity, setFromEntity] = useState<string | null>(null);
|
||||
const [toEntity, setToEntity] = useState<string | null>(null);
|
||||
const [fromLocation, setFromLocation] = useState<any>(null);
|
||||
const [toLocation, setToLocation] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
const fetchRelatedData = async () => {
|
||||
// Fetch the main entity this timeline event is for
|
||||
if (data.entity_id && data.entity_type) {
|
||||
if (data.entity_type === 'park') {
|
||||
const { data: park } = await supabase
|
||||
.from('parks')
|
||||
.select('name')
|
||||
.eq('id', data.entity_id)
|
||||
.single();
|
||||
setEntityName(park?.name || null);
|
||||
} else if (data.entity_type === 'ride') {
|
||||
const { data: ride } = await supabase
|
||||
.from('rides')
|
||||
.select('name, park:parks(name)')
|
||||
.eq('id', data.entity_id)
|
||||
.single();
|
||||
setEntityName(ride?.name || null);
|
||||
setParkContext((ride?.park as any)?.name || null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from/to entities for relational changes
|
||||
if (data.from_entity_id) {
|
||||
const { data: entity } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', data.from_entity_id)
|
||||
.single();
|
||||
setFromEntity(entity?.name || null);
|
||||
}
|
||||
|
||||
if (data.to_entity_id) {
|
||||
const { data: entity } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', data.to_entity_id)
|
||||
.single();
|
||||
setToEntity(entity?.name || null);
|
||||
}
|
||||
|
||||
// Fetch from/to locations for location changes
|
||||
if (data.from_location_id) {
|
||||
const { data: loc } = await supabase
|
||||
.from('locations')
|
||||
.select('*')
|
||||
.eq('id', data.from_location_id)
|
||||
.single();
|
||||
setFromLocation(loc);
|
||||
}
|
||||
|
||||
if (data.to_location_id) {
|
||||
const { data: loc } = await supabase
|
||||
.from('locations')
|
||||
.select('*')
|
||||
.eq('id', data.to_location_id)
|
||||
.single();
|
||||
setToLocation(loc);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRelatedData();
|
||||
}, [data.entity_id, data.entity_type, data.from_entity_id, data.to_entity_id, data.from_location_id, data.to_location_id]);
|
||||
|
||||
const formatEventType = (type: string) => {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'opening': return 'bg-green-600';
|
||||
case 'closure': return 'bg-red-600';
|
||||
case 'reopening': return 'bg-blue-600';
|
||||
case 'renovation': return 'bg-purple-600';
|
||||
case 'expansion': return 'bg-indigo-600';
|
||||
case 'acquisition': return 'bg-amber-600';
|
||||
case 'name_change': return 'bg-cyan-600';
|
||||
case 'operator_change':
|
||||
case 'owner_change': return 'bg-orange-600';
|
||||
case 'location_change': return 'bg-pink-600';
|
||||
case 'status_change': return 'bg-yellow-600';
|
||||
case 'milestone': return 'bg-emerald-600';
|
||||
default: return 'bg-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getPrecisionIcon = (precision: string) => {
|
||||
switch (precision) {
|
||||
case 'day': return '📅';
|
||||
case 'month': return '📆';
|
||||
case 'year': return '🗓️';
|
||||
default: return '📅';
|
||||
}
|
||||
};
|
||||
|
||||
const formatLocation = (loc: any) => {
|
||||
if (!loc) return null;
|
||||
const parts = [loc.city, loc.state_province, loc.country].filter(Boolean);
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-bold text-foreground">{data.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<Badge className={`${getEventTypeColor(data.event_type)} text-white text-xs`}>
|
||||
{formatEventType(data.event_type)}
|
||||
</Badge>
|
||||
{actionType === 'create' && (
|
||||
<Badge className="bg-green-600 text-white text-xs">New Event</Badge>
|
||||
)}
|
||||
{actionType === 'edit' && (
|
||||
<Badge className="bg-amber-600 text-white text-xs">Edit Event</Badge>
|
||||
)}
|
||||
{actionType === 'delete' && (
|
||||
<Badge variant="destructive" className="text-xs">Delete Event</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Entity Context Section */}
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Event For:</span>
|
||||
<span className="text-foreground">
|
||||
{entityName || 'Loading...'}
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
{data.entity_type}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{parkContext && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Park:</span>
|
||||
<span className="text-foreground">{parkContext}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Event Date Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Event Date:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<span className="text-2xl">{getPrecisionIcon(data.event_date_precision)}</span>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">
|
||||
<FlexibleDateDisplay
|
||||
date={data.event_date}
|
||||
precision={data.event_date_precision}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Precision: {data.event_date_precision}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Details Section */}
|
||||
{(data.from_value || data.to_value || fromEntity || toEntity) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Change Details:</div>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<div className="flex-1 p-3 rounded-lg bg-muted/50">
|
||||
<div className="text-xs text-muted-foreground mb-1">From</div>
|
||||
<div className="font-medium">
|
||||
{fromEntity || data.from_value || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 p-3 rounded-lg bg-muted/50">
|
||||
<div className="text-xs text-muted-foreground mb-1">To</div>
|
||||
<div className="font-medium">
|
||||
{toEntity || data.to_value || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Location Change Section */}
|
||||
{(fromLocation || toLocation) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Location Change:
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<div className="flex-1 p-3 rounded-lg bg-muted/50">
|
||||
<div className="text-xs text-muted-foreground mb-1">From</div>
|
||||
<div className="font-medium">
|
||||
{formatLocation(fromLocation) || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 p-3 rounded-lg bg-muted/50">
|
||||
<div className="text-xs text-muted-foreground mb-1">To</div>
|
||||
<div className="font-medium">
|
||||
{formatLocation(toLocation) || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Description Section */}
|
||||
{data.description && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Description:</div>
|
||||
<p className="text-sm text-muted-foreground pl-6 leading-relaxed">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,9 @@ export interface ParkFormData {
|
||||
park_type: string;
|
||||
status: string;
|
||||
opening_date?: string;
|
||||
opening_date_precision?: string;
|
||||
closing_date?: string;
|
||||
closing_date_precision?: string;
|
||||
website_url?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
@@ -131,7 +133,9 @@ export interface RideFormData {
|
||||
designer_id?: string;
|
||||
ride_model_id?: string;
|
||||
opening_date?: string;
|
||||
opening_date_precision?: string;
|
||||
closing_date?: string;
|
||||
closing_date_precision?: string;
|
||||
max_speed_kmh?: number;
|
||||
max_height_meters?: number;
|
||||
length_meters?: number;
|
||||
@@ -890,21 +894,72 @@ export async function submitParkUpdate(
|
||||
|
||||
if (submissionError) throw submissionError;
|
||||
|
||||
// Create the submission item with actual park data AND original data
|
||||
// Extract changed fields
|
||||
const changedFields = extractChangedFields(data, existingPark as any);
|
||||
|
||||
// Handle location data properly
|
||||
let tempLocationData: any = null;
|
||||
if (data.location) {
|
||||
tempLocationData = {
|
||||
name: data.location.name,
|
||||
street_address: data.location.street_address || null,
|
||||
city: data.location.city || null,
|
||||
state_province: data.location.state_province || null,
|
||||
country: data.location.country,
|
||||
latitude: data.location.latitude,
|
||||
longitude: data.location.longitude,
|
||||
timezone: data.location.timezone || null,
|
||||
postal_code: data.location.postal_code || null,
|
||||
display_name: data.location.display_name
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ FIXED: Insert into park_submissions table (relational pattern)
|
||||
const { data: parkSubmission, error: parkSubmissionError } = await supabase
|
||||
.from('park_submissions')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
name: changedFields.name ?? existingPark.name,
|
||||
slug: changedFields.slug ?? existingPark.slug,
|
||||
description: changedFields.description !== undefined ? changedFields.description : existingPark.description,
|
||||
park_type: changedFields.park_type ?? existingPark.park_type,
|
||||
status: changedFields.status ?? existingPark.status,
|
||||
opening_date: changedFields.opening_date !== undefined ? changedFields.opening_date : existingPark.opening_date,
|
||||
opening_date_precision: changedFields.opening_date_precision !== undefined ? changedFields.opening_date_precision : existingPark.opening_date_precision,
|
||||
closing_date: changedFields.closing_date !== undefined ? changedFields.closing_date : existingPark.closing_date,
|
||||
closing_date_precision: changedFields.closing_date_precision !== undefined ? changedFields.closing_date_precision : existingPark.closing_date_precision,
|
||||
website_url: changedFields.website_url !== undefined ? changedFields.website_url : existingPark.website_url,
|
||||
phone: changedFields.phone !== undefined ? changedFields.phone : existingPark.phone,
|
||||
email: changedFields.email !== undefined ? changedFields.email : existingPark.email,
|
||||
operator_id: changedFields.operator_id !== undefined ? changedFields.operator_id : existingPark.operator_id,
|
||||
property_owner_id: changedFields.property_owner_id !== undefined ? changedFields.property_owner_id : existingPark.property_owner_id,
|
||||
location_id: changedFields.location_id !== undefined ? changedFields.location_id : existingPark.location_id,
|
||||
temp_location_data: tempLocationData,
|
||||
banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingPark.banner_image_url,
|
||||
banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingPark.banner_image_id,
|
||||
card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingPark.card_image_url,
|
||||
card_image_id: changedFields.card_image_id !== undefined ? changedFields.card_image_id : existingPark.card_image_id,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (parkSubmissionError) throw parkSubmissionError;
|
||||
|
||||
// ✅ Create submission_items referencing park_submission (no JSON data)
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: 'park',
|
||||
action_type: 'edit',
|
||||
item_data: JSON.parse(JSON.stringify({
|
||||
...extractChangedFields(data, existingPark as any),
|
||||
park_id: parkId, // Always include for relational integrity
|
||||
images: processedImages
|
||||
})) as Json,
|
||||
item_data: {
|
||||
park_id: parkId, // Only reference IDs
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingPark)),
|
||||
status: 'pending' as const,
|
||||
order_index: 0
|
||||
order_index: 0,
|
||||
park_submission_id: parkSubmission.id
|
||||
});
|
||||
|
||||
if (itemError) throw itemError;
|
||||
@@ -1440,7 +1495,52 @@ export async function submitRideUpdate(
|
||||
|
||||
if (submissionError) throw submissionError;
|
||||
|
||||
// Create the submission item with actual ride data AND original data
|
||||
// Extract changed fields
|
||||
const changedFields = extractChangedFields(data, existingRide as any);
|
||||
|
||||
// ✅ FIXED: Insert into ride_submissions table (relational pattern)
|
||||
const { data: rideSubmission, error: rideSubmissionError } = await supabase
|
||||
.from('ride_submissions')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
name: changedFields.name ?? existingRide.name,
|
||||
slug: changedFields.slug ?? existingRide.slug,
|
||||
description: changedFields.description !== undefined ? changedFields.description : existingRide.description,
|
||||
category: changedFields.category ?? existingRide.category,
|
||||
status: changedFields.status ?? existingRide.status,
|
||||
park_id: changedFields.park_id !== undefined ? changedFields.park_id : existingRide.park_id,
|
||||
manufacturer_id: changedFields.manufacturer_id !== undefined ? changedFields.manufacturer_id : existingRide.manufacturer_id,
|
||||
designer_id: changedFields.designer_id !== undefined ? changedFields.designer_id : existingRide.designer_id,
|
||||
ride_model_id: changedFields.ride_model_id !== undefined ? changedFields.ride_model_id : existingRide.ride_model_id,
|
||||
opening_date: changedFields.opening_date !== undefined ? changedFields.opening_date : existingRide.opening_date,
|
||||
opening_date_precision: changedFields.opening_date_precision !== undefined ? changedFields.opening_date_precision : existingRide.opening_date_precision,
|
||||
closing_date: changedFields.closing_date !== undefined ? changedFields.closing_date : existingRide.closing_date,
|
||||
closing_date_precision: changedFields.closing_date_precision !== undefined ? changedFields.closing_date_precision : existingRide.closing_date_precision,
|
||||
max_speed_kmh: changedFields.max_speed_kmh !== undefined ? changedFields.max_speed_kmh : existingRide.max_speed_kmh,
|
||||
max_height_meters: changedFields.max_height_meters !== undefined ? changedFields.max_height_meters : existingRide.max_height_meters,
|
||||
length_meters: changedFields.length_meters !== undefined ? changedFields.length_meters : existingRide.length_meters,
|
||||
duration_seconds: changedFields.duration_seconds !== undefined ? changedFields.duration_seconds : existingRide.duration_seconds,
|
||||
capacity_per_hour: changedFields.capacity_per_hour !== undefined ? changedFields.capacity_per_hour : existingRide.capacity_per_hour,
|
||||
height_requirement: changedFields.height_requirement !== undefined ? changedFields.height_requirement : existingRide.height_requirement,
|
||||
age_requirement: changedFields.age_requirement !== undefined ? changedFields.age_requirement : existingRide.age_requirement,
|
||||
inversions: changedFields.inversions !== undefined ? changedFields.inversions : existingRide.inversions,
|
||||
drop_height_meters: changedFields.drop_height_meters !== undefined ? changedFields.drop_height_meters : existingRide.drop_height_meters,
|
||||
max_g_force: changedFields.max_g_force !== undefined ? changedFields.max_g_force : existingRide.max_g_force,
|
||||
intensity_level: changedFields.intensity_level !== undefined ? changedFields.intensity_level : existingRide.intensity_level,
|
||||
coaster_type: changedFields.coaster_type !== undefined ? changedFields.coaster_type : existingRide.coaster_type,
|
||||
seating_type: changedFields.seating_type !== undefined ? changedFields.seating_type : existingRide.seating_type,
|
||||
ride_sub_type: changedFields.ride_sub_type !== undefined ? changedFields.ride_sub_type : existingRide.ride_sub_type,
|
||||
banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingRide.banner_image_url,
|
||||
banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingRide.banner_image_id,
|
||||
card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingRide.card_image_url,
|
||||
card_image_id: changedFields.card_image_id !== undefined ? changedFields.card_image_id : existingRide.card_image_id,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (rideSubmissionError) throw rideSubmissionError;
|
||||
|
||||
// ✅ Create submission_items referencing ride_submission (no JSON data)
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
@@ -1448,13 +1548,13 @@ export async function submitRideUpdate(
|
||||
item_type: 'ride',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...extractChangedFields(data, existingRide as any),
|
||||
ride_id: rideId, // Always include for relational integrity
|
||||
ride_id: rideId, // Only reference IDs
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingRide)),
|
||||
status: 'pending' as const,
|
||||
order_index: 0
|
||||
order_index: 0,
|
||||
ride_submission_id: rideSubmission.id
|
||||
});
|
||||
|
||||
if (itemError) throw itemError;
|
||||
@@ -2248,16 +2348,30 @@ export async function submitTimelineEvent(
|
||||
throw new Error('User ID is required for timeline event submission');
|
||||
}
|
||||
|
||||
// Create submission content (minimal reference data only)
|
||||
const content: Json = {
|
||||
action: 'create',
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
};
|
||||
|
||||
// Create the main submission record
|
||||
// Use atomic RPC function to create submission + items in transaction
|
||||
const itemData: Record<string, any> = {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'timeline_event',
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (submissionError) {
|
||||
handleError(submissionError, {
|
||||
action: 'Submit timeline event',
|
||||
userId,
|
||||
});
|
||||
throw new Error('Failed to create timeline event submission');
|
||||
}
|
||||
|
||||
// ✅ FIXED: Insert into timeline_event_submissions table (relational pattern)
|
||||
const { data: timelineSubmission, error: timelineSubmissionError } = await supabase
|
||||
.from('timeline_event_submissions')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
event_type: data.event_type,
|
||||
@@ -2272,34 +2386,45 @@ export async function submitTimelineEvent(
|
||||
from_location_id: data.from_location_id,
|
||||
to_location_id: data.to_location_id,
|
||||
is_public: true,
|
||||
};
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
const items = [{
|
||||
item_type: 'timeline_event',
|
||||
action_type: 'create',
|
||||
item_data: itemData,
|
||||
order_index: 0,
|
||||
}];
|
||||
|
||||
const { data: submissionId, error } = await supabase
|
||||
.rpc('create_submission_with_items', {
|
||||
p_user_id: userId,
|
||||
p_submission_type: 'timeline_event',
|
||||
p_content: content,
|
||||
p_items: items as unknown as Json[],
|
||||
});
|
||||
|
||||
if (error || !submissionId) {
|
||||
handleError(error || new Error('No submission ID returned'), {
|
||||
action: 'Submit timeline event',
|
||||
if (timelineSubmissionError) {
|
||||
handleError(timelineSubmissionError, {
|
||||
action: 'Submit timeline event data',
|
||||
userId,
|
||||
});
|
||||
throw new Error('Failed to submit timeline event for review');
|
||||
}
|
||||
|
||||
// ✅ Create submission_items referencing timeline_event_submission (no JSON data)
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: 'timeline_event',
|
||||
action_type: 'create',
|
||||
item_data: {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId
|
||||
} as Json,
|
||||
status: 'pending' as const,
|
||||
order_index: 0,
|
||||
timeline_event_submission_id: timelineSubmission.id
|
||||
});
|
||||
|
||||
if (itemError) {
|
||||
handleError(itemError, {
|
||||
action: 'Create timeline event submission item',
|
||||
userId,
|
||||
});
|
||||
throw new Error('Failed to link timeline event submission');
|
||||
}
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
submissionId: submissionId,
|
||||
submissionId: submissionData.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2332,49 +2457,85 @@ export async function submitTimelineEventUpdate(
|
||||
// Extract only changed fields from form data
|
||||
const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>);
|
||||
|
||||
const itemData: Record<string, unknown> = {
|
||||
...changedFields,
|
||||
// Always include entity reference (for FK integrity)
|
||||
// Create the main submission record
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'timeline_event',
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (submissionError) {
|
||||
handleError(submissionError, {
|
||||
action: 'Update timeline event',
|
||||
metadata: { eventId },
|
||||
});
|
||||
throw new Error('Failed to create timeline event update submission');
|
||||
}
|
||||
|
||||
// ✅ FIXED: Insert into timeline_event_submissions table (relational pattern)
|
||||
const { data: timelineSubmission, error: timelineSubmissionError } = await supabase
|
||||
.from('timeline_event_submissions')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
entity_type: originalEvent.entity_type,
|
||||
entity_id: originalEvent.entity_id,
|
||||
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
|
||||
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
|
||||
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day',
|
||||
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
|
||||
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
|
||||
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
|
||||
to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value,
|
||||
from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id,
|
||||
to_entity_id: changedFields.to_entity_id !== undefined ? changedFields.to_entity_id : originalEvent.to_entity_id,
|
||||
from_location_id: changedFields.from_location_id !== undefined ? changedFields.from_location_id : originalEvent.from_location_id,
|
||||
to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id,
|
||||
is_public: true,
|
||||
};
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
// Use atomic RPC function to create submission and item together
|
||||
const { data: result, error: rpcError } = await supabase.rpc(
|
||||
'create_submission_with_items',
|
||||
{
|
||||
p_user_id: userId,
|
||||
p_submission_type: 'timeline_event',
|
||||
p_content: {
|
||||
action: 'edit',
|
||||
event_id: eventId,
|
||||
entity_type: originalEvent.entity_type,
|
||||
} as unknown as Json,
|
||||
p_items: [
|
||||
{
|
||||
item_type: 'timeline_event',
|
||||
action_type: 'edit',
|
||||
item_data: itemData,
|
||||
original_data: originalEvent,
|
||||
status: 'pending' as const,
|
||||
order_index: 0,
|
||||
}
|
||||
] as unknown as Json[],
|
||||
}
|
||||
);
|
||||
|
||||
if (rpcError || !result) {
|
||||
handleError(rpcError || new Error('No result returned'), {
|
||||
action: 'Update timeline event',
|
||||
if (timelineSubmissionError) {
|
||||
handleError(timelineSubmissionError, {
|
||||
action: 'Update timeline event data',
|
||||
metadata: { eventId },
|
||||
});
|
||||
throw new Error('Failed to submit timeline event update');
|
||||
}
|
||||
|
||||
// ✅ Create submission_items referencing timeline_event_submission (no JSON data)
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: 'timeline_event',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
event_id: eventId,
|
||||
entity_type: originalEvent.entity_type,
|
||||
entity_id: originalEvent.entity_id
|
||||
} as Json,
|
||||
original_data: JSON.parse(JSON.stringify(originalEvent)),
|
||||
status: 'pending' as const,
|
||||
order_index: 0,
|
||||
timeline_event_submission_id: timelineSubmission.id
|
||||
});
|
||||
|
||||
if (itemError) {
|
||||
handleError(itemError, {
|
||||
action: 'Create timeline event update submission item',
|
||||
metadata: { eventId },
|
||||
});
|
||||
throw new Error('Failed to link timeline event update submission');
|
||||
}
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
submissionId: result,
|
||||
submissionId: submissionData.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1653,6 +1653,37 @@ async function createPark(supabase: any, data: any): Promise<string> {
|
||||
parkId = data.park_id;
|
||||
delete data.park_id; // Remove ID from update data
|
||||
|
||||
// ✅ FIXED: Handle location updates from temp_location_data
|
||||
if (data.temp_location_data && !data.location_id) {
|
||||
edgeLogger.info('Creating location from temp data for update', {
|
||||
action: 'approval_create_location_update',
|
||||
locationName: data.temp_location_data.name
|
||||
});
|
||||
|
||||
const { data: newLocation, error: locationError } = await supabase
|
||||
.from('locations')
|
||||
.insert({
|
||||
name: data.temp_location_data.name,
|
||||
street_address: data.temp_location_data.street_address || null,
|
||||
city: data.temp_location_data.city,
|
||||
state_province: data.temp_location_data.state_province,
|
||||
country: data.temp_location_data.country,
|
||||
latitude: data.temp_location_data.latitude,
|
||||
longitude: data.temp_location_data.longitude,
|
||||
timezone: data.temp_location_data.timezone,
|
||||
postal_code: data.temp_location_data.postal_code
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (locationError) {
|
||||
throw new Error(`Failed to create location: ${locationError.message}`);
|
||||
}
|
||||
|
||||
data.location_id = newLocation.id;
|
||||
}
|
||||
delete data.temp_location_data;
|
||||
|
||||
const normalizedData = normalizeParkTypeValue(normalizeStatusValue(data));
|
||||
const sanitizedData = sanitizeDateFields(normalizedData);
|
||||
const filteredData = filterDatabaseFields(sanitizedData, PARK_FIELDS);
|
||||
@@ -1764,6 +1795,89 @@ async function createRide(supabase: any, data: any): Promise<string> {
|
||||
|
||||
if (error) throw new Error(`Failed to update ride: ${error.message}`);
|
||||
|
||||
// ✅ FIXED: Handle nested data updates (technical specs, coaster stats, name history)
|
||||
// For updates, we typically replace all related data rather than merge
|
||||
// Delete existing and insert new
|
||||
if (technicalSpecifications.length > 0) {
|
||||
// Delete existing specs
|
||||
await supabase
|
||||
.from('ride_technical_specifications')
|
||||
.delete()
|
||||
.eq('ride_id', rideId);
|
||||
|
||||
// Insert new specs
|
||||
const techSpecsToInsert = technicalSpecifications.map((spec: any) => ({
|
||||
ride_id: rideId,
|
||||
spec_name: spec.spec_name,
|
||||
spec_value: spec.spec_value,
|
||||
spec_unit: spec.spec_unit || null,
|
||||
category: spec.category || null,
|
||||
display_order: spec.display_order || 0
|
||||
}));
|
||||
|
||||
const { error: techSpecError } = await supabase
|
||||
.from('ride_technical_specifications')
|
||||
.insert(techSpecsToInsert);
|
||||
|
||||
if (techSpecError) {
|
||||
edgeLogger.error('Failed to update technical specifications', { action: 'approval_update_specs', error: techSpecError.message, rideId });
|
||||
}
|
||||
}
|
||||
|
||||
if (coasterStatistics.length > 0) {
|
||||
// Delete existing stats
|
||||
await supabase
|
||||
.from('ride_coaster_stats')
|
||||
.delete()
|
||||
.eq('ride_id', rideId);
|
||||
|
||||
// Insert new stats
|
||||
const statsToInsert = coasterStatistics.map((stat: any) => ({
|
||||
ride_id: rideId,
|
||||
stat_name: stat.stat_name,
|
||||
stat_value: stat.stat_value,
|
||||
unit: stat.unit || null,
|
||||
category: stat.category || null,
|
||||
description: stat.description || null,
|
||||
display_order: stat.display_order || 0
|
||||
}));
|
||||
|
||||
const { error: statsError } = await supabase
|
||||
.from('ride_coaster_stats')
|
||||
.insert(statsToInsert);
|
||||
|
||||
if (statsError) {
|
||||
edgeLogger.error('Failed to update coaster statistics', { action: 'approval_update_stats', error: statsError.message, rideId });
|
||||
}
|
||||
}
|
||||
|
||||
if (nameHistory.length > 0) {
|
||||
// Delete existing name history
|
||||
await supabase
|
||||
.from('ride_name_history')
|
||||
.delete()
|
||||
.eq('ride_id', rideId);
|
||||
|
||||
// Insert new name history
|
||||
const namesToInsert = nameHistory.map((name: any) => ({
|
||||
ride_id: rideId,
|
||||
former_name: name.former_name,
|
||||
date_changed: name.date_changed || null,
|
||||
reason: name.reason || null,
|
||||
from_year: name.from_year || null,
|
||||
to_year: name.to_year || null,
|
||||
order_index: name.order_index || 0
|
||||
}));
|
||||
|
||||
const { error: namesError } = await supabase
|
||||
.from('ride_name_history')
|
||||
.insert(namesToInsert);
|
||||
|
||||
if (namesError) {
|
||||
edgeLogger.error('Failed to update name history', { action: 'approval_update_names', error: namesError.message, rideId });
|
||||
}
|
||||
}
|
||||
|
||||
// Update park ride counts after successful ride update
|
||||
if (parkId) {
|
||||
edgeLogger.info('Updating ride counts for park', { action: 'approval_update_counts', parkId });
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Fix timeline event display in moderation queue
|
||||
-- Add timeline_event_submissions to the get_submission_items_with_entities function
|
||||
|
||||
-- Drop and recreate the function with timeline events support
|
||||
DROP FUNCTION IF EXISTS get_submission_items_with_entities(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_submission_items_with_entities(p_submission_id UUID)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
submission_id UUID,
|
||||
item_type TEXT,
|
||||
action_type TEXT,
|
||||
status TEXT,
|
||||
order_index INTEGER,
|
||||
depends_on UUID,
|
||||
park_submission_id UUID,
|
||||
ride_submission_id UUID,
|
||||
company_submission_id UUID,
|
||||
photo_submission_id UUID,
|
||||
ride_model_submission_id UUID,
|
||||
timeline_event_submission_id UUID,
|
||||
approved_entity_id UUID,
|
||||
rejection_reason TEXT,
|
||||
is_test_data BOOLEAN,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
entity_data JSONB
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
si.id,
|
||||
si.submission_id,
|
||||
si.item_type,
|
||||
si.action_type,
|
||||
si.status,
|
||||
si.order_index,
|
||||
si.depends_on,
|
||||
si.park_submission_id,
|
||||
si.ride_submission_id,
|
||||
si.company_submission_id,
|
||||
si.photo_submission_id,
|
||||
si.ride_model_submission_id,
|
||||
si.timeline_event_submission_id,
|
||||
si.approved_entity_id,
|
||||
si.rejection_reason,
|
||||
si.is_test_data,
|
||||
si.created_at,
|
||||
si.updated_at,
|
||||
-- Join entity data based on item_type
|
||||
CASE
|
||||
WHEN si.item_type = 'park' THEN
|
||||
(SELECT to_jsonb(ps.*) FROM park_submissions ps WHERE ps.id = si.park_submission_id)
|
||||
WHEN si.item_type = 'ride' THEN
|
||||
(SELECT to_jsonb(rs.*) FROM ride_submissions rs WHERE rs.id = si.ride_submission_id)
|
||||
WHEN si.item_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||
(SELECT to_jsonb(cs.*) FROM company_submissions cs WHERE cs.id = si.company_submission_id)
|
||||
WHEN si.item_type IN ('photo', 'photo_edit', 'photo_delete') THEN
|
||||
(SELECT to_jsonb(phs.*) FROM photo_submissions phs WHERE phs.id = si.photo_submission_id)
|
||||
WHEN si.item_type = 'ride_model' THEN
|
||||
(SELECT to_jsonb(rms.*) FROM ride_model_submissions rms WHERE rms.id = si.ride_model_submission_id)
|
||||
WHEN si.item_type IN ('milestone', 'timeline_event') THEN
|
||||
(SELECT to_jsonb(tes.*) FROM timeline_event_submissions tes WHERE tes.id = si.timeline_event_submission_id)
|
||||
ELSE NULL
|
||||
END AS entity_data
|
||||
FROM submission_items si
|
||||
WHERE si.submission_id = p_submission_id
|
||||
ORDER BY si.order_index;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_submission_items_with_entities IS
|
||||
'Fetch submission items with their entity data in a single query. Uses SECURITY DEFINER to access submission tables with proper RLS context. Now includes timeline_event_submissions.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION get_submission_items_with_entities(uuid) TO authenticated;
|
||||
Reference in New Issue
Block a user