mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
feat: Implement emergency hotfixes
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -127,25 +127,34 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
// Refs for tracking
|
||||
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||
const fetchInProgressRef = useRef(false);
|
||||
const itemsRef = useRef<ModerationItem[]>([]);
|
||||
const lastFetchTimeRef = useRef<number>(0);
|
||||
const pauseFetchingRef = useRef(false);
|
||||
const initialFetchCompleteRef = useRef(false);
|
||||
const isMountingRef = useRef(true);
|
||||
const fetchItemsRef = useRef<((silent?: boolean) => Promise<void>) | 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 {
|
||||
|
||||
@@ -61,8 +61,8 @@ export interface RealtimeSubscriptionConfig {
|
||||
/** Set of IDs currently being interacted with */
|
||||
interactingWithIds: Set<string>;
|
||||
|
||||
/** Current items in queue (for comparison) - using ref to avoid reconnections */
|
||||
currentItemsRef: React.MutableRefObject<ModerationItem[]>;
|
||||
/** Current items in queue (for comparison) - using ref to avoid reconnections (optional) */
|
||||
currentItemsRef?: React.MutableRefObject<ModerationItem[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user