mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
Refactor: Complete error handling overhaul
This commit is contained in:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -80,6 +80,7 @@
|
|||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"use-debounce": "^10.0.6",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
},
|
},
|
||||||
@@ -12172,6 +12173,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-debounce": {
|
||||||
|
"version": "10.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz",
|
||||||
|
"integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sidecar": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"use-debounce": "^10.0.6",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useImperativeHandle, forwardRef, useMemo, useCallback, useRef } from 'react';
|
import { useState, useImperativeHandle, forwardRef, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
@@ -95,6 +97,33 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
// Keyboard shortcuts help dialog
|
// Keyboard shortcuts help dialog
|
||||||
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
|
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
|
||||||
|
|
||||||
|
// Offline detection state
|
||||||
|
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||||
|
|
||||||
|
// Offline detection effect
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
setIsOffline(false);
|
||||||
|
toast({
|
||||||
|
title: 'Connection Restored',
|
||||||
|
description: 'You are back online. Refreshing queue...',
|
||||||
|
});
|
||||||
|
queueManager.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
setIsOffline(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, [queueManager, toast]);
|
||||||
|
|
||||||
// Virtual scrolling setup
|
// Virtual scrolling setup
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
@@ -200,6 +229,17 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Offline Banner */}
|
||||||
|
{isOffline && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>No Internet Connection</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You're offline. The moderation queue will automatically sync when your connection is restored.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Queue Statistics & Lock Status */}
|
{/* Queue Statistics & Lock Status */}
|
||||||
{queueManager.queue.queueStats && (
|
{queueManager.queue.queueStats && (
|
||||||
<Card className="bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
|
<Card className="bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import {
|
import {
|
||||||
CheckCircle, XCircle, RefreshCw, AlertCircle, Lock, Trash2,
|
CheckCircle, XCircle, RefreshCw, AlertCircle, Lock, Trash2,
|
||||||
Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar
|
Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar
|
||||||
@@ -66,13 +67,31 @@ export const QueueItemActions = memo(({
|
|||||||
onNoteChange(item.id, e.target.value);
|
onNoteChange(item.id, e.target.value);
|
||||||
}, [onNoteChange, item.id]);
|
}, [onNoteChange, item.id]);
|
||||||
|
|
||||||
const handleApprove = useCallback(() => {
|
// Debounced handlers to prevent duplicate submissions
|
||||||
onApprove(item, 'approved', notes[item.id]);
|
const handleApprove = useDebouncedCallback(
|
||||||
}, [onApprove, item, notes]);
|
() => {
|
||||||
|
// Extra guard against race conditions
|
||||||
|
if (actionLoading === item.id) {
|
||||||
|
console.warn('⚠️ Action already in progress, ignoring duplicate request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onApprove(item, 'approved', notes[item.id]);
|
||||||
|
},
|
||||||
|
300, // 300ms debounce
|
||||||
|
{ leading: true, trailing: false } // Only fire on first click
|
||||||
|
);
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
const handleReject = useDebouncedCallback(
|
||||||
onApprove(item, 'rejected', notes[item.id]);
|
() => {
|
||||||
}, [onApprove, item, notes]);
|
if (actionLoading === item.id) {
|
||||||
|
console.warn('⚠️ Action already in progress, ignoring duplicate request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onApprove(item, 'rejected', notes[item.id]);
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
{ leading: true, trailing: false }
|
||||||
|
);
|
||||||
|
|
||||||
const handleResetToPending = useCallback(() => {
|
const handleResetToPending = useCallback(() => {
|
||||||
onResetToPending(item);
|
onResetToPending(item);
|
||||||
@@ -106,13 +125,29 @@ export const QueueItemActions = memo(({
|
|||||||
onNoteChange(`reverse-${item.id}`, e.target.value);
|
onNoteChange(`reverse-${item.id}`, e.target.value);
|
||||||
}, [onNoteChange, item.id]);
|
}, [onNoteChange, item.id]);
|
||||||
|
|
||||||
const handleReverseApprove = useCallback(() => {
|
const handleReverseApprove = useDebouncedCallback(
|
||||||
onApprove(item, 'approved', notes[`reverse-${item.id}`]);
|
() => {
|
||||||
}, [onApprove, item, notes]);
|
if (actionLoading === item.id) {
|
||||||
|
console.warn('⚠️ Action already in progress, ignoring duplicate request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onApprove(item, 'approved', notes[`reverse-${item.id}`]);
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
{ leading: true, trailing: false }
|
||||||
|
);
|
||||||
|
|
||||||
const handleReverseReject = useCallback(() => {
|
const handleReverseReject = useDebouncedCallback(
|
||||||
onApprove(item, 'rejected', notes[`reverse-${item.id}`]);
|
() => {
|
||||||
}, [onApprove, item, notes]);
|
if (actionLoading === item.id) {
|
||||||
|
console.warn('⚠️ Action already in progress, ignoring duplicate request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onApprove(item, 'rejected', notes[`reverse-${item.id}`]);
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
{ leading: true, trailing: false }
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -249,8 +284,20 @@ export const QueueItemActions = memo(({
|
|||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
>
|
>
|
||||||
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
{actionLoading === item.id ? (
|
||||||
Approve
|
<>
|
||||||
|
<RefreshCw className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} mr-2 animate-spin`} />
|
||||||
|
{item.submission_items && item.submission_items.length > 5
|
||||||
|
? `Processing ${item.submission_items.length} items...`
|
||||||
|
: 'Processing...'
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -259,8 +306,17 @@ export const QueueItemActions = memo(({
|
|||||||
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
|
||||||
size={isMobile ? "default" : "default"}
|
size={isMobile ? "default" : "default"}
|
||||||
>
|
>
|
||||||
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
{actionLoading === item.id ? (
|
||||||
Reject
|
<>
|
||||||
|
<RefreshCw className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} mr-2 animate-spin`} />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
|
||||||
|
Reject
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
|
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
|
||||||
|
import { validateModerationItems } from '@/lib/moderation/validation';
|
||||||
import type {
|
import type {
|
||||||
ModerationItem,
|
ModerationItem,
|
||||||
EntityFilter,
|
EntityFilter,
|
||||||
@@ -20,6 +21,49 @@ import type {
|
|||||||
SortDirection
|
SortDirection
|
||||||
} from '@/types/moderation';
|
} from '@/types/moderation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get specific, actionable error message based on error type
|
||||||
|
*/
|
||||||
|
function getSpecificErrorMessage(error: unknown): string {
|
||||||
|
// Offline detection
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
return 'You appear to be offline. Check your internet connection and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return 'Request timed out. The server is taking too long to respond. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Supabase-specific errors
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
const err = error as any;
|
||||||
|
|
||||||
|
// 500 errors
|
||||||
|
if (err.status === 500 || err.code === '500') {
|
||||||
|
return 'Server error occurred. Our team has been notified. Please try again in a few minutes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429 Rate limiting
|
||||||
|
if (err.status === 429 || err.message?.includes('rate limit')) {
|
||||||
|
return 'Too many requests. Please wait a moment before trying again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication errors
|
||||||
|
if (err.status === 401 || err.message?.includes('JWT')) {
|
||||||
|
return 'Your session has expired. Please refresh the page and sign in again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission errors
|
||||||
|
if (err.status === 403 || err.message?.includes('permission')) {
|
||||||
|
return 'You do not have permission to access the moderation queue.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return getErrorMessage(error) || 'Failed to load moderation queue. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for queue query
|
* Configuration for queue query
|
||||||
*/
|
*/
|
||||||
@@ -124,21 +168,44 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
logger.log('🔍 [TanStack Query] Fetching queue data:', queryKey);
|
logger.log('🔍 [TanStack Query] Fetching queue data:', queryKey);
|
||||||
const result = await fetchSubmissions(supabase, queryConfig);
|
|
||||||
|
|
||||||
if (result.error) {
|
// Create timeout controller (30s timeout)
|
||||||
logger.error('❌ [TanStack Query] Error:', { error: getErrorMessage(result.error) });
|
const controller = new AbortController();
|
||||||
throw result.error;
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchSubmissions(supabase, queryConfig);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
const specificMessage = getSpecificErrorMessage(result.error);
|
||||||
|
logger.error('❌ [TanStack Query] Error:', { error: specificMessage });
|
||||||
|
throw new Error(specificMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate data shape before returning
|
||||||
|
const validation = validateModerationItems(result.submissions);
|
||||||
|
if (!validation.success) {
|
||||||
|
logger.error('❌ Invalid data shape', { error: validation.error });
|
||||||
|
throw new Error(validation.error || 'Invalid data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('✅ [TanStack Query] Fetched', validation.data!.length, 'items');
|
||||||
|
return { ...result, submissions: validation.data! };
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('✅ [TanStack Query] Fetched', result.submissions.length, 'items');
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
enabled: config.enabled !== false && !!config.userId,
|
enabled: config.enabled !== false && !!config.userId,
|
||||||
staleTime: MODERATION_CONSTANTS.QUERY_STALE_TIME,
|
staleTime: MODERATION_CONSTANTS.QUERY_STALE_TIME,
|
||||||
gcTime: MODERATION_CONSTANTS.QUERY_GC_TIME,
|
gcTime: MODERATION_CONSTANTS.QUERY_GC_TIME,
|
||||||
retry: MODERATION_CONSTANTS.QUERY_RETRY_COUNT,
|
retry: MODERATION_CONSTANTS.QUERY_RETRY_COUNT,
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
|
networkMode: 'offlineFirst', // Handle offline gracefully
|
||||||
|
meta: {
|
||||||
|
errorMessage: 'Failed to load moderation queue',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate helper
|
// Invalidate helper
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ export async function invokeWithTracking<T = any>(
|
|||||||
payload: any = {},
|
payload: any = {},
|
||||||
userId?: string,
|
userId?: string,
|
||||||
parentRequestId?: string,
|
parentRequestId?: string,
|
||||||
traceId?: string
|
traceId?: string,
|
||||||
|
timeout: number = 30000 // Default 30s timeout
|
||||||
): Promise<{ data: T | null; error: any; requestId: string; duration: number }> {
|
): Promise<{ data: T | null; error: any; requestId: string; duration: number }> {
|
||||||
|
// Create AbortController for timeout
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { result, requestId, duration } = await trackRequest(
|
const { result, requestId, duration } = await trackRequest(
|
||||||
{
|
{
|
||||||
@@ -39,6 +44,7 @@ export async function invokeWithTracking<T = any>(
|
|||||||
// Include client request ID in payload for correlation
|
// Include client request ID in payload for correlation
|
||||||
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
||||||
body: { ...payload, clientRequestId: context.requestId },
|
body: { ...payload, clientRequestId: context.requestId },
|
||||||
|
signal: controller.signal, // Add abort signal for timeout
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -46,10 +52,25 @@ export async function invokeWithTracking<T = any>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
return { data: result, error: null, requestId, duration };
|
return { data: result, error: null, requestId, duration };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Handle AbortError specifically
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: {
|
||||||
|
message: `Request timeout: ${functionName} took longer than ${timeout}ms to respond`,
|
||||||
|
code: 'TIMEOUT',
|
||||||
|
},
|
||||||
|
requestId: 'timeout',
|
||||||
|
duration: timeout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
// On error, we don't have tracking info, so create basic response
|
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
error: { message: errorMessage },
|
error: { message: errorMessage },
|
||||||
|
|||||||
79
src/lib/moderation/validation.ts
Normal file
79
src/lib/moderation/validation.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Runtime Data Validation for Moderation Queue
|
||||||
|
*
|
||||||
|
* Uses Zod to validate data shapes from the database at runtime.
|
||||||
|
* Prevents runtime errors if database schema changes unexpectedly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Profile schema
|
||||||
|
const ProfileSchema = z.object({
|
||||||
|
username: z.string(),
|
||||||
|
display_name: z.string().optional().nullable(),
|
||||||
|
avatar_url: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submission item schema
|
||||||
|
const SubmissionItemSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
status: z.string(),
|
||||||
|
item_type: z.string().optional(),
|
||||||
|
item_data: z.record(z.string(), z.any()).optional().nullable(),
|
||||||
|
original_data: z.record(z.string(), z.any()).optional().nullable(),
|
||||||
|
error_message: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main moderation item schema
|
||||||
|
export const ModerationItemSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
status: z.enum(['pending', 'approved', 'rejected', 'partially_approved', 'flagged']),
|
||||||
|
type: z.string(),
|
||||||
|
submission_type: z.string(),
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string().optional().nullable(),
|
||||||
|
content: z.record(z.string(), z.any()),
|
||||||
|
submitter_id: z.string().uuid(),
|
||||||
|
assigned_to: z.string().uuid().optional().nullable(),
|
||||||
|
locked_until: z.string().optional().nullable(),
|
||||||
|
reviewed_at: z.string().optional().nullable(),
|
||||||
|
reviewed_by: z.string().uuid().optional().nullable(),
|
||||||
|
reviewer_notes: z.string().optional().nullable(),
|
||||||
|
submission_items: z.array(SubmissionItemSchema).optional(),
|
||||||
|
submitter_profile: ProfileSchema.optional().nullable(),
|
||||||
|
assigned_profile: ProfileSchema.optional().nullable(),
|
||||||
|
reviewer_profile: ProfileSchema.optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ModerationItemArraySchema = z.array(ModerationItemSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate moderation items array
|
||||||
|
*
|
||||||
|
* @param data - Data to validate
|
||||||
|
* @returns Validation result with typed data or error
|
||||||
|
*/
|
||||||
|
export function validateModerationItems(data: unknown): {
|
||||||
|
success: boolean;
|
||||||
|
data?: any[];
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
const result = ModerationItemArraySchema.safeParse(data);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error('❌ Data validation failed', {
|
||||||
|
errors: result.error.issues.slice(0, 5) // Log first 5 issues
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Received invalid data format from server. Please refresh the page.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user