Compare commits

...

3 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
1a4e30674f Refactor: Improve timeline event display
Implement changes to enhance the display of timeline event submissions in the moderation queue. This includes updating the `get_submission_items_with_entities` function to include timeline event data, creating a new `RichTimelineEventDisplay` component, and modifying `SubmissionItemsList` and `TimelineEventPreview` components to utilize the new display logic.
2025-11-06 15:25:33 +00:00
gpt-engineer-app[bot]
4d7b00e4e7 feat: Implement rich timeline event display
Implement the plan to enhance the display of timeline event submissions in the moderation queue. This includes fixing the database function to fetch timeline event data, creating a new `RichTimelineEventDisplay` component, and updating the `SubmissionItemsList` and `TimelineEventPreview` components to leverage this new display. The goal is to provide moderators with complete and contextually rich information for timeline events.
2025-11-06 15:24:46 +00:00
gpt-engineer-app[bot]
bd4f75bfb2 Fix entity submission pipelines
Refactor park updates, ride updates, and timeline event submissions to use dedicated relational tables instead of JSON blobs in `submission_items.item_data`. This enforces the "NO JSON IN SQL" rule, improving queryability, data integrity, and consistency across the pipeline.
2025-11-06 15:13:36 +00:00
6 changed files with 815 additions and 100 deletions

View File

