diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index 664a3dbf..ad748987 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -222,17 +222,26 @@ export function SubmissionReviewManager({ const successCount = data.results.filter((r: any) => r.success).length; const failCount = data.results.filter((r: any) => !r.success).length; + const allFailed = failCount > 0 && successCount === 0; + const someFailed = failCount > 0 && successCount > 0; + toast({ - title: 'Approval Complete', + title: allFailed ? 'Approval Failed' : someFailed ? 'Partial Approval' : 'Approval Complete', description: failCount > 0 ? `Approved ${successCount} item(s), ${failCount} failed` : `Successfully approved ${successCount} item(s)`, - variant: failCount > 0 ? 'destructive' : 'default', + variant: allFailed ? 'destructive' : someFailed ? 'default' : 'default', }); // Reset warning confirmation state after approval setUserConfirmedWarnings(false); + // If ALL items failed, don't close dialog - show errors + if (allFailed) { + setLoading(false); + return; + } + onComplete(); onOpenChange(false); } catch (error: any) { diff --git a/src/hooks/moderation/useModerationQueueManager.ts b/src/hooks/moderation/useModerationQueueManager.ts index 6ee815cc..ceceb06a 100644 --- a/src/hooks/moderation/useModerationQueueManager.ts +++ b/src/hooks/moderation/useModerationQueueManager.ts @@ -127,25 +127,34 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): // Refs for tracking const recentlyRemovedRef = useRef>(new Set()); const fetchInProgressRef = useRef(false); - const itemsRef = useRef([]); const lastFetchTimeRef = useRef(0); - const pauseFetchingRef = useRef(false); const initialFetchCompleteRef = useRef(false); const isMountingRef = useRef(true); - const fetchItemsRef = useRef<((silent?: boolean) => Promise) | null>(null); const FETCH_COOLDOWN_MS = 1000; - // Store settings in refs to avoid re-creating fetchItems + // Store settings, filters, sort, and pagination in refs to stabilize fetchItems const settingsRef = useRef(settings); + const filtersRef = useRef(filters); + const sortRef = useRef(sort.debouncedConfig); + const paginationRef = useRef(pagination); + + // Sync refs with state useEffect(() => { settingsRef.current = settings; }, [settings]); - // Sync items with ref useEffect(() => { - itemsRef.current = items; - }, [items]); + filtersRef.current = filters; + }, [filters]); + + useEffect(() => { + sortRef.current = sort.debouncedConfig; + }, [sort.debouncedConfig]); + + useEffect(() => { + paginationRef.current = pagination; + }, [pagination]); /** * Fetch queue items from database @@ -160,20 +169,13 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): console.log("πŸ”„ [FETCH ITEMS] Called", { silent, - pauseFetchingRef: pauseFetchingRef.current, documentHidden: document.hidden, caller: callerLine, - sortField: sort.debouncedConfig.field, - sortDirection: sort.debouncedConfig.direction, + sortField: sortRef.current.field, + sortDirection: sortRef.current.direction, timestamp: new Date().toISOString(), }); - // Check if fetching is paused (controlled by visibility handler if enabled) - if (pauseFetchingRef.current) { - console.log("⏸️ Fetch paused by pauseFetchingRef"); - return; - } - // Prevent concurrent calls if (fetchInProgressRef.current) { console.log("⚠️ Fetch already in progress, skipping"); @@ -192,10 +194,10 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): lastFetchTimeRef.current = now; console.log("πŸ” fetchItems called:", { - entityFilter: filters.debouncedEntityFilter, - statusFilter: filters.debouncedStatusFilter, - sortField: sort.debouncedConfig.field, - sortDirection: sort.debouncedConfig.direction, + entityFilter: filtersRef.current.debouncedEntityFilter, + statusFilter: filtersRef.current.debouncedStatusFilter, + sortField: sortRef.current.field, + sortDirection: sortRef.current.direction, silent, timestamp: new Date().toISOString(), }); @@ -237,28 +239,28 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): // CRITICAL: Multi-level ordering console.log('πŸ“Š [SORT QUERY] Applying multi-level sort:', { level1: 'escalated DESC', - level2: `${sort.debouncedConfig.field} ${sort.debouncedConfig.direction.toUpperCase()}`, - level3: sort.debouncedConfig.field !== 'created_at' ? 'created_at ASC' : 'none' + level2: `${sortRef.current.field} ${sortRef.current.direction.toUpperCase()}`, + level3: sortRef.current.field !== 'created_at' ? 'created_at ASC' : 'none' }); // Level 1: Always sort by escalated first (descending) submissionsQuery = submissionsQuery.order('escalated', { ascending: false }); - // Level 2: Apply user-selected sort (use debounced config) + // Level 2: Apply user-selected sort (use ref) submissionsQuery = submissionsQuery.order( - sort.debouncedConfig.field, - { ascending: sort.debouncedConfig.direction === 'asc' } + sortRef.current.field, + { ascending: sortRef.current.direction === 'asc' } ); // Level 3: Tertiary sort by created_at (if not already primary) - if (sort.debouncedConfig.field !== 'created_at') { + if (sortRef.current.field !== 'created_at') { submissionsQuery = submissionsQuery.order('created_at', { ascending: true }); } - // Apply tab-based status filtering - const tab = filters.activeTab; - const statusFilter = filters.debouncedStatusFilter; - const entityFilter = filters.debouncedEntityFilter; + // Apply tab-based status filtering (use refs) + const tab = filtersRef.current.activeTab; + const statusFilter = filtersRef.current.debouncedStatusFilter; + const entityFilter = filtersRef.current.debouncedEntityFilter; if (tab === "mainQueue") { if (statusFilter === "all") { @@ -328,11 +330,11 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): const { count } = await countQuery; - pagination.setTotalCount(count || 0); + paginationRef.current.setTotalCount(count || 0); - // Apply pagination - const startIndex = pagination.startIndex; - const endIndex = pagination.endIndex; + // Apply pagination (use refs) + const startIndex = paginationRef.current.startIndex; + const endIndex = paginationRef.current.endIndex; submissionsQuery = submissionsQuery.range(startIndex, endIndex); const { data: submissions, error: submissionsError } = await submissionsQuery; @@ -341,7 +343,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): // Log the actual data returned to verify sort order if (submissions && submissions.length > 0) { - const sortField = sort.debouncedConfig.field; + const sortField = sortRef.current.field; const preview = submissions.slice(0, 3).map(s => ({ id: s.id.substring(0, 8), [sortField]: s[sortField], @@ -426,7 +428,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): if (silent) { // Background polling: detect new submissions - const currentDisplayedIds = new Set(itemsRef.current.map((item) => item.id)); + const currentDisplayedIds = new Set(items.map((item) => item.id)); const newSubmissions = moderationItems.filter((item) => !currentDisplayedIds.has(item.id)); if (newSubmissions.length > 0) { @@ -452,7 +454,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): case "merge": if (newSubmissions.length > 0) { - const currentIds = new Set(itemsRef.current.map((item) => item.id)); + const currentIds = new Set(items.map((item) => item.id)); const trulyNewSubmissions = newSubmissions.filter((item) => !currentIds.has(item.id)); if (trulyNewSubmissions.length > 0) { @@ -463,7 +465,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): break; case "replace": - const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { + const mergeResult = smartMergeArray(items, moderationItems, { compareFields: [ "status", "content", @@ -527,11 +529,6 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): ], ); - // Store fetchItems in ref to avoid re-creating visibility listener - useEffect(() => { - fetchItemsRef.current = fetchItems; - }, [fetchItems]); - /** * Show pending new items by merging them into the queue */ @@ -865,21 +862,23 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): }); pagination.reset(); - fetchItemsRef.current?.(false); + fetchItems(false); }, [ filters.debouncedEntityFilter, filters.debouncedStatusFilter, sort.debouncedConfig.field, sort.debouncedConfig.direction, - user + user, + fetchItems, + pagination ]); // Pagination changes trigger refetch useEffect(() => { if (!user || !initialFetchCompleteRef.current || pagination.currentPage === 1) return; - fetchItemsRef.current?.(true); - }, [pagination.currentPage, pagination.pageSize]); + fetchItems(true); + }, [pagination.currentPage, pagination.pageSize, user, fetchItems]); // Polling effect (when realtime disabled) useEffect(() => { @@ -890,14 +889,14 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): console.log("⚠️ Polling ENABLED - interval:", settings.pollInterval); const interval = setInterval(() => { console.log("πŸ”„ Polling refresh triggered"); - fetchItemsRef.current?.(true); + fetchItems(true); }, settings.pollInterval); return () => { clearInterval(interval); console.log("πŸ›‘ Polling stopped"); }; - }, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue]); + }, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, fetchItems]); // Initialize realtime subscriptions useRealtimeSubscriptions({ @@ -941,7 +940,6 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): profileCache, recentlyRemovedIds: recentlyRemovedRef.current, interactingWithIds: interactingWith, - currentItemsRef: itemsRef, }); return { diff --git a/src/hooks/moderation/useRealtimeSubscriptions.ts b/src/hooks/moderation/useRealtimeSubscriptions.ts index 3c4f9a6a..0f4ebe79 100644 --- a/src/hooks/moderation/useRealtimeSubscriptions.ts +++ b/src/hooks/moderation/useRealtimeSubscriptions.ts @@ -61,8 +61,8 @@ export interface RealtimeSubscriptionConfig { /** Set of IDs currently being interacted with */ interactingWithIds: Set; - /** Current items in queue (for comparison) - using ref to avoid reconnections */ - currentItemsRef: React.MutableRefObject; + /** Current items in queue (for comparison) - using ref to avoid reconnections (optional) */ + currentItemsRef?: React.MutableRefObject; } /** diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 00c539e5..23f13087 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -1,6 +1,6 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; -import { validateEntityData } from "./validation.ts"; +import { validateEntityData, validateEntityDataStrict } from "./validation.ts"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -206,10 +206,28 @@ serve(async (req) => { try { console.log(`Processing item ${item.id} of type ${item.item_type}`); - // Validate entity data before processing - const validation = validateEntityData(item.item_type, item.item_data); - if (!validation.valid) { - throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + // Validate entity data with strict validation + const validation = validateEntityDataStrict(item.item_type, item.item_data); + + if (validation.blockingErrors.length > 0) { + console.error(`❌ Blocking errors for item ${item.id}:`, validation.blockingErrors); + + // Fail the entire batch if ANY item has blocking errors + return new Response(JSON.stringify({ + success: false, + message: 'Validation failed: Items have blocking errors that must be fixed', + errors: validation.blockingErrors, + failedItemId: item.id, + failedItemType: item.item_type + }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }); + } + + if (validation.warnings.length > 0) { + console.warn(`⚠️ Warnings for item ${item.id}:`, validation.warnings); + // Continue processing - warnings don't block approval } // Set user context for versioning trigger diff --git a/supabase/functions/process-selective-approval/validation.ts b/supabase/functions/process-selective-approval/validation.ts index bd4b70cc..e847402e 100644 --- a/supabase/functions/process-selective-approval/validation.ts +++ b/supabase/functions/process-selective-approval/validation.ts @@ -8,8 +8,165 @@ export interface ValidationResult { errors: string[]; } +export interface StrictValidationResult { + valid: boolean; + blockingErrors: string[]; + warnings: string[]; +} + /** - * Validate entity data before database write + * Strict validation that separates blocking errors from warnings + * Used by the approval flow to prevent invalid data from being approved + */ +export function validateEntityDataStrict( + entityType: string, + data: any +): StrictValidationResult { + const result: StrictValidationResult = { + valid: true, + blockingErrors: [], + warnings: [] + }; + + // Common validations (blocking) + if (!data.name?.trim()) { + result.blockingErrors.push('Name is required'); + } + + if (!data.slug?.trim()) { + result.blockingErrors.push('Slug is required'); + } + + if (data.slug && !/^[a-z0-9-]+$/.test(data.slug)) { + result.blockingErrors.push('Slug must contain only lowercase letters, numbers, and hyphens'); + } + + if (data.name && data.name.length > 200) { + result.blockingErrors.push('Name must be less than 200 characters'); + } + + if (data.description && data.description.length > 2000) { + result.blockingErrors.push('Description must be less than 2000 characters'); + } + + // URL validation (warning) + if (data.website_url && data.website_url !== '' && !isValidUrl(data.website_url)) { + result.warnings.push('Website URL format may be invalid'); + } + + // Email validation (warning) + if (data.email && data.email !== '' && !isValidEmail(data.email)) { + result.warnings.push('Email format may be invalid'); + } + + // Entity-specific validations + switch (entityType) { + case 'park': + if (!data.park_type) { + result.blockingErrors.push('Park type is required'); + } + if (!data.status) { + result.blockingErrors.push('Status is required'); + } + if (data.location_id === null || data.location_id === undefined) { + result.blockingErrors.push('Location is required for parks'); + } + if (data.opening_date && data.closing_date) { + const opening = new Date(data.opening_date); + const closing = new Date(data.closing_date); + if (closing < opening) { + result.blockingErrors.push('Closing date must be after opening date'); + } + } + break; + + case 'ride': + if (!data.category) { + result.blockingErrors.push('Category is required'); + } + if (!data.status) { + result.blockingErrors.push('Status is required'); + } + if (data.park_id === null || data.park_id === undefined) { + result.blockingErrors.push('Park is required for rides'); + } + if (data.max_speed_kmh && (data.max_speed_kmh < 0 || data.max_speed_kmh > 300)) { + result.blockingErrors.push('Max speed must be between 0 and 300 km/h'); + } + if (data.max_height_meters && (data.max_height_meters < 0 || data.max_height_meters > 200)) { + result.blockingErrors.push('Max height must be between 0 and 200 meters'); + } + if (data.drop_height_meters && (data.drop_height_meters < 0 || data.drop_height_meters > 200)) { + result.blockingErrors.push('Drop height must be between 0 and 200 meters'); + } + if (data.height_requirement && (data.height_requirement < 0 || data.height_requirement > 300)) { + result.blockingErrors.push('Height requirement must be between 0 and 300 cm'); + } + break; + + case 'manufacturer': + case 'designer': + case 'operator': + case 'property_owner': + if (!data.company_type) { + result.blockingErrors.push(`Company type is required (expected: ${entityType})`); + } else if (data.company_type !== entityType) { + result.blockingErrors.push(`Company type mismatch: expected '${entityType}' but got '${data.company_type}'`); + } + if (data.founded_year) { + const year = parseInt(data.founded_year); + const currentYear = new Date().getFullYear(); + if (year < 1800 || year > currentYear) { + result.warnings.push(`Founded year should be between 1800 and ${currentYear}`); + } + } + break; + + case 'ride_model': + if (!data.category) { + result.blockingErrors.push('Category is required'); + } + if (!data.ride_type) { + result.blockingErrors.push('Ride type is required'); + } + break; + + case 'photo': + if (!data.cloudflare_image_id) { + result.blockingErrors.push('Image ID is required'); + } + if (!data.entity_type) { + result.blockingErrors.push('Entity type is required'); + } + if (!data.entity_id) { + result.blockingErrors.push('Entity ID is required'); + } + if (data.caption && data.caption.length > 500) { + result.blockingErrors.push('Caption must be less than 500 characters'); + } + break; + } + + result.valid = result.blockingErrors.length === 0; + return result; +} + +// Helper functions +function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +/** + * Validate entity data before database write (legacy function) */ export function validateEntityData(entityType: string, data: any): ValidationResult { const errors: string[] = [];