Refactor validation to edge function

Centralize all business logic validation within the edge function for the submission pipeline. Remove validation logic from React hooks, retaining only basic UX validation (e.g., checking for empty fields). This ensures a single source of truth for validation, preventing inconsistencies between the frontend and backend.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-06 16:18:34 +00:00
parent 1cc80e0dc4
commit e7f5aa9d17
3 changed files with 224 additions and 203 deletions

View File

@@ -4,7 +4,7 @@ import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { logger } from '@/lib/logger';
import { getErrorMessage, handleError, isSupabaseConnectionError } from '@/lib/errorHandler';
import { validateMultipleItems } from '@/lib/entityValidationSchemas';
// Validation removed from client - edge function is single source of truth
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import type { User } from '@supabase/supabase-js';
import type { ModerationItem } from '@/types/moderation';
@@ -133,206 +133,10 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
if (submissionItems && submissionItems.length > 0) {
if (action === 'approved') {
// Fetch full item data for validation with relational joins
const { data: fullItems, error: itemError } = await supabase
.from('submission_items')
.select(`
id,
item_type,
park_submission:park_submissions!submission_items_park_submission_id_fkey(*),
ride_submission:ride_submissions!submission_items_ride_submission_id_fkey(*),
company_submission:company_submissions!submission_items_company_submission_id_fkey(*),
ride_model_submission:ride_model_submissions!submission_items_ride_model_submission_id_fkey(*),
timeline_event_submission:timeline_event_submissions!submission_items_timeline_event_submission_id_fkey(*),
photo_submission:photo_submissions!submission_items_photo_submission_id_fkey(*)
`)
.eq('submission_id', item.id)
.in('status', ['pending', 'rejected']);
if (itemError) {
throw new Error(`Failed to fetch submission items: ${itemError.message}`);
}
if (fullItems && fullItems.length > 0) {
console.info('[Submission Flow] Preparing items for validation', {
submissionId: item.id,
itemCount: fullItems.length,
itemTypes: fullItems.map(i => i.item_type),
timestamp: new Date().toISOString()
});
// Transform to include item_data
const itemsWithData = await Promise.all(fullItems.map(async item => {
let itemData = {};
switch (item.item_type) {
case 'park': {
const parkSub = (item.park_submission as any) || {};
let locationData: any = null;
if (parkSub?.id) {
const { data } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', parkSub.id)
.maybeSingle();
locationData = data;
}
itemData = {
...parkSub,
location: locationData || undefined
};
break;
}
case 'ride':
itemData = item.ride_submission || {};
break;
case 'operator':
case 'manufacturer':
case 'designer':
case 'property_owner':
itemData = {
...(item.company_submission || {}),
company_id: item.company_submission?.id // Use company_submission ID for validation
};
break;
case 'ride_model':
itemData = {
...(item.ride_model_submission || {}),
ride_model_id: item.ride_model_submission?.id // Use ride_model_submission ID for validation
};
break;
case 'milestone':
case 'timeline_event':
itemData = item.timeline_event_submission || {};
break;
case 'photo':
case 'photo_edit':
case 'photo_delete':
itemData = item.photo_submission || {};
break;
default:
logger.warn(`Unknown item_type in validation: ${item.item_type}`);
itemData = {};
}
return {
id: item.id,
item_type: item.item_type,
item_data: itemData
};
}));
// Run validation on all items
try {
console.info('[Submission Flow] Starting validation', {
submissionId: item.id,
itemCount: itemsWithData.length,
itemTypes: itemsWithData.map(i => i.item_type),
timestamp: new Date().toISOString()
});
const validationResults = await validateMultipleItems(itemsWithData);
console.info('[Submission Flow] Validation completed', {
submissionId: item.id,
resultsCount: validationResults.size,
timestamp: new Date().toISOString()
});
// Check for blocking errors
const itemsWithBlockingErrors = itemsWithData.filter(item => {
const result = validationResults.get(item.id);
return result && result.blockingErrors.length > 0;
});
// CRITICAL: Block approval if any item has blocking errors
if (itemsWithBlockingErrors.length > 0) {
console.warn('[Submission Flow] Validation found blocking errors', {
submissionId: item.id,
itemsWithErrors: itemsWithBlockingErrors.map(i => ({
itemId: i.id,
itemType: i.item_type,
errors: validationResults.get(i.id)?.blockingErrors
})),
timestamp: new Date().toISOString()
});
// Log detailed blocking errors
itemsWithBlockingErrors.forEach(item => {
const result = validationResults.get(item.id);
logger.error('Validation blocking approval', {
submissionId: item.id,
itemId: item.id,
itemType: item.item_type,
blockingErrors: result?.blockingErrors
});
});
const errorDetails = itemsWithBlockingErrors.map(item => {
const result = validationResults.get(item.id);
const itemName = (item.item_data as any)?.name || item.item_type;
const errors = result?.blockingErrors.map(e => `${e.field}: ${e.message}`).join(', ');
return `${itemName} - ${errors}`;
}).join('; ');
throw new Error(`Validation failed: ${errorDetails}`);
}
// Check for warnings (optional - can proceed but inform user)
const itemsWithWarnings = itemsWithData.filter(item => {
const result = validationResults.get(item.id);
return result && result.warnings.length > 0;
});
if (itemsWithWarnings.length > 0) {
logger.info('Approval proceeding with warnings', {
submissionId: item.id,
warningCount: itemsWithWarnings.length
});
}
} catch (error) {
// Check if this is a validation error or system error
if (getErrorMessage(error).includes('Validation failed:')) {
// This is expected - validation rules preventing approval
handleError(error, {
action: 'Validation Blocked Approval',
userId: user?.id,
metadata: {
submissionId: item.id,
submissionType: item.submission_type,
selectedItemCount: itemsWithData.length
}
});
toast({
title: 'Cannot Approve - Validation Errors',
description: getErrorMessage(error),
variant: 'destructive',
});
// Return early - do NOT proceed with approval
return;
} else {
// Unexpected validation system error
const errorId = handleError(error, {
action: 'Validation System Failure',
userId: user?.id,
metadata: {
submissionId: item.id,
submissionType: item.submission_type,
phase: 'validation'
}
});
toast({
title: 'Validation System Error',
description: `Unable to validate submission (ref: ${errorId.slice(0, 8)})`,
variant: 'destructive',
});
// Return early - do NOT proceed with approval
return;
}
}
}
// ⚠️ VALIDATION CENTRALIZED IN EDGE FUNCTION
// All business logic validation happens in process-selective-approval edge function.
// Client-side only performs basic UX validation (non-empty, format) in forms.
// If server-side validation fails, the edge function returns detailed 400/500 errors.
const { data, error, requestId, attempts } = await invokeWithTracking(
'process-selective-approval',
@@ -465,8 +269,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
const isNetworkError = isSupabaseConnectionError(error);
const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`;
// Check if this is a validation error from edge function
const isValidationError = errorMessage.includes('Validation failed') ||
errorMessage.includes('blocking errors') ||
errorMessage.includes('blockingErrors');
toast({
title: isNetworkError ? 'Connection Error' : 'Action Failed',
title: isNetworkError ? 'Connection Error' :
isValidationError ? 'Validation Failed' : 'Action Failed',
description: errorMessage,
variant: 'destructive',
});
@@ -477,6 +287,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
error: errorMessage,
errorId: error.errorId,
isNetworkError,
isValidationError,
});
},
onSuccess: (data) => {