Refactor: Implement milestone moderation fixes

This commit is contained in:
gpt-engineer-app[bot]
2025-10-17 17:21:21 +00:00
parent 62c8b7f2c3
commit 026f402057
4 changed files with 103 additions and 44 deletions

View File

@@ -6,7 +6,9 @@ import { PhotoAdditionPreview, PhotoEditPreview, PhotoDeletionPreview } from './
import { detectChanges, type ChangesSummary } from '@/lib/submissionChangeDetection'; import { detectChanges, type ChangesSummary } from '@/lib/submissionChangeDetection';
import type { SubmissionItemData } from '@/types/submissions'; import type { SubmissionItemData } from '@/types/submissions';
import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService'; 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 { interface SubmissionChangesDisplayProps {
item: SubmissionItemData | SubmissionItemWithDeps | { item_data?: any; original_data?: any; item_type: string; action_type?: 'create' | 'edit' | 'delete' }; 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':
case 'photo_edit': case 'photo_edit':
case 'photo_delete': return <ImageIcon className={iconClass} />; case 'photo_delete': return <ImageIcon className={iconClass} />;
case 'milestone':
case 'timeline_event': return <Calendar className={iconClass} />;
default: return <MapPin className={iconClass} />; default: return <MapPin className={iconClass} />;
} }
}; };
@@ -111,6 +115,35 @@ export function SubmissionChangesDisplay({
</div> </div>
); );
} }
// 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 (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
{getEntityIcon()}
<span className="font-medium">{milestoneData.title}</span>
{getActionBadge()}
<Badge variant="secondary" className="text-xs">
{eventType}
</Badge>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Calendar className="h-3 w-3" />
{eventDate}
{milestoneData.from_value && milestoneData.to_value && (
<span className="ml-1">
{milestoneData.from_value} {milestoneData.to_value}
</span>
)}
</div>
</div>
);
}
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -193,6 +226,23 @@ export function SubmissionChangesDisplay({
</div> </div>
); );
} }
// 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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{getEntityIcon()}
<h3 className="text-lg font-semibold">{milestoneData.title}</h3>
{getActionBadge()}
<Badge variant="secondary">Timeline Event</Badge>
</div>
<TimelineEventPreview data={milestoneData} />
</div>
);
}
// Detailed view for other items // Detailed view for other items
return ( return (

View File

@@ -1167,30 +1167,7 @@ export async function submitTimelineEventUpdate(
throw new Error('Failed to fetch original timeline event'); throw new Error('Failed to fetch original timeline event');
} }
// Create submission // Prepare item data
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
const itemData: Record<string, any> = { const itemData: Record<string, any> = {
entity_type: originalEvent.entity_type, entity_type: originalEvent.entity_type,
entity_id: originalEvent.entity_id, entity_id: originalEvent.entity_id,
@@ -1205,28 +1182,41 @@ export async function submitTimelineEventUpdate(
to_entity_id: data.to_entity_id, to_entity_id: data.to_entity_id,
from_location_id: data.from_location_id, from_location_id: data.from_location_id,
to_location_id: data.to_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 // Use atomic RPC function to create submission and item together
.from('submission_items') const { data: result, error: rpcError } = await supabase.rpc(
.insert({ 'create_submission_with_items',
submission_id: submission.id, {
item_type: 'milestone', p_user_id: userId,
action_type: 'edit', p_submission_type: 'milestone',
item_data: itemData as unknown as Json, p_content: {
original_data: originalEvent as unknown as Json, action: 'edit',
status: 'pending', event_id: eventId,
order_index: 0, 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) { if (rpcError || !result) {
throw new Error('Failed to submit timeline event update item'); console.error('Failed to create timeline event update:', rpcError);
throw new Error('Failed to submit timeline event update');
} }
return { return {
submitted: true, submitted: true,
submissionId: submission.id, submissionId: result,
}; };
} }

View File

@@ -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'), 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('')), 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_type: z.string().min(1, 'Event type is required'),
event_date: z.string().min(1, 'Event date is required'), event_date: z.string().min(1, 'Event date is required').refine((val) => {
event_date_precision: z.enum(['day', 'month', 'year']).optional(), 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_type: z.string().min(1, 'Entity type is required'),
entity_id: z.string().uuid('Invalid entity ID'), entity_id: z.string().uuid('Invalid entity ID'),
is_public: z.boolean().optional(), is_public: z.boolean().optional(),
display_order: z.number().optional(), display_order: z.number().optional(),
from_value: z.string().optional(), from_value: z.string().trim().max(200).optional().or(z.literal('')),
to_value: z.string().optional(), to_value: z.string().trim().max(200).optional().or(z.literal('')),
from_entity_id: z.string().uuid().optional().nullable(), from_entity_id: z.string().uuid().optional().nullable(),
to_entity_id: z.string().uuid().optional().nullable(), to_entity_id: z.string().uuid().optional().nullable(),
from_location_id: z.string().uuid().optional().nullable(), from_location_id: z.string().uuid().optional().nullable(),
to_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'],
}); });
// ============================================ // ============================================

View File

@@ -212,6 +212,9 @@ export function getSubmissionTypeLabel(submissionType: string): string {
property_owner: 'Property Owner', property_owner: 'Property Owner',
ride_model: 'Ride Model', ride_model: 'Ride Model',
photo: 'Photo', photo: 'Photo',
photo_delete: 'Photo Deletion',
milestone: 'Timeline Event',
timeline_event: 'Timeline Event',
review: 'Review', review: 'Review',
}; };