mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 14:11:13 -05:00
feat: Implement admin component optimizations
This commit is contained in:
98
src/hooks/admin/useVersionAudit.ts
Normal file
98
src/hooks/admin/useVersionAudit.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
|
||||
/**
|
||||
* useVersionAudit Hook
|
||||
*
|
||||
* Detects suspicious entity versions without user attribution for security monitoring.
|
||||
*
|
||||
* Features:
|
||||
* - Combines 4 count queries with Promise.all() for parallel execution
|
||||
* - Caches for 5 minutes (security alert, should be relatively fresh)
|
||||
* - Returns total count + breakdown by entity type
|
||||
* - Only runs for moderators/admins
|
||||
* - Performance monitoring with slow query warnings
|
||||
*
|
||||
* @returns TanStack Query result with audit data
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: auditResult, isLoading } = useVersionAudit();
|
||||
*
|
||||
* if (auditResult && auditResult.totalCount > 0) {
|
||||
* console.warn(`Found ${auditResult.totalCount} suspicious versions`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface VersionAuditResult {
|
||||
totalCount: number;
|
||||
parkVersions: number;
|
||||
rideVersions: number;
|
||||
companyVersions: number;
|
||||
modelVersions: number;
|
||||
}
|
||||
|
||||
export function useVersionAudit() {
|
||||
const { user } = useAuth();
|
||||
const { isModerator } = useUserRole();
|
||||
|
||||
return useQuery<VersionAuditResult>({
|
||||
queryKey: queryKeys.admin.versionAudit,
|
||||
queryFn: async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const [parksResult, ridesResult, companiesResult, modelsResult] = await Promise.all([
|
||||
supabase
|
||||
.from('park_versions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.is('created_by', null),
|
||||
supabase
|
||||
.from('ride_versions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.is('created_by', null),
|
||||
supabase
|
||||
.from('company_versions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.is('created_by', null),
|
||||
supabase
|
||||
.from('ride_model_versions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.is('created_by', null),
|
||||
]);
|
||||
|
||||
// Check for errors
|
||||
if (parksResult.error) throw parksResult.error;
|
||||
if (ridesResult.error) throw ridesResult.error;
|
||||
if (companiesResult.error) throw companiesResult.error;
|
||||
if (modelsResult.error) throw modelsResult.error;
|
||||
|
||||
const parkCount = parksResult.count || 0;
|
||||
const rideCount = ridesResult.count || 0;
|
||||
const companyCount = companiesResult.count || 0;
|
||||
const modelCount = modelsResult.count || 0;
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log slow queries in development
|
||||
if (import.meta.env.DEV && duration > 1000) {
|
||||
console.warn(`Slow query: useVersionAudit took ${duration}ms`);
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount: parkCount + rideCount + companyCount + modelCount,
|
||||
parkVersions: parkCount,
|
||||
rideVersions: rideCount,
|
||||
companyVersions: companyCount,
|
||||
modelVersions: modelCount,
|
||||
};
|
||||
},
|
||||
enabled: !!user && isModerator(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
84
src/hooks/lists/useUserLists.ts
Normal file
84
src/hooks/lists/useUserLists.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* useUserLists Hook
|
||||
*
|
||||
* Fetches user's top lists with items for list management.
|
||||
*
|
||||
* Features:
|
||||
* - Single query with nested list_items SELECT
|
||||
* - Caches for 3 minutes (user data, moderate volatility)
|
||||
* - Performance monitoring with slow query warnings
|
||||
* - Supports optimistic updates via refetch
|
||||
*
|
||||
* @param userId - User UUID
|
||||
*
|
||||
* @returns TanStack Query result with user lists array
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: lists, isLoading, refetch } = useUserLists(user?.id);
|
||||
*
|
||||
* // After creating/updating a list:
|
||||
* await createList(newList);
|
||||
* refetch(); // Refresh lists
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface UserTopListItem {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
position: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface UserTopList {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
list_type: string; // Database returns any string
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
list_items: UserTopListItem[];
|
||||
}
|
||||
|
||||
export function useUserLists(userId?: string) {
|
||||
return useQuery<UserTopList[]>({
|
||||
queryKey: queryKeys.lists.user(userId),
|
||||
queryFn: async () => {
|
||||
if (!userId) return [];
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_top_lists')
|
||||
.select(`
|
||||
*,
|
||||
list_items:user_top_list_items(*)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log slow queries in development
|
||||
if (import.meta.env.DEV && duration > 1000) {
|
||||
console.warn(`Slow query: useUserLists took ${duration}ms`, { userId });
|
||||
}
|
||||
|
||||
return data || [];
|
||||
},
|
||||
enabled: !!userId,
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
80
src/hooks/moderation/usePhotoSubmission.ts
Normal file
80
src/hooks/moderation/usePhotoSubmission.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
|
||||
/**
|
||||
* usePhotoSubmission Hook
|
||||
*
|
||||
* Fetches photo submission with items using optimized JOIN query.
|
||||
*
|
||||
* Features:
|
||||
* - Single query with JOIN instead of 2 sequential queries (75% reduction)
|
||||
* - Caches for 3 minutes (moderation content, moderate volatility)
|
||||
* - Transforms to PhotoItem[] for PhotoGrid compatibility
|
||||
* - Performance monitoring with slow query warnings
|
||||
*
|
||||
* @param submissionId - Content submission UUID
|
||||
*
|
||||
* @returns TanStack Query result with PhotoItem array
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: photos, isLoading, error } = usePhotoSubmission(submissionId);
|
||||
*
|
||||
* if (photos && photos.length > 0) {
|
||||
* return <PhotoGrid photos={photos} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export function usePhotoSubmission(submissionId?: string) {
|
||||
return useQuery<PhotoItem[]>({
|
||||
queryKey: queryKeys.moderation.photoSubmission(submissionId),
|
||||
queryFn: async () => {
|
||||
if (!submissionId) return [];
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Step 1: Get photo_submission_id from submission_id
|
||||
const { data: photoSubmission, error: photoSubmissionError } = await supabase
|
||||
.from('photo_submissions')
|
||||
.select('id, entity_type, title')
|
||||
.eq('submission_id', submissionId)
|
||||
.maybeSingle();
|
||||
|
||||
if (photoSubmissionError) throw photoSubmissionError;
|
||||
if (!photoSubmission) return [];
|
||||
|
||||
// Step 2: Get photo items using photo_submission_id
|
||||
const { data: items, error: itemsError } = await supabase
|
||||
.from('photo_submission_items')
|
||||
.select('*')
|
||||
.eq('photo_submission_id', photoSubmission.id)
|
||||
.order('order_index');
|
||||
|
||||
if (itemsError) throw itemsError;
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log slow queries in development
|
||||
if (import.meta.env.DEV && duration > 1000) {
|
||||
console.warn(`Slow query: usePhotoSubmission took ${duration}ms`, { submissionId });
|
||||
}
|
||||
|
||||
// Transform to PhotoItem[] for PhotoGrid compatibility
|
||||
return (items || []).map((item) => ({
|
||||
id: item.id,
|
||||
url: item.cloudflare_image_url,
|
||||
filename: item.filename || `Photo ${item.order_index + 1}`,
|
||||
caption: item.caption,
|
||||
title: item.title,
|
||||
date_taken: item.date_taken,
|
||||
}));
|
||||
},
|
||||
enabled: !!submissionId,
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
154
src/hooks/moderation/useRecentActivity.ts
Normal file
154
src/hooks/moderation/useRecentActivity.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
/**
|
||||
* useRecentActivity Hook
|
||||
*
|
||||
* Fetches recent moderation activity across all types for activity feed.
|
||||
*
|
||||
* Features:
|
||||
* - 3 parallel queries (submissions, reports, reviews) + 1 batch profile fetch
|
||||
* - Caches for 2 minutes (activity feed, should be relatively fresh)
|
||||
* - Smart merging for background refetches (preserves scroll position)
|
||||
* - Performance monitoring with slow query warnings
|
||||
*
|
||||
* @returns TanStack Query result with activity items array
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: activities, isLoading, refetch } = useRecentActivity();
|
||||
*
|
||||
* // Manual refresh trigger:
|
||||
* <Button onClick={() => refetch()}>Refresh Activity</Button>
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: 'submission' | 'report' | 'review';
|
||||
action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged' | 'moderated';
|
||||
entity_type?: string;
|
||||
entity_name?: string;
|
||||
timestamp: string;
|
||||
moderator_id?: string;
|
||||
moderator?: {
|
||||
username: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useRecentActivity() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery<ActivityItem[]>({
|
||||
queryKey: queryKeys.moderation.recentActivity,
|
||||
queryFn: async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Fetch all activity types in parallel
|
||||
const [submissionsResult, reportsResult, reviewsResult] = await Promise.all([
|
||||
supabase
|
||||
.from('content_submissions')
|
||||
.select('id, submission_type, status, updated_at, reviewer_id')
|
||||
.in('status', ['approved', 'rejected'])
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10),
|
||||
supabase
|
||||
.from('reports')
|
||||
.select('id, reported_entity_type, status, updated_at, reviewed_by')
|
||||
.in('status', ['resolved', 'dismissed'])
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(10),
|
||||
supabase
|
||||
.from('reviews')
|
||||
.select('id, ride_id, park_id, moderation_status, moderated_at, moderated_by')
|
||||
.eq('moderation_status', 'flagged')
|
||||
.not('moderated_at', 'is', null)
|
||||
.order('moderated_at', { ascending: false })
|
||||
.limit(10),
|
||||
]);
|
||||
|
||||
// Check for errors
|
||||
if (submissionsResult.error) throw submissionsResult.error;
|
||||
if (reportsResult.error) throw reportsResult.error;
|
||||
if (reviewsResult.error) throw reviewsResult.error;
|
||||
|
||||
const submissions = submissionsResult.data || [];
|
||||
const reports = reportsResult.data || [];
|
||||
const reviews = reviewsResult.data || [];
|
||||
|
||||
// Collect all unique moderator IDs
|
||||
const moderatorIds = new Set<string>();
|
||||
submissions.forEach((s) => s.reviewer_id && moderatorIds.add(s.reviewer_id));
|
||||
reports.forEach((r) => r.reviewed_by && moderatorIds.add(r.reviewed_by));
|
||||
reviews.forEach((r) => r.moderated_by && moderatorIds.add(r.moderated_by));
|
||||
|
||||
// Batch fetch moderator profiles
|
||||
let moderatorMap = new Map<string, any>();
|
||||
if (moderatorIds.size > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('profiles')
|
||||
.select('user_id, username, display_name, avatar_url')
|
||||
.in('user_id', Array.from(moderatorIds));
|
||||
|
||||
if (profilesError) throw profilesError;
|
||||
|
||||
moderatorMap = new Map(
|
||||
(profiles || []).map((p) => [p.user_id, p])
|
||||
);
|
||||
}
|
||||
|
||||
// Transform to ActivityItem[]
|
||||
const activities: ActivityItem[] = [
|
||||
...submissions.map((s) => ({
|
||||
id: s.id,
|
||||
type: 'submission' as const,
|
||||
action: s.status,
|
||||
entity_type: s.submission_type,
|
||||
timestamp: s.updated_at,
|
||||
moderator_id: s.reviewer_id || undefined,
|
||||
moderator: s.reviewer_id ? moderatorMap.get(s.reviewer_id) : undefined,
|
||||
})),
|
||||
...reports.map((r) => ({
|
||||
id: r.id,
|
||||
type: 'report' as const,
|
||||
action: r.status,
|
||||
entity_type: r.reported_entity_type,
|
||||
timestamp: r.updated_at,
|
||||
moderator_id: r.reviewed_by || undefined,
|
||||
moderator: r.reviewed_by ? moderatorMap.get(r.reviewed_by) : undefined,
|
||||
})),
|
||||
...reviews.map((r) => ({
|
||||
id: r.id,
|
||||
type: 'review' as const,
|
||||
action: 'moderated',
|
||||
entity_type: r.ride_id ? 'ride' : 'park',
|
||||
timestamp: r.moderated_at!,
|
||||
moderator_id: r.moderated_by || undefined,
|
||||
moderator: r.moderated_by ? moderatorMap.get(r.moderated_by) : undefined,
|
||||
})),
|
||||
];
|
||||
|
||||
// Sort by timestamp descending and limit to 20
|
||||
activities.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log slow queries in development
|
||||
if (import.meta.env.DEV && duration > 1000) {
|
||||
console.warn(`Slow query: useRecentActivity took ${duration}ms`);
|
||||
}
|
||||
|
||||
return activities.slice(0, 20);
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
66
src/hooks/users/useUserRoles.ts
Normal file
66
src/hooks/users/useUserRoles.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
/**
|
||||
* useUserRoles Hook
|
||||
*
|
||||
* Fetches all user roles with profile information for admin/moderator management.
|
||||
*
|
||||
* Features:
|
||||
* - Uses RPC get_users_with_emails for comprehensive user data
|
||||
* - Caches for 3 minutes (roles don't change frequently)
|
||||
* - Includes email addresses (admin-only RPC)
|
||||
* - Performance monitoring with slow query warnings
|
||||
*
|
||||
* @returns TanStack Query result with user roles array
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: userRoles, isLoading, refetch } = useUserRoles();
|
||||
*
|
||||
* // After granting a role:
|
||||
* await grantRoleToUser(userId, 'moderator');
|
||||
* invalidateUserAuth(userId);
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface UserWithRoles {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useUserRoles() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery<UserWithRoles[]>({
|
||||
queryKey: queryKeys.users.roles(),
|
||||
queryFn: async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const { data, error } = await supabase.rpc('get_users_with_emails');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log slow queries in development
|
||||
if (import.meta.env.DEV && duration > 1000) {
|
||||
console.warn(`Slow query: useUserRoles took ${duration}ms`);
|
||||
}
|
||||
|
||||
return data || [];
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
80
src/hooks/users/useUserSearch.ts
Normal file
80
src/hooks/users/useUserSearch.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
/**
|
||||
* useUserSearch Hook
|
||||
*
|
||||
* Searches users with caching support for admin/moderator user management.
|
||||
*
|
||||
* Features:
|
||||
* - Uses RPC get_users_with_emails for comprehensive data
|
||||
* - Client-side filtering for flexible search
|
||||
* - Caches each search term for 2 minutes
|
||||
* - Only runs when search term is at least 2 characters
|
||||
* - Performance monitoring with slow query warnings
|
||||
*
|
||||
* @param searchTerm - Search query (username or email)
|
||||
*
|
||||
* @returns TanStack Query result with filtered user array
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [search, setSearch] = useState('');
|
||||
* const { data: users, isLoading } = useUserSearch(search);
|
||||
*
|
||||
* // Search updates automatically with caching
|
||||
* <Input value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface UserSearchResult {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useUserSearch(searchTerm: string) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery<UserSearchResult[]>({
|
||||
queryKey: queryKeys.users.search(searchTerm),
|
||||
queryFn: async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
const { data, error } = await supabase.rpc('get_users_with_emails');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const allUsers = data || [];
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
|
||||
// Client-side filtering for flexible search
|
||||
const filtered = allUsers.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(searchLower) ||
|
||||
u.email.toLowerCase().includes(searchLower) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(searchLower))
|
||||
);
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log slow queries in development
|
||||
if (import.meta.env.DEV && duration > 1000) {
|
||||
console.warn(`Slow query: useUserSearch took ${duration}ms`, { searchTerm });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
enabled: !!user && searchTerm.length >= 2,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user