@@ -6,6 +6,7 @@ import { RichParkDisplay } from './displays/RichParkDisplay';
import { RichRideDisplay } from './displays/RichRideDisplay'; import { RichRideDisplay } from './displays/RichRideDisplay';
import { RichCompanyDisplay } from './displays/RichCompanyDisplay'; import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
import { RichRideModelDisplay } from './displays/RichRideModelDisplay'; import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -13,6 +14,7 @@ import { AlertCircle, Loader2 } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { SubmissionItemData } from '@/types/submissions'; import type { SubmissionItemData } from '@/types/submissions';
import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data'; import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data';
import type { TimelineSubmissionData } from '@/types/timeline';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler'; import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary'; 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 // Fallback to SubmissionChangesDisplay
return ( return (
<> <>

View File

@@ -1,38 +1,93 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 type { TimelineSubmissionData } from '@/types/timeline';
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
interface TimelineEventPreviewProps { interface TimelineEventPreviewProps {
data: TimelineSubmissionData; data: TimelineSubmissionData;
} }
export function TimelineEventPreview({ data }: TimelineEventPreviewProps) { 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) => { const formatEventType = (type: string) => {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Tag className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Timeline Event: {data.title} {data.title}
</CardTitle> </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> </CardHeader>
<CardContent className="space-y-4"> <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 className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium">Event Type:</span> <span className="font-medium">Event Date:</span>
<p className="text-muted-foreground"> <p className="text-muted-foreground flex items-center gap-1 mt-1">
{formatEventType(data.event_type)}
</p>
</div>
<div>
<span className="font-medium">Date:</span>
<p className="text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{new Date(data.event_date).toLocaleDateString()} <FlexibleDateDisplay
({data.event_date_precision}) 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> </p>
</div> </div>
</div> </div>
@@ -45,6 +100,20 @@ export function TimelineEventPreview({ data }: TimelineEventPreviewProps) {
</span> </span>
</div> </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 && ( {data.description && (
<div> <div>

View 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>
);
}

View File

@@ -91,7 +91,9 @@ export interface ParkFormData {
park_type: string; park_type: string;
status: string; status: string;
opening_date?: string; opening_date?: string;
opening_date_precision?: string;
closing_date?: string; closing_date?: string;
closing_date_precision?: string;
website_url?: string; website_url?: string;
phone?: string; phone?: string;
email?: string; email?: string;
@@ -131,7 +133,9 @@ export interface RideFormData {
designer_id?: string; designer_id?: string;
ride_model_id?: string; ride_model_id?: string;
opening_date?: string; opening_date?: string;
opening_date_precision?: string;
closing_date?: string; closing_date?: string;
closing_date_precision?: string;
max_speed_kmh?: number; max_speed_kmh?: number;
max_height_meters?: number; max_height_meters?: number;
length_meters?: number; length_meters?: number;
@@ -890,21 +894,72 @@ export async function submitParkUpdate(
if (submissionError) throw submissionError; 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 const { error: itemError } = await supabase
.from('submission_items') .from('submission_items')
.insert({ .insert({
submission_id: submissionData.id, submission_id: submissionData.id,
item_type: 'park', item_type: 'park',
action_type: 'edit', action_type: 'edit',
item_data: JSON.parse(JSON.stringify({ item_data: {
...extractChangedFields(data, existingPark as any), park_id: parkId, // Only reference IDs
park_id: parkId, // Always include for relational integrity images: processedImages as unknown as Json
images: processedImages },
})) as Json,
original_data: JSON.parse(JSON.stringify(existingPark)), original_data: JSON.parse(JSON.stringify(existingPark)),
status: 'pending' as const, status: 'pending' as const,
order_index: 0 order_index: 0,
park_submission_id: parkSubmission.id
}); });
if (itemError) throw itemError; if (itemError) throw itemError;
@@ -1440,7 +1495,52 @@ export async function submitRideUpdate(
if (submissionError) throw submissionError; 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 const { error: itemError } = await supabase
.from('submission_items') .from('submission_items')
.insert({ .insert({
@@ -1448,13 +1548,13 @@ export async function submitRideUpdate(
item_type: 'ride', item_type: 'ride',
action_type: 'edit', action_type: 'edit',
item_data: { item_data: {
...extractChangedFields(data, existingRide as any), ride_id: rideId, // Only reference IDs
ride_id: rideId, // Always include for relational integrity
images: processedImages as unknown as Json images: processedImages as unknown as Json
}, },
original_data: JSON.parse(JSON.stringify(existingRide)), original_data: JSON.parse(JSON.stringify(existingRide)),
status: 'pending' as const, status: 'pending' as const,
order_index: 0 order_index: 0,
ride_submission_id: rideSubmission.id
}); });
if (itemError) throw itemError; if (itemError) throw itemError;
@@ -2248,58 +2348,83 @@ export async function submitTimelineEvent(
throw new Error('User ID is required for timeline event submission'); 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 // Create the main submission record
// Use atomic RPC function to create submission + items in transaction const { data: submissionData, error: submissionError } = await supabase
const itemData: Record<string, any> = { .from('content_submissions')
entity_type: entityType, .insert({
entity_id: entityId, user_id: userId,
event_type: data.event_type, submission_type: 'timeline_event',
event_date: data.event_date.toISOString().split('T')[0], status: 'pending' as const
event_date_precision: data.event_date_precision, })
title: data.title, .select('id')
description: data.description, .single();
from_value: data.from_value,
to_value: data.to_value,
from_entity_id: data.from_entity_id,
to_entity_id: data.to_entity_id,
from_location_id: data.from_location_id,
to_location_id: data.to_location_id,
is_public: true,
};
const items = [{ if (submissionError) {
item_type: 'timeline_event', handleError(submissionError, {
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', action: 'Submit timeline event',
userId, 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,
event_date: data.event_date.toISOString().split('T')[0],
event_date_precision: data.event_date_precision,
title: data.title,
description: data.description,
from_value: data.from_value,
to_value: data.to_value,
from_entity_id: data.from_entity_id,
to_entity_id: data.to_entity_id,
from_location_id: data.from_location_id,
to_location_id: data.to_location_id,
is_public: true,
})
.select('id')
.single();
if (timelineSubmissionError) {
handleError(timelineSubmissionError, {
action: 'Submit timeline event data',
userId,
});
throw new Error('Failed to submit timeline event for review'); 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 { return {
submitted: true, submitted: true,
submissionId: submissionId, submissionId: submissionData.id,
}; };
} }
@@ -2332,49 +2457,85 @@ export async function submitTimelineEventUpdate(
// Extract only changed fields from form data // Extract only changed fields from form data
const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>); const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>);
const itemData: Record<string, unknown> = { // Create the main submission record
...changedFields, const { data: submissionData, error: submissionError } = await supabase
// Always include entity reference (for FK integrity) .from('content_submissions')
entity_type: originalEvent.entity_type, .insert({
entity_id: originalEvent.entity_id, user_id: userId,
is_public: true, submission_type: 'timeline_event',
}; status: 'pending' as const
})
.select('id')
.single();
// Use atomic RPC function to create submission and item together if (submissionError) {
const { data: result, error: rpcError } = await supabase.rpc( handleError(submissionError, {
'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', action: 'Update timeline event',
metadata: { eventId }, 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();
if (timelineSubmissionError) {
handleError(timelineSubmissionError, {
action: 'Update timeline event data',
metadata: { eventId },
});
throw new Error('Failed to submit timeline event update'); 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 { return {
submitted: true, submitted: true,
submissionId: result, submissionId: submissionData.id,
}; };
} }

View File

@@ -1653,6 +1653,37 @@ async function createPark(supabase: any, data: any): Promise<string> {
parkId = data.park_id; parkId = data.park_id;
delete data.park_id; // Remove ID from update data 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 normalizedData = normalizeParkTypeValue(normalizeStatusValue(data));
const sanitizedData = sanitizeDateFields(normalizedData); const sanitizedData = sanitizeDateFields(normalizedData);
const filteredData = filterDatabaseFields(sanitizedData, PARK_FIELDS); 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}`); 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 // Update park ride counts after successful ride update
if (parkId) { if (parkId) {
edgeLogger.info('Updating ride counts for park', { action: 'approval_update_counts', parkId }); edgeLogger.info('Updating ride counts for park', { action: 'approval_update_counts', parkId });

View File

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