diff --git a/src/components/moderation/ReportButton.tsx b/src/components/moderation/ReportButton.tsx index bf766d18..ce273404 100644 --- a/src/components/moderation/ReportButton.tsx +++ b/src/components/moderation/ReportButton.tsx @@ -23,6 +23,7 @@ import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; import { useToast } from '@/hooks/use-toast'; import { getErrorMessage } from '@/lib/errorHandler'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; interface ReportButtonProps { entityType: 'review' | 'profile' | 'content_submission'; @@ -45,6 +46,9 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr const [loading, setLoading] = useState(false); const { user } = useAuth(); const { toast } = useToast(); + + // Cache invalidation for moderation queue + const { invalidateModerationQueue, invalidateModerationStats } = useQueryInvalidation(); const handleSubmit = async () => { if (!user || !reportType) return; @@ -61,6 +65,10 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr if (error) throw error; + // Invalidate moderation caches + invalidateModerationQueue(); + invalidateModerationStats(); + toast({ title: "Report Submitted", description: "Thank you for your report. We'll review it shortly.", diff --git a/src/components/moderation/UserRoleManager.tsx b/src/components/moderation/UserRoleManager.tsx index 09a3d7e0..094d47a9 100644 --- a/src/components/moderation/UserRoleManager.tsx +++ b/src/components/moderation/UserRoleManager.tsx @@ -11,6 +11,7 @@ import { useAuth } from '@/hooks/useAuth'; import { useUserRole } from '@/hooks/useUserRole'; import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; // Type-safe role definitions const VALID_ROLES = ['admin', 'moderator', 'user'] as const; @@ -67,6 +68,9 @@ export function UserRoleManager() { isSuperuser, permissions } = useUserRole(); + + // Cache invalidation for role changes + const { invalidateUserAuth, invalidateModerationStats } = useQueryInvalidation(); const fetchUserRoles = async () => { try { const { @@ -187,6 +191,11 @@ export function UserRoleManager() { if (error) throw error; handleSuccess('Role Granted', `User has been granted ${getRoleLabel(role)} role`); + + // Invalidate caches instead of manual refetch + invalidateUserAuth(userId); + invalidateModerationStats(); // Role changes affect who can moderate + setNewUserSearch(''); setNewRole(''); setSearchResults([]); @@ -209,7 +218,16 @@ export function UserRoleManager() { error } = await supabase.from('user_roles').delete().eq('id', roleId); if (error) throw error; + handleSuccess('Role Revoked', 'User role has been revoked'); + + // Invalidate caches instead of manual refetch + const revokedRole = userRoles.find(r => r.id === roleId); + if (revokedRole) { + invalidateUserAuth(revokedRole.user_id); + invalidateModerationStats(); + } + fetchUserRoles(); } catch (error: unknown) { handleError(error, { diff --git a/src/components/upload/EntityPhotoGallery.tsx b/src/components/upload/EntityPhotoGallery.tsx index e9943686..b58e24ec 100644 --- a/src/components/upload/EntityPhotoGallery.tsx +++ b/src/components/upload/EntityPhotoGallery.tsx @@ -17,6 +17,7 @@ import { PhotoModal } from '@/components/moderation/PhotoModal'; import { EntityPhotoGalleryProps } from '@/types/submissions'; import { useUserRole } from '@/hooks/useUserRole'; import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; interface Photo { id: string; @@ -49,6 +50,13 @@ export function EntityPhotoGallery({ sortBy ); + // Query invalidation for cross-component cache updates + const { + invalidateEntityPhotos, + invalidatePhotoCount, + invalidateHomepageData + } = useQueryInvalidation(); + const handleUploadClick = () => { if (!user) { navigate('/auth'); @@ -59,7 +67,14 @@ export function EntityPhotoGallery({ const handleSubmissionComplete = () => { setShowUpload(false); - refetch(); // Refresh photos after submission + + // Invalidate all related caches + invalidateEntityPhotos(entityType, entityId); + invalidatePhotoCount(entityType, entityId); + invalidateHomepageData(); // Photos affect homepage stats + + // Also refetch local component (immediate UI update) + refetch(); }; const handlePhotoClick = (index: number) => { diff --git a/src/hooks/companies/useCompanyStatistics.ts b/src/hooks/companies/useCompanyStatistics.ts index 007b3aa2..4e5d1835 100644 --- a/src/hooks/companies/useCompanyStatistics.ts +++ b/src/hooks/companies/useCompanyStatistics.ts @@ -1,21 +1,48 @@ /** * Company Statistics Hook * - * Fetches company statistics (rides, models, photos, parks) with parallel queries. + * Fetches company-specific statistics with optimized parallel queries. + * Adapts query strategy based on company type (manufacturer/designer/operator/property_owner). + * + * Features: + * - Parallel stat queries for performance + * - Type-specific optimizations + * - Long cache times (10 min) for rarely-changing stats + * - Performance monitoring in dev mode + * + * @param companyId - UUID of the company + * @param companyType - Type of company (manufacturer, designer, operator, property_owner) + * @returns Statistics object with counts + * + * @example + * ```tsx + * const { data: stats } = useCompanyStatistics(companyId, 'manufacturer'); + * console.log(stats?.ridesCount); // Number of rides + * ``` */ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; -export function useCompanyStatistics(companyId: string | undefined, companyType: string) { +interface CompanyStatistics { + ridesCount?: number; + modelsCount?: number; + photosCount?: number; + parksCount?: number; + operatingRidesCount?: number; +} + +export function useCompanyStatistics( + companyId: string | undefined, + companyType: string +): UseQueryResult { return useQuery({ queryKey: queryKeys.companies.statistics(companyId || '', companyType), queryFn: async () => { - if (!companyId) return null; + const startTime = performance.now(); - // Batch fetch all statistics in parallel - const statsPromises: Promise[] = []; + if (!companyId) return null; if (companyType === 'manufacturer') { const [ridesRes, modelsRes, photosRes] = await Promise.all([ @@ -24,21 +51,41 @@ export function useCompanyStatistics(companyId: string | undefined, companyType: supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'manufacturer').eq('entity_id', companyId) ]); - return { + const result = { ridesCount: ridesRes.count || 0, modelsCount: modelsRes.count || 0, photosCount: photosRes.count || 0, }; + + // Performance monitoring (dev only) + if (import.meta.env.DEV) { + const duration = performance.now() - startTime; + if (duration > 1000) { + console.warn(`⚠️ Slow query: useCompanyStatistics took ${duration.toFixed(0)}ms`, { companyId, companyType }); + } + } + + return result; } else if (companyType === 'designer') { const [ridesRes, photosRes] = await Promise.all([ supabase.from('rides').select('id', { count: 'exact', head: true }).eq('designer_id', companyId), supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'designer').eq('entity_id', companyId) ]); - return { + const result = { ridesCount: ridesRes.count || 0, photosCount: photosRes.count || 0, }; + + // Performance monitoring (dev only) + if (import.meta.env.DEV) { + const duration = performance.now() - startTime; + if (duration > 1000) { + console.warn(`⚠️ Slow query: useCompanyStatistics took ${duration.toFixed(0)}ms`, { companyId, companyType }); + } + } + + return result; } else { // operator or property_owner - optimized single query const parkField = companyType === 'operator' ? 'operator_id' : 'property_owner_id'; @@ -52,11 +99,21 @@ export function useCompanyStatistics(companyId: string | undefined, companyType: supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', companyType).eq('entity_id', companyId) ]); - return { + const result = { parksCount: parksRes.count || 0, operatingRidesCount: ridesRes.count || 0, photosCount: photosRes.count || 0, }; + + // Performance monitoring (dev only) + if (import.meta.env.DEV) { + const duration = performance.now() - startTime; + if (duration > 1000) { + console.warn(`⚠️ Slow query: useCompanyStatistics took ${duration.toFixed(0)}ms`, { companyId, companyType }); + } + } + + return result; } }, enabled: !!companyId, diff --git a/src/hooks/homepage/useHomepageRecentChanges.ts b/src/hooks/homepage/useHomepageRecentChanges.ts index aacfd0e3..0c0ed4b2 100644 --- a/src/hooks/homepage/useHomepageRecentChanges.ts +++ b/src/hooks/homepage/useHomepageRecentChanges.ts @@ -1,4 +1,30 @@ -import { useQuery } from '@tanstack/react-query'; +/** + * Homepage Recent Changes Hook + * + * Fetches recent entity changes (parks, rides, companies) for homepage display. + * Uses optimized RPC function for single-query fetch of all data. + * + * Features: + * - Fetches up to 24 recent changes + * - Includes entity details, change metadata, and user info + * - Single database query via RPC + * - 5 minute cache for homepage performance + * - Performance monitoring + * + * @param enabled - Whether the query should run (default: true) + * @returns Array of recent changes with full entity context + * + * @example + * ```tsx + * const { data: changes, isLoading } = useHomepageRecentChanges(); + * + * changes?.forEach(change => { + * console.log(`${change.name} was ${change.changeType} by ${change.changedBy?.username}`); + * }); + * ``` + */ + +import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; @@ -18,17 +44,21 @@ interface RecentChange { changeReason?: string; } -export function useHomepageRecentChanges(enabled = true) { +export function useHomepageRecentChanges( + enabled = true +): UseQueryResult { return useQuery({ queryKey: queryKeys.homepage.recentChanges(), queryFn: async () => { + const startTime = performance.now(); + // Use the new database function to get all changes in a single query const { data, error } = await supabase.rpc('get_recent_changes', { limit_count: 24 }); if (error) throw error; // Transform the database response to match our interface - return (data || []).map((item: any): RecentChange => ({ + const result: RecentChange[] = (data || []).map((item: any) => ({ id: item.entity_id, name: item.entity_name, type: item.entity_type as 'park' | 'ride' | 'company', @@ -43,6 +73,16 @@ export function useHomepageRecentChanges(enabled = true) { } : undefined, changeReason: item.change_reason || undefined })); + + // Performance monitoring (dev only) + if (import.meta.env.DEV) { + const duration = performance.now() - startTime; + if (duration > 500) { + console.warn(`⚠️ Slow query: useHomepageRecentChanges took ${duration.toFixed(0)}ms`, { changeCount: result.length }); + } + } + + return result; }, enabled, staleTime: 5 * 60 * 1000, diff --git a/src/hooks/lists/useListItems.ts b/src/hooks/lists/useListItems.ts index 67f3eff2..168b0384 100644 --- a/src/hooks/lists/useListItems.ts +++ b/src/hooks/lists/useListItems.ts @@ -1,14 +1,66 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; /** - * Hook to fetch list items with entities (batch fetching to avoid N+1) + * List Item with Entity Data */ -export function useListItems(listId: string | undefined, enabled = true) { +interface ListItemWithEntity { + id: string; + list_id: string; + entity_type: string; // Allow any string from DB + entity_id: string; + position: number; + notes: string; + created_at: string; + updated_at: string; + entity?: { + id: string; + name: string; + slug: string; + park_type?: string; + category?: string; + company_type?: string; + location_id?: string; + park_id?: string; + }; +} + +/** + * Fetch List Items Hook + * + * Fetches list items with their associated entities using optimized batch fetching. + * Prevents N+1 queries by grouping entity requests by type. + * + * Features: + * - Batch fetches parks, rides, and companies in parallel + * - Caches results for 5 minutes (staleTime) + * - Background refetch every 15 minutes (gcTime) + * - Type-safe entity data + * - Performance monitoring in dev mode + * + * @param listId - UUID of the list to fetch items for + * @param enabled - Whether the query should run (default: true) + * @returns TanStack Query result with array of list items + * + * @example + * ```tsx + * const { data: items, isLoading } = useListItems(listId); + * + * items?.forEach(item => { + * console.log(item.entity?.name); // Entity data is pre-loaded + * }); + * ``` + */ +export function useListItems( + listId: string | undefined, + enabled = true +): UseQueryResult { return useQuery({ queryKey: queryKeys.lists.items(listId || ''), queryFn: async () => { + const startTime = performance.now(); + if (!listId) return []; // Get items @@ -26,30 +78,47 @@ export function useListItems(listId: string | undefined, enabled = true) { const rideIds = items.filter(i => i.entity_type === 'ride').map(i => i.entity_id); const companyIds = items.filter(i => i.entity_type === 'company').map(i => i.entity_id); - // Batch fetch all entities in parallel + // Batch fetch all entities in parallel with error handling const [parksResult, ridesResult, companiesResult] = await Promise.all([ parkIds.length > 0 ? supabase.from('parks').select('id, name, slug, park_type, location_id').in('id', parkIds) - : Promise.resolve({ data: [] }), + : Promise.resolve({ data: [], error: null }), rideIds.length > 0 ? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds) - : Promise.resolve({ data: [] }), + : Promise.resolve({ data: [], error: null }), companyIds.length > 0 ? supabase.from('companies').select('id, name, slug, company_type').in('id', companyIds) - : Promise.resolve({ data: [] }), + : Promise.resolve({ data: [], error: null }), ]); - // Create entities map for quick lookup - const entitiesMap = new Map(); - (parksResult.data || []).forEach(p => entitiesMap.set(p.id, p)); - (ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r)); - (companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c)); + // Check for errors in batch fetches + if (parksResult.error) throw parksResult.error; + if (ridesResult.error) throw ridesResult.error; + if (companiesResult.error) throw companiesResult.error; + + // Create entities map for quick lookup (properly typed) + type EntityData = NonNullable; + const entitiesMap = new Map(); + + (parksResult.data || []).forEach(p => entitiesMap.set(p.id, p as EntityData)); + (ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r as EntityData)); + (companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c as EntityData)); // Map entities to items - return items.map(item => ({ + const result = items.map(item => ({ ...item, entity: entitiesMap.get(item.entity_id), })); + + // Performance monitoring (dev only) + if (import.meta.env.DEV) { + const duration = performance.now() - startTime; + if (duration > 1000) { + console.warn(`⚠️ Slow query: useListItems took ${duration.toFixed(0)}ms`, { listId, itemCount: items.length }); + } + } + + return result; }, enabled: enabled && !!listId, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/src/hooks/moderation/useModerationActions.ts b/src/hooks/moderation/useModerationActions.ts index 016b7a9f..f5e732c2 100644 --- a/src/hooks/moderation/useModerationActions.ts +++ b/src/hooks/moderation/useModerationActions.ts @@ -7,6 +7,7 @@ import { validateMultipleItems } from '@/lib/entityValidationSchemas'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import type { User } from '@supabase/supabase-js'; import type { ModerationItem } from '@/types/moderation'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; /** * Configuration for moderation actions @@ -29,15 +30,42 @@ export interface ModerationActions { } /** - * Hook for moderation action handlers - * Extracted from useModerationQueueManager for better separation of concerns + * Moderation Actions Hook * - * @param config - Configuration object with user, callbacks, and dependencies + * Provides functions for performing moderation actions on content submissions. + * Handles approval, rejection, deletion, and retry operations with proper + * cache invalidation and audit logging. + * + * Features: + * - Photo submission processing + * - Submission item validation + * - Selective approval via edge function + * - Comprehensive error handling + * - Cache invalidation for affected entities + * - Audit trail logging + * - Performance monitoring + * + * @param config - Configuration with user, callbacks, and lock state * @returns Object with action handler functions + * + * @example + * ```tsx + * const actions = useModerationActions({ + * user, + * onActionStart: (id) => console.log('Starting:', id), + * onActionComplete: () => refetch(), + * currentLockSubmissionId: lockedId + * }); + * + * await actions.performAction(item, 'approved', 'Looks good!'); + * ``` */ export function useModerationActions(config: ModerationActionsConfig): ModerationActions { const { user, onActionStart, onActionComplete } = config; const { toast } = useToast(); + + // Cache invalidation for moderation and affected entities + const invalidation = useQueryInvalidation(); /** * Perform moderation action (approve/reject) @@ -263,6 +291,30 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio description: `The ${item.type} has been ${action}`, }); + // Invalidate specific entity caches based on submission type + if (action === 'approved') { + if (item.submission_type === 'photo' && item.content) { + const entityType = item.content.entity_type as string; + const entityId = item.content.entity_id as string; + if (entityType && entityId) { + invalidation.invalidateEntityPhotos(entityType, entityId); + invalidation.invalidatePhotoCount(entityType, entityId); + } + } else if (item.submission_type === 'park') { + invalidation.invalidateParks(); + invalidation.invalidateHomepageData('parks'); + } else if (item.submission_type === 'ride') { + invalidation.invalidateRides(); + invalidation.invalidateHomepageData('rides'); + } else if (item.submission_type === 'company') { + invalidation.invalidateHomepageData('all'); + } + } + + // Always invalidate moderation queue + invalidation.invalidateModerationQueue(); + invalidation.invalidateModerationStats(); + logger.log(`✅ Action ${action} completed for ${item.id}`); } catch (error: unknown) { logger.error('❌ Error performing action:', { error: getErrorMessage(error) }); diff --git a/src/hooks/photos/useEntityPhotos.ts b/src/hooks/photos/useEntityPhotos.ts index 394137b5..97dd5556 100644 --- a/src/hooks/photos/useEntityPhotos.ts +++ b/src/hooks/photos/useEntityPhotos.ts @@ -1,21 +1,54 @@ /** * Entity Photos Hook * - * Fetches photos for an entity with caching and sorting support. + * Fetches photos for a specific entity with intelligent caching and sort support. + * + * Features: + * - Caches photos for 5 minutes (staleTime) + * - Background refetch every 15 minutes (gcTime) + * - Supports 'newest' and 'oldest' sorting without refetching + * - Performance monitoring in dev mode + * + * @param entityType - Type of entity ('park', 'ride', 'company', etc.) + * @param entityId - UUID of the entity + * @param sortBy - Sort order: 'newest' (default) or 'oldest' + * + * @returns TanStack Query result with photo array + * + * @example + * ```tsx + * const { data: photos, isLoading, refetch } = useEntityPhotos('park', parkId, 'newest'); + * + * // After uploading new photos: + * await uploadPhotos(); + * refetch(); // Refresh this component + * invalidateEntityPhotos('park', parkId); // Refresh all components + * ``` */ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; +interface EntityPhoto { + id: string; + url: string; + caption?: string; + title?: string; + user_id: string; + created_at: string; +} + export function useEntityPhotos( entityType: string, entityId: string, sortBy: 'newest' | 'oldest' = 'newest' -) { +): UseQueryResult { return useQuery({ queryKey: queryKeys.photos.entity(entityType, entityId, sortBy), queryFn: async () => { + const startTime = performance.now(); + const { data, error } = await supabase .from('photos') .select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index') @@ -25,7 +58,7 @@ export function useEntityPhotos( if (error) throw error; - return data?.map((photo) => ({ + const result = data?.map((photo) => ({ id: photo.id, url: photo.cloudflare_image_url, caption: photo.caption || undefined, @@ -33,6 +66,16 @@ export function useEntityPhotos( user_id: photo.submitted_by, created_at: photo.created_at, })) || []; + + // Performance monitoring (dev only) + if (import.meta.env.DEV) { + const duration = performance.now() - startTime; + if (duration > 1000) { + console.warn(`⚠️ Slow query: useEntityPhotos took ${duration.toFixed(0)}ms`, { entityType, entityId, photoCount: result.length }); + } + } + + return result; }, enabled: !!entityType && !!entityId, staleTime: 5 * 60 * 1000, diff --git a/src/hooks/profile/useProfileActivity.ts b/src/hooks/profile/useProfileActivity.ts index 98b7ea4c..3048693f 100644 --- a/src/hooks/profile/useProfileActivity.ts +++ b/src/hooks/profile/useProfileActivity.ts @@ -1,22 +1,54 @@ /** * Profile Activity Hook * - * Fetches user activity feed with privacy checks and batch optimization. - * Eliminates N+1 queries for photo submissions. + * Fetches user activity feed with privacy checks and optimized batch fetching. + * Prevents N+1 queries by batch fetching photo submission entities. + * + * Features: + * - Privacy-aware filtering based on user preferences + * - Batch fetches related entities (parks, rides) for photo submissions + * - Combines reviews, credits, submissions, and rankings + * - Returns top 15 most recent activities + * - 3 minute cache for frequently updated data + * - Performance monitoring in dev mode + * + * @param userId - UUID of the profile user + * @param isOwnProfile - Whether viewing user is the profile owner + * @param isModerator - Whether viewing user is a moderator + * @returns Combined activity feed sorted by date + * + * @example + * ```tsx + * const { data: activity } = useProfileActivity(userId, isOwnProfile, isModerator()); + * + * activity?.forEach(item => { + * if (item.type === 'review') console.log('Review:', item.rating); + * if (item.type === 'submission') console.log('Submission:', item.submission_type); + * }); + * ``` */ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; +// Type-safe activity item types +type ActivityItem = + | { type: 'review'; [key: string]: any } + | { type: 'credit'; [key: string]: any } + | { type: 'submission'; [key: string]: any } + | { type: 'ranking'; [key: string]: any }; + export function useProfileActivity( userId: string | undefined, isOwnProfile: boolean, isModerator: boolean -) { +): UseQueryResult { return useQuery({ queryKey: queryKeys.profile.activity(userId || '', isOwnProfile, isModerator), queryFn: async () => { + const startTime = performance.now(); + if (!userId) return []; // Check privacy settings first @@ -98,18 +130,45 @@ export function useProfileActivity( rideIds.length ? supabase.from('rides').select('id, name, slug, parks!inner(name, slug)').in('id', rideIds).then(r => r.data || []) : [] ]); - // Create lookup maps - const photoSubMap = new Map(photoSubs.map(ps => [ps.submission_id, ps])); - const photoItemsMap = new Map(); - photoItems?.forEach(item => { + // Create lookup maps with proper typing + interface PhotoSubmissionData { + id: string; + submission_id: string; + entity_type: string; + entity_id: string; + } + + interface PhotoItem { + photo_submission_id: string; + cloudflare_image_url: string; + [key: string]: any; + } + + interface EntityData { + id: string; + name: string; + slug: string; + parks?: { + name: string; + slug: string; + }; + } + + const photoSubMap = new Map( + photoSubs.map(ps => [ps.submission_id, ps as PhotoSubmissionData]) + ); + + const photoItemsMap = new Map(); + photoItems?.forEach((item: PhotoItem) => { if (!photoItemsMap.has(item.photo_submission_id)) { photoItemsMap.set(item.photo_submission_id, []); } photoItemsMap.get(item.photo_submission_id)!.push(item); }); - const entityMap = new Map([ - ...parks.map((p: any) => [p.id, p] as [string, any]), - ...rides.map((r: any) => [r.id, r] as [string, any]) + + const entityMap = new Map([ + ...parks.map((p: any): [string, EntityData] => [p.id, p]), + ...rides.map((r: any): [string, EntityData] => [r.id, r]) ]); // Enrich submissions @@ -137,7 +196,7 @@ export function useProfileActivity( } // Combine and sort - const combined = [ + const combined: ActivityItem[] = [ ...reviews.map(r => ({ ...r, type: 'review' as const })), ...credits.map(c => ({ ...c, type: 'credit' as const })), ...submissions.map(s => ({ ...s, type: 'submission' as const })), @@ -145,6 +204,19 @@ export function useProfileActivity( ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .slice(0, 15); + // Performance monitoring (dev only) + if (import.meta.env.DEV) { + const duration = performance.now() - startTime; + if (duration > 1500) { + console.warn(`⚠️ Slow query: useProfileActivity took ${duration.toFixed(0)}ms`, { + userId, + itemCount: combined.length, + reviewCount: reviews.length, + submissionCount: submissions.length + }); + } + } + return combined; }, enabled: !!userId, diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 9cc76684..0df5fe04 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -34,6 +34,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; import { useProfileActivity } from '@/hooks/profile/useProfileActivity'; import { useProfileStats } from '@/hooks/profile/useProfileStats'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; // Activity type definitions interface SubmissionActivity { @@ -150,6 +151,9 @@ export default function Profile() { const [avatarUrl, setAvatarUrl] = useState(''); const [avatarImageId, setAvatarImageId] = useState(''); + // Query invalidation for cache updates + const { invalidateProfileActivity, invalidateProfileStats } = useQueryInvalidation(); + // User role checking const { isModerator, loading: rolesLoading } = useUserRole(); @@ -325,6 +329,13 @@ export default function Profile() { error } = await supabase.from('profiles').update(updateData).eq('user_id', currentUser.id); if (error) throw error; + + // Invalidate profile caches across the app + if (currentUser.id) { + invalidateProfileActivity(currentUser.id); + invalidateProfileStats(currentUser.id); + } + setProfile(prev => prev ? { ...prev, ...updateData @@ -374,6 +385,11 @@ export default function Profile() { }).eq('user_id', currentUser.id); if (error) throw error; + // Invalidate profile activity cache (avatar shows in activity) + if (currentUser.id) { + invalidateProfileActivity(currentUser.id); + } + // Update local profile state setProfile(prev => prev ? { ...prev,