Implement Phase 4 optimizations

This commit is contained in:
gpt-engineer-app[bot]
2025-10-15 12:28:03 +00:00
parent 81a4b9ae31
commit 97337ed7a3
7 changed files with 1097 additions and 2 deletions

View File

@@ -21,6 +21,7 @@ import { NewItemsAlert } from './NewItemsAlert';
import { EmptyQueueState } from './EmptyQueueState';
import { QueuePagination } from './QueuePagination';
import type { ModerationQueueRef } from '@/types/moderation';
import type { PhotoItem } from '@/types/photos';
export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const isMobile = useIsMobile();
@@ -57,7 +58,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// UI-only state
const [notes, setNotes] = useState<Record<string, string>>({});
const [photoModalOpen, setPhotoModalOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<any[]>([]);
const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
const [reviewManagerOpen, setReviewManagerOpen] = useState(false);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);

View File

@@ -34,6 +34,12 @@ function getRoleLabel(role: string): string {
};
return isValidRole(role) ? labels[role] : role;
}
interface ProfileSearchResult {
user_id: string;
username: string;
display_name?: string;
}
interface UserRole {
id: string;
user_id: string;
@@ -50,7 +56,7 @@ export function UserRoleManager() {
const [searchTerm, setSearchTerm] = useState('');
const [newUserSearch, setNewUserSearch] = useState('');
const [newRole, setNewRole] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [searchResults, setSearchResults] = useState<ProfileSearchResult[]>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const {
user

View File

@@ -0,0 +1,282 @@
import { useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { logger } from '@/lib/logger';
import type { User } from '@supabase/supabase-js';
import type { ModerationItem } from '@/types/moderation';
/**
* Configuration for moderation actions
*/
export interface ModerationActionsConfig {
user: User | null;
onActionStart: (itemId: string) => void;
onActionComplete: () => void;
}
/**
* Return type for useModerationActions
*/
export interface ModerationActions {
performAction: (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => Promise<void>;
deleteSubmission: (item: ModerationItem) => Promise<void>;
resetToPending: (item: ModerationItem) => Promise<void>;
retryFailedItems: (item: ModerationItem) => Promise<void>;
}
/**
* Hook for moderation action handlers
* Extracted from useModerationQueueManager for better separation of concerns
*
* @param config - Configuration object with user, callbacks, and dependencies
* @returns Object with action handler functions
*/
export function useModerationActions(config: ModerationActionsConfig): ModerationActions {
const { user, onActionStart, onActionComplete } = config;
const { toast } = useToast();
/**
* Perform moderation action (approve/reject)
*/
const performAction = useCallback(
async (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => {
onActionStart(item.id);
try {
// Handle photo submissions
if (action === 'approved' && item.submission_type === 'photo') {
const { data: photoSubmission } = await supabase
.from('photo_submissions')
.select(`*, items:photo_submission_items(*), submission:content_submissions!inner(user_id)`)
.eq('submission_id', item.id)
.single();
if (photoSubmission && photoSubmission.items) {
const { data: existingPhotos } = await supabase
.from('photos')
.select('id')
.eq('submission_id', item.id);
if (!existingPhotos || existingPhotos.length === 0) {
const photoRecords = photoSubmission.items.map((photoItem: any) => ({
entity_id: photoSubmission.entity_id,
entity_type: photoSubmission.entity_type,
cloudflare_image_id: photoItem.cloudflare_image_id,
cloudflare_image_url: photoItem.cloudflare_image_url,
title: photoItem.title || null,
caption: photoItem.caption || null,
date_taken: photoItem.date_taken || null,
order_index: photoItem.order_index,
submission_id: photoSubmission.submission_id,
submitted_by: photoSubmission.submission?.user_id,
approved_by: user?.id,
approved_at: new Date().toISOString(),
}));
await supabase.from('photos').insert(photoRecords);
}
}
}
// Check for submission items
const { data: submissionItems } = await supabase
.from('submission_items')
.select('id, status')
.eq('submission_id', item.id)
.in('status', ['pending', 'rejected']);
if (submissionItems && submissionItems.length > 0) {
if (action === 'approved') {
await supabase.functions.invoke('process-selective-approval', {
body: {
itemIds: submissionItems.map((i) => i.id),
submissionId: item.id,
},
});
toast({
title: 'Submission Approved',
description: `Successfully processed ${submissionItems.length} item(s)`,
});
return;
} else if (action === 'rejected') {
await supabase
.from('submission_items')
.update({
status: 'rejected',
rejection_reason: moderatorNotes || 'Parent submission rejected',
updated_at: new Date().toISOString(),
})
.eq('submission_id', item.id)
.eq('status', 'pending');
}
}
// Standard update
const table = item.type === 'review' ? 'reviews' : 'content_submissions';
const statusField = item.type === 'review' ? 'moderation_status' : 'status';
const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at';
const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id';
const updateData: any = {
[statusField]: action,
[timestampField]: new Date().toISOString(),
};
if (user) {
updateData[reviewerField] = user.id;
}
if (moderatorNotes) {
updateData.reviewer_notes = moderatorNotes;
}
const { error } = await supabase.from(table).update(updateData).eq('id', item.id);
if (error) throw error;
toast({
title: `Content ${action}`,
description: `The ${item.type} has been ${action}`,
});
logger.log(`✅ Action ${action} completed for ${item.id}`);
} catch (error: any) {
logger.error('❌ Error performing action:', error);
toast({
title: 'Error',
description: error.message || `Failed to ${action} content`,
variant: 'destructive',
});
throw error;
} finally {
onActionComplete();
}
},
[user, toast, onActionStart, onActionComplete]
);
/**
* Delete a submission permanently
*/
const deleteSubmission = useCallback(
async (item: ModerationItem) => {
if (item.type !== 'content_submission') return;
onActionStart(item.id);
try {
const { error } = await supabase.from('content_submissions').delete().eq('id', item.id);
if (error) throw error;
toast({
title: 'Submission deleted',
description: 'The submission has been permanently deleted',
});
logger.log(`✅ Submission ${item.id} deleted`);
} catch (error: any) {
logger.error('❌ Error deleting submission:', error);
toast({
title: 'Error',
description: 'Failed to delete submission',
variant: 'destructive',
});
throw error;
} finally {
onActionComplete();
}
},
[toast, onActionStart, onActionComplete]
);
/**
* Reset submission to pending status
*/
const resetToPending = useCallback(
async (item: ModerationItem) => {
onActionStart(item.id);
try {
const { resetRejectedItemsToPending } = await import('@/lib/submissionItemsService');
await resetRejectedItemsToPending(item.id);
toast({
title: 'Reset Complete',
description: 'Submission and all items have been reset to pending status',
});
logger.log(`✅ Submission ${item.id} reset to pending`);
} catch (error: any) {
logger.error('❌ Error resetting submission:', error);
toast({
title: 'Reset Failed',
description: error.message,
variant: 'destructive',
});
} finally {
onActionComplete();
}
},
[toast, onActionStart, onActionComplete]
);
/**
* Retry failed items in a submission
*/
const retryFailedItems = useCallback(
async (item: ModerationItem) => {
onActionStart(item.id);
try {
const { data: failedItems } = await supabase
.from('submission_items')
.select('id')
.eq('submission_id', item.id)
.eq('status', 'rejected');
if (!failedItems || failedItems.length === 0) {
toast({
title: 'No Failed Items',
description: 'All items have been processed successfully',
});
return;
}
const { error } = await supabase.functions.invoke('process-selective-approval', {
body: {
itemIds: failedItems.map((i) => i.id),
submissionId: item.id,
},
});
if (error) throw error;
toast({
title: 'Items Retried',
description: `Successfully retried ${failedItems.length} failed item(s)`,
});
logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`);
} catch (error: any) {
logger.error('❌ Error retrying items:', error);
toast({
title: 'Retry Failed',
description: error.message || 'Failed to retry items',
variant: 'destructive',
});
} finally {
onActionComplete();
}
},
[toast, onActionStart, onActionComplete]
);
return {
performAction,
deleteSubmission,
resetToPending,
retryFailedItems,
};
}

125
src/lib/adminValidation.ts Normal file
View File

@@ -0,0 +1,125 @@
import { z } from 'zod';
/**
* Admin form validation schemas
* Provides type-safe validation for admin settings and user management forms
*/
/**
* Email validation schema
* Ensures valid email format with reasonable length constraints
*/
export const emailSchema = z
.string()
.trim()
.min(1, 'Email is required')
.max(255, 'Email must be less than 255 characters')
.email('Invalid email address')
.toLowerCase();
/**
* URL validation schema
* Validates URLs with http/https protocol and reasonable length
*/
export const urlSchema = z
.string()
.trim()
.min(1, 'URL is required')
.max(2048, 'URL must be less than 2048 characters')
.url('Invalid URL format')
.refine(
(url) => url.startsWith('http://') || url.startsWith('https://'),
'URL must start with http:// or https://'
);
/**
* Username validation schema
* Alphanumeric with underscores and hyphens, 3-30 characters
*/
export const usernameSchema = z
.string()
.trim()
.min(3, 'Username must be at least 3 characters')
.max(30, 'Username must be less than 30 characters')
.regex(
/^[a-zA-Z0-9_-]+$/,
'Username can only contain letters, numbers, underscores, and hyphens'
);
/**
* Display name validation schema
* More permissive than username, allows spaces and special characters
*/
export const displayNameSchema = z
.string()
.trim()
.min(1, 'Display name is required')
.max(100, 'Display name must be less than 100 characters');
/**
* Admin settings validation schema
* For system-wide configuration values
*/
export const adminSettingsSchema = z.object({
email: emailSchema.optional(),
url: urlSchema.optional(),
username: usernameSchema.optional(),
displayName: displayNameSchema.optional(),
});
/**
* User search validation schema
* For searching users in admin panel
*/
export const userSearchSchema = z.object({
query: z
.string()
.trim()
.min(1, 'Search query must be at least 1 character')
.max(100, 'Search query must be less than 100 characters'),
});
/**
* Helper function to validate email
*/
export function validateEmail(email: string): { valid: boolean; error?: string } {
try {
emailSchema.parse(email);
return { valid: true };
} catch (error) {
if (error instanceof z.ZodError) {
return { valid: false, error: error.issues[0]?.message };
}
return { valid: false, error: 'Invalid email' };
}
}
/**
* Helper function to validate URL
*/
export function validateUrl(url: string): { valid: boolean; error?: string } {
try {
urlSchema.parse(url);
return { valid: true };
} catch (error) {
if (error instanceof z.ZodError) {
return { valid: false, error: error.issues[0]?.message };
}
return { valid: false, error: 'Invalid URL' };
}
}
/**
* Helper function to validate username
*/
export function validateUsername(username: string): { valid: boolean; error?: string } {
try {
usernameSchema.parse(username);
return { valid: true };
} catch (error) {
if (error instanceof z.ZodError) {
return { valid: false, error: error.issues[0]?.message };
}
return { valid: false, error: 'Invalid username' };
}
}

22
src/types/photos.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Photo-related type definitions
*/
export interface PhotoItem {
id: string;
url: string;
filename: string;
caption?: string;
size?: number;
type?: string;
}
export interface PhotoSubmissionItem {
id: string;
cloudflare_image_id: string;
cloudflare_image_url: string;
title?: string;
caption?: string;
date_taken?: string;
order_index: number;
}