diff --git a/src/components/moderation/SubmissionChangesDisplay.tsx b/src/components/moderation/SubmissionChangesDisplay.tsx
index 11ac2645..d0db5cb8 100644
--- a/src/components/moderation/SubmissionChangesDisplay.tsx
+++ b/src/components/moderation/SubmissionChangesDisplay.tsx
@@ -6,7 +6,9 @@ import { PhotoAdditionPreview, PhotoEditPreview, PhotoDeletionPreview } from './
import { detectChanges, type ChangesSummary } from '@/lib/submissionChangeDetection';
import type { SubmissionItemData } from '@/types/submissions';
import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
-import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle } from 'lucide-react';
+import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle, Calendar } from 'lucide-react';
+import { TimelineEventPreview } from './TimelineEventPreview';
+import type { TimelineSubmissionData } from '@/types/timeline';
interface SubmissionChangesDisplayProps {
item: SubmissionItemData | SubmissionItemWithDeps | { item_data?: any; original_data?: any; item_type: string; action_type?: 'create' | 'edit' | 'delete' };
@@ -62,6 +64,8 @@ export function SubmissionChangesDisplay({
case 'photo':
case 'photo_edit':
case 'photo_delete': return ;
+ case 'milestone':
+ case 'timeline_event': return ;
default: return ;
}
};
@@ -111,6 +115,35 @@ export function SubmissionChangesDisplay({
);
}
+
+ // Special compact display for milestone/timeline events
+ if (item.item_type === 'milestone' || item.item_type === 'timeline_event') {
+ const milestoneData = item.item_data as TimelineSubmissionData;
+ const eventType = milestoneData.event_type?.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) || 'Event';
+ const eventDate = milestoneData.event_date ? new Date(milestoneData.event_date).toLocaleDateString() : 'No date';
+
+ return (
+
+
+ {getEntityIcon()}
+ {milestoneData.title}
+ {getActionBadge()}
+
+ {eventType}
+
+
+
+
+ {eventDate}
+ {milestoneData.from_value && milestoneData.to_value && (
+
+ • {milestoneData.from_value} → {milestoneData.to_value}
+
+ )}
+
+
+ );
+ }
return (
@@ -193,6 +226,23 @@ export function SubmissionChangesDisplay({
);
}
+
+ // Detailed view - special handling for milestone/timeline events
+ if (item.item_type === 'milestone' || item.item_type === 'timeline_event') {
+ const milestoneData = item.item_data as TimelineSubmissionData;
+ return (
+
+
+ {getEntityIcon()}
+
{milestoneData.title}
+ {getActionBadge()}
+ Timeline Event
+
+
+
+
+ );
+ }
// Detailed view for other items
return (
diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts
index 31bc5635..753dad8a 100644
--- a/src/lib/entitySubmissionHelpers.ts
+++ b/src/lib/entitySubmissionHelpers.ts
@@ -1167,30 +1167,7 @@ export async function submitTimelineEventUpdate(
throw new Error('Failed to fetch original timeline event');
}
- // Create submission
- const content: Json = {
- action: 'edit',
- event_id: eventId,
- entity_type: originalEvent.entity_type,
- };
-
- const { data: submission, error: submissionError } = await supabase
- .from('content_submissions')
- .insert({
- user_id: userId,
- submission_type: 'milestone',
- content,
- status: 'pending',
- approval_mode: 'full',
- })
- .select()
- .single();
-
- if (submissionError || !submission) {
- throw new Error('Failed to create timeline event update submission');
- }
-
- // Create submission item
+ // Prepare item data
const itemData: Record = {
entity_type: originalEvent.entity_type,
entity_id: originalEvent.entity_id,
@@ -1205,28 +1182,41 @@ export async function submitTimelineEventUpdate(
to_entity_id: data.to_entity_id,
from_location_id: data.from_location_id,
to_location_id: data.to_location_id,
- is_public: true, // All timeline events are public
+ is_public: true,
};
- const { error: itemError } = await supabase
- .from('submission_items')
- .insert({
- submission_id: submission.id,
- item_type: 'milestone',
- action_type: 'edit',
- item_data: itemData as unknown as Json,
- original_data: originalEvent as unknown as Json,
- status: 'pending',
- order_index: 0,
- });
+ // 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: 'milestone',
+ p_content: {
+ action: 'edit',
+ event_id: eventId,
+ entity_type: originalEvent.entity_type,
+ } as unknown as Json,
+ p_items: [
+ {
+ item_type: 'milestone',
+ action_type: 'edit',
+ item_data: itemData,
+ original_data: originalEvent,
+ status: 'pending',
+ order_index: 0,
+ }
+ ] as unknown as Json[],
+ }
+ );
- if (itemError) {
- throw new Error('Failed to submit timeline event update item');
+ if (rpcError || !result) {
+ console.error('Failed to create timeline event update:', rpcError);
+ throw new Error('Failed to submit timeline event update');
}
return {
submitted: true,
- submissionId: submission.id,
+ submissionId: result,
};
}
diff --git a/src/lib/entityValidationSchemas.ts b/src/lib/entityValidationSchemas.ts
index 1a83b1d3..9cff8abc 100644
--- a/src/lib/entityValidationSchemas.ts
+++ b/src/lib/entityValidationSchemas.ts
@@ -195,18 +195,34 @@ export const milestoneValidationSchema = z.object({
title: z.string().trim().min(1, 'Event title is required').max(200, 'Title must be less than 200 characters'),
description: z.string().trim().max(2000, 'Description must be less than 2000 characters').optional().or(z.literal('')),
event_type: z.string().min(1, 'Event type is required'),
- event_date: z.string().min(1, 'Event date is required'),
- event_date_precision: z.enum(['day', 'month', 'year']).optional(),
+ event_date: z.string().min(1, 'Event date is required').refine((val) => {
+ if (!val) return true;
+ const date = new Date(val);
+ const fiveYearsFromNow = new Date();
+ fiveYearsFromNow.setFullYear(fiveYearsFromNow.getFullYear() + 5);
+ return date <= fiveYearsFromNow;
+ }, 'Event date cannot be more than 5 years in the future'),
+ event_date_precision: z.enum(['day', 'month', 'year']).optional().default('day'),
entity_type: z.string().min(1, 'Entity type is required'),
entity_id: z.string().uuid('Invalid entity ID'),
is_public: z.boolean().optional(),
display_order: z.number().optional(),
- from_value: z.string().optional(),
- to_value: z.string().optional(),
+ from_value: z.string().trim().max(200).optional().or(z.literal('')),
+ to_value: z.string().trim().max(200).optional().or(z.literal('')),
from_entity_id: z.string().uuid().optional().nullable(),
to_entity_id: z.string().uuid().optional().nullable(),
from_location_id: z.string().uuid().optional().nullable(),
to_location_id: z.string().uuid().optional().nullable(),
+}).refine((data) => {
+ // For change events, require from_value or to_value
+ const changeEvents = ['name_change', 'operator_change', 'owner_change', 'location_change', 'status_change'];
+ if (changeEvents.includes(data.event_type)) {
+ return data.from_value || data.to_value || data.from_entity_id || data.to_entity_id || data.from_location_id || data.to_location_id;
+ }
+ return true;
+}, {
+ message: 'Change events must specify what changed (from/to values or entity IDs)',
+ path: ['from_value'],
});
// ============================================
diff --git a/src/lib/moderation/entities.ts b/src/lib/moderation/entities.ts
index 41be69a3..79da128a 100644
--- a/src/lib/moderation/entities.ts
+++ b/src/lib/moderation/entities.ts
@@ -212,6 +212,9 @@ export function getSubmissionTypeLabel(submissionType: string): string {
property_owner: 'Property Owner',
ride_model: 'Ride Model',
photo: 'Photo',
+ photo_delete: 'Photo Deletion',
+ milestone: 'Timeline Event',
+ timeline_event: 'Timeline Event',
review: 'Review',
};