feat: Implement emergency hotfixes

This commit is contained in:
gpt-engineer-app[bot]
2025-10-13 20:39:39 +00:00
parent 1d3c907c8a
commit 27c7f36ca4
5 changed files with 242 additions and 60 deletions

View File

@@ -222,17 +222,26 @@ export function SubmissionReviewManager({
const successCount = data.results.filter((r: any) => r.success).length; const successCount = data.results.filter((r: any) => r.success).length;
const failCount = 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({ toast({
title: 'Approval Complete', title: allFailed ? 'Approval Failed' : someFailed ? 'Partial Approval' : 'Approval Complete',
description: failCount > 0 description: failCount > 0
? `Approved ${successCount} item(s), ${failCount} failed` ? `Approved ${successCount} item(s), ${failCount} failed`
: `Successfully approved ${successCount} item(s)`, : `Successfully approved ${successCount} item(s)`,
variant: failCount > 0 ? 'destructive' : 'default', variant: allFailed ? 'destructive' : someFailed ? 'default' : 'default',
}); });
// Reset warning confirmation state after approval // Reset warning confirmation state after approval
setUserConfirmedWarnings(false); setUserConfirmedWarnings(false);
// If ALL items failed, don't close dialog - show errors
if (allFailed) {
setLoading(false);
return;
}
onComplete(); onComplete();
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: any) {

View File

@@ -127,25 +127,34 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
// Refs for tracking // Refs for tracking
const recentlyRemovedRef = useRef<Set<string>>(new Set()); const recentlyRemovedRef = useRef<Set<string>>(new Set());
const fetchInProgressRef = useRef(false); const fetchInProgressRef = useRef(false);
const itemsRef = useRef<ModerationItem[]>([]);
const lastFetchTimeRef = useRef<number>(0); const lastFetchTimeRef = useRef<number>(0);
const pauseFetchingRef = useRef(false);
const initialFetchCompleteRef = useRef(false); const initialFetchCompleteRef = useRef(false);
const isMountingRef = useRef(true); const isMountingRef = useRef(true);
const fetchItemsRef = useRef<((silent?: boolean) => Promise<void>) | null>(null);
const FETCH_COOLDOWN_MS = 1000; 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 settingsRef = useRef(settings);
const filtersRef = useRef(filters);
const sortRef = useRef(sort.debouncedConfig);
const paginationRef = useRef(pagination);
// Sync refs with state
useEffect(() => { useEffect(() => {
settingsRef.current = settings; settingsRef.current = settings;
}, [settings]); }, [settings]);
// Sync items with ref
useEffect(() => { useEffect(() => {
itemsRef.current = items; filtersRef.current = filters;
}, [items]); }, [filters]);
useEffect(() => {
sortRef.current = sort.debouncedConfig;
}, [sort.debouncedConfig]);
useEffect(() => {
paginationRef.current = pagination;
}, [pagination]);
/** /**
* Fetch queue items from database * Fetch queue items from database
@@ -160,20 +169,13 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
console.log("🔄 [FETCH ITEMS] Called", { console.log("🔄 [FETCH ITEMS] Called", {
silent, silent,
pauseFetchingRef: pauseFetchingRef.current,
documentHidden: document.hidden, documentHidden: document.hidden,
caller: callerLine, caller: callerLine,
sortField: sort.debouncedConfig.field, sortField: sortRef.current.field,
sortDirection: sort.debouncedConfig.direction, sortDirection: sortRef.current.direction,
timestamp: new Date().toISOString(), 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 // Prevent concurrent calls
if (fetchInProgressRef.current) { if (fetchInProgressRef.current) {
console.log("⚠️ Fetch already in progress, skipping"); console.log("⚠️ Fetch already in progress, skipping");
@@ -192,10 +194,10 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
lastFetchTimeRef.current = now; lastFetchTimeRef.current = now;
console.log("🔍 fetchItems called:", { console.log("🔍 fetchItems called:", {
entityFilter: filters.debouncedEntityFilter, entityFilter: filtersRef.current.debouncedEntityFilter,
statusFilter: filters.debouncedStatusFilter, statusFilter: filtersRef.current.debouncedStatusFilter,
sortField: sort.debouncedConfig.field, sortField: sortRef.current.field,
sortDirection: sort.debouncedConfig.direction, sortDirection: sortRef.current.direction,
silent, silent,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
@@ -237,28 +239,28 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
// CRITICAL: Multi-level ordering // CRITICAL: Multi-level ordering
console.log('📊 [SORT QUERY] Applying multi-level sort:', { console.log('📊 [SORT QUERY] Applying multi-level sort:', {
level1: 'escalated DESC', level1: 'escalated DESC',
level2: `${sort.debouncedConfig.field} ${sort.debouncedConfig.direction.toUpperCase()}`, level2: `${sortRef.current.field} ${sortRef.current.direction.toUpperCase()}`,
level3: sort.debouncedConfig.field !== 'created_at' ? 'created_at ASC' : 'none' level3: sortRef.current.field !== 'created_at' ? 'created_at ASC' : 'none'
}); });
// Level 1: Always sort by escalated first (descending) // Level 1: Always sort by escalated first (descending)
submissionsQuery = submissionsQuery.order('escalated', { ascending: false }); 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( submissionsQuery = submissionsQuery.order(
sort.debouncedConfig.field, sortRef.current.field,
{ ascending: sort.debouncedConfig.direction === 'asc' } { ascending: sortRef.current.direction === 'asc' }
); );
// Level 3: Tertiary sort by created_at (if not already primary) // 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 }); submissionsQuery = submissionsQuery.order('created_at', { ascending: true });
} }
// Apply tab-based status filtering // Apply tab-based status filtering (use refs)
const tab = filters.activeTab; const tab = filtersRef.current.activeTab;
const statusFilter = filters.debouncedStatusFilter; const statusFilter = filtersRef.current.debouncedStatusFilter;
const entityFilter = filters.debouncedEntityFilter; const entityFilter = filtersRef.current.debouncedEntityFilter;
if (tab === "mainQueue") { if (tab === "mainQueue") {
if (statusFilter === "all") { if (statusFilter === "all") {
@@ -328,11 +330,11 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
const { count } = await countQuery; const { count } = await countQuery;
pagination.setTotalCount(count || 0); paginationRef.current.setTotalCount(count || 0);
// Apply pagination // Apply pagination (use refs)
const startIndex = pagination.startIndex; const startIndex = paginationRef.current.startIndex;
const endIndex = pagination.endIndex; const endIndex = paginationRef.current.endIndex;
submissionsQuery = submissionsQuery.range(startIndex, endIndex); submissionsQuery = submissionsQuery.range(startIndex, endIndex);
const { data: submissions, error: submissionsError } = await submissionsQuery; 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 // Log the actual data returned to verify sort order
if (submissions && submissions.length > 0) { if (submissions && submissions.length > 0) {
const sortField = sort.debouncedConfig.field; const sortField = sortRef.current.field;
const preview = submissions.slice(0, 3).map(s => ({ const preview = submissions.slice(0, 3).map(s => ({
id: s.id.substring(0, 8), id: s.id.substring(0, 8),
[sortField]: s[sortField], [sortField]: s[sortField],
@@ -426,7 +428,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
if (silent) { if (silent) {
// Background polling: detect new submissions // 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)); const newSubmissions = moderationItems.filter((item) => !currentDisplayedIds.has(item.id));
if (newSubmissions.length > 0) { if (newSubmissions.length > 0) {
@@ -452,7 +454,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
case "merge": case "merge":
if (newSubmissions.length > 0) { 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)); const trulyNewSubmissions = newSubmissions.filter((item) => !currentIds.has(item.id));
if (trulyNewSubmissions.length > 0) { if (trulyNewSubmissions.length > 0) {
@@ -463,7 +465,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
break; break;
case "replace": case "replace":
const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { const mergeResult = smartMergeArray(items, moderationItems, {
compareFields: [ compareFields: [
"status", "status",
"content", "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 * Show pending new items by merging them into the queue
*/ */
@@ -865,21 +862,23 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
}); });
pagination.reset(); pagination.reset();
fetchItemsRef.current?.(false); fetchItems(false);
}, [ }, [
filters.debouncedEntityFilter, filters.debouncedEntityFilter,
filters.debouncedStatusFilter, filters.debouncedStatusFilter,
sort.debouncedConfig.field, sort.debouncedConfig.field,
sort.debouncedConfig.direction, sort.debouncedConfig.direction,
user user,
fetchItems,
pagination
]); ]);
// Pagination changes trigger refetch // Pagination changes trigger refetch
useEffect(() => { useEffect(() => {
if (!user || !initialFetchCompleteRef.current || pagination.currentPage === 1) return; if (!user || !initialFetchCompleteRef.current || pagination.currentPage === 1) return;
fetchItemsRef.current?.(true); fetchItems(true);
}, [pagination.currentPage, pagination.pageSize]); }, [pagination.currentPage, pagination.pageSize, user, fetchItems]);
// Polling effect (when realtime disabled) // Polling effect (when realtime disabled)
useEffect(() => { useEffect(() => {
@@ -890,14 +889,14 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
console.log("⚠️ Polling ENABLED - interval:", settings.pollInterval); console.log("⚠️ Polling ENABLED - interval:", settings.pollInterval);
const interval = setInterval(() => { const interval = setInterval(() => {
console.log("🔄 Polling refresh triggered"); console.log("🔄 Polling refresh triggered");
fetchItemsRef.current?.(true); fetchItems(true);
}, settings.pollInterval); }, settings.pollInterval);
return () => { return () => {
clearInterval(interval); clearInterval(interval);
console.log("🛑 Polling stopped"); console.log("🛑 Polling stopped");
}; };
}, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue]); }, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, fetchItems]);
// Initialize realtime subscriptions // Initialize realtime subscriptions
useRealtimeSubscriptions({ useRealtimeSubscriptions({
@@ -941,7 +940,6 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
profileCache, profileCache,
recentlyRemovedIds: recentlyRemovedRef.current, recentlyRemovedIds: recentlyRemovedRef.current,
interactingWithIds: interactingWith, interactingWithIds: interactingWith,
currentItemsRef: itemsRef,
}); });
return { return {

View File

@@ -61,8 +61,8 @@ export interface RealtimeSubscriptionConfig {
/** Set of IDs currently being interacted with */ /** Set of IDs currently being interacted with */
interactingWithIds: Set<string>; interactingWithIds: Set<string>;
/** Current items in queue (for comparison) - using ref to avoid reconnections */ /** Current items in queue (for comparison) - using ref to avoid reconnections (optional) */
currentItemsRef: React.MutableRefObject<ModerationItem[]>; currentItemsRef?: React.MutableRefObject<ModerationItem[]>;
} }
/** /**

View File

@@ -1,6 +1,6 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; 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 { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { validateEntityData } from "./validation.ts"; import { validateEntityData, validateEntityDataStrict } from "./validation.ts";
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -206,10 +206,28 @@ serve(async (req) => {
try { try {
console.log(`Processing item ${item.id} of type ${item.item_type}`); console.log(`Processing item ${item.id} of type ${item.item_type}`);
// Validate entity data before processing // Validate entity data with strict validation
const validation = validateEntityData(item.item_type, item.item_data); const validation = validateEntityDataStrict(item.item_type, item.item_data);
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.errors.join(', ')}`); 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 // Set user context for versioning trigger

View File

@@ -8,8 +8,165 @@ export interface ValidationResult {
errors: string[]; 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 { export function validateEntityData(entityType: string, data: any): ValidationResult {
const errors: string[] = []; const errors: string[] = [];