Apply all API enhancements

This commit is contained in:
gpt-engineer-app[bot]
2025-10-30 23:55:18 +00:00
parent 8f4110d890
commit d40f0f13aa
10 changed files with 435 additions and 45 deletions

View File

@@ -23,6 +23,7 @@ import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler'; import { getErrorMessage } from '@/lib/errorHandler';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
interface ReportButtonProps { interface ReportButtonProps {
entityType: 'review' | 'profile' | 'content_submission'; entityType: 'review' | 'profile' | 'content_submission';
@@ -46,6 +47,9 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
// Cache invalidation for moderation queue
const { invalidateModerationQueue, invalidateModerationStats } = useQueryInvalidation();
const handleSubmit = async () => { const handleSubmit = async () => {
if (!user || !reportType) return; if (!user || !reportType) return;
@@ -61,6 +65,10 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
if (error) throw error; if (error) throw error;
// Invalidate moderation caches
invalidateModerationQueue();
invalidateModerationStats();
toast({ toast({
title: "Report Submitted", title: "Report Submitted",
description: "Thank you for your report. We'll review it shortly.", description: "Thank you for your report. We'll review it shortly.",

View File

@@ -11,6 +11,7 @@ import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler'; import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
// Type-safe role definitions // Type-safe role definitions
const VALID_ROLES = ['admin', 'moderator', 'user'] as const; const VALID_ROLES = ['admin', 'moderator', 'user'] as const;
@@ -67,6 +68,9 @@ export function UserRoleManager() {
isSuperuser, isSuperuser,
permissions permissions
} = useUserRole(); } = useUserRole();
// Cache invalidation for role changes
const { invalidateUserAuth, invalidateModerationStats } = useQueryInvalidation();
const fetchUserRoles = async () => { const fetchUserRoles = async () => {
try { try {
const { const {
@@ -187,6 +191,11 @@ export function UserRoleManager() {
if (error) throw error; if (error) throw error;
handleSuccess('Role Granted', `User has been granted ${getRoleLabel(role)} role`); 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(''); setNewUserSearch('');
setNewRole(''); setNewRole('');
setSearchResults([]); setSearchResults([]);
@@ -209,7 +218,16 @@ export function UserRoleManager() {
error error
} = await supabase.from('user_roles').delete().eq('id', roleId); } = await supabase.from('user_roles').delete().eq('id', roleId);
if (error) throw error; if (error) throw error;
handleSuccess('Role Revoked', 'User role has been revoked'); 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(); fetchUserRoles();
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, { handleError(error, {

View File

@@ -17,6 +17,7 @@ import { PhotoModal } from '@/components/moderation/PhotoModal';
import { EntityPhotoGalleryProps } from '@/types/submissions'; import { EntityPhotoGalleryProps } from '@/types/submissions';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos'; import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
interface Photo { interface Photo {
id: string; id: string;
@@ -49,6 +50,13 @@ export function EntityPhotoGallery({
sortBy sortBy
); );
// Query invalidation for cross-component cache updates
const {
invalidateEntityPhotos,
invalidatePhotoCount,
invalidateHomepageData
} = useQueryInvalidation();
const handleUploadClick = () => { const handleUploadClick = () => {
if (!user) { if (!user) {
navigate('/auth'); navigate('/auth');
@@ -59,7 +67,14 @@ export function EntityPhotoGallery({
const handleSubmissionComplete = () => { const handleSubmissionComplete = () => {
setShowUpload(false); 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) => { const handlePhotoClick = (index: number) => {

View File

@@ -1,21 +1,48 @@
/** /**
* Company Statistics Hook * 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 { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys'; 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<CompanyStatistics | null> {
return useQuery({ return useQuery({
queryKey: queryKeys.companies.statistics(companyId || '', companyType), queryKey: queryKeys.companies.statistics(companyId || '', companyType),
queryFn: async () => { queryFn: async () => {
if (!companyId) return null; const startTime = performance.now();
// Batch fetch all statistics in parallel if (!companyId) return null;
const statsPromises: Promise<any>[] = [];
if (companyType === 'manufacturer') { if (companyType === 'manufacturer') {
const [ridesRes, modelsRes, photosRes] = await Promise.all([ 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) supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'manufacturer').eq('entity_id', companyId)
]); ]);
return { const result = {
ridesCount: ridesRes.count || 0, ridesCount: ridesRes.count || 0,
modelsCount: modelsRes.count || 0, modelsCount: modelsRes.count || 0,
photosCount: photosRes.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') { } else if (companyType === 'designer') {
const [ridesRes, photosRes] = await Promise.all([ const [ridesRes, photosRes] = await Promise.all([
supabase.from('rides').select('id', { count: 'exact', head: true }).eq('designer_id', companyId), 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) supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'designer').eq('entity_id', companyId)
]); ]);
return { const result = {
ridesCount: ridesRes.count || 0, ridesCount: ridesRes.count || 0,
photosCount: photosRes.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 { } else {
// operator or property_owner - optimized single query // operator or property_owner - optimized single query
const parkField = companyType === 'operator' ? 'operator_id' : 'property_owner_id'; 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) supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', companyType).eq('entity_id', companyId)
]); ]);
return { const result = {
parksCount: parksRes.count || 0, parksCount: parksRes.count || 0,
operatingRidesCount: ridesRes.count || 0, operatingRidesCount: ridesRes.count || 0,
photosCount: photosRes.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, enabled: !!companyId,

View File

@@ -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 { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys'; import { queryKeys } from '@/lib/queryKeys';
@@ -18,17 +44,21 @@ interface RecentChange {
changeReason?: string; changeReason?: string;
} }
export function useHomepageRecentChanges(enabled = true) { export function useHomepageRecentChanges(
enabled = true
): UseQueryResult<RecentChange[]> {
return useQuery({ return useQuery({
queryKey: queryKeys.homepage.recentChanges(), queryKey: queryKeys.homepage.recentChanges(),
queryFn: async () => { queryFn: async () => {
const startTime = performance.now();
// Use the new database function to get all changes in a single query // 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 }); const { data, error } = await supabase.rpc('get_recent_changes', { limit_count: 24 });
if (error) throw error; if (error) throw error;
// Transform the database response to match our interface // 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, id: item.entity_id,
name: item.entity_name, name: item.entity_name,
type: item.entity_type as 'park' | 'ride' | 'company', type: item.entity_type as 'park' | 'ride' | 'company',
@@ -43,6 +73,16 @@ export function useHomepageRecentChanges(enabled = true) {
} : undefined, } : undefined,
changeReason: item.change_reason || 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, enabled,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,

View File

@@ -1,14 +1,66 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys'; 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<ListItemWithEntity[]> {
return useQuery({ return useQuery({
queryKey: queryKeys.lists.items(listId || ''), queryKey: queryKeys.lists.items(listId || ''),
queryFn: async () => { queryFn: async () => {
const startTime = performance.now();
if (!listId) return []; if (!listId) return [];
// Get items // 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 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); 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([ const [parksResult, ridesResult, companiesResult] = await Promise.all([
parkIds.length > 0 parkIds.length > 0
? supabase.from('parks').select('id, name, slug, park_type, location_id').in('id', parkIds) ? 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 rideIds.length > 0
? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds) ? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds)
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [], error: null }),
companyIds.length > 0 companyIds.length > 0
? supabase.from('companies').select('id, name, slug, company_type').in('id', companyIds) ? 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 // Check for errors in batch fetches
const entitiesMap = new Map<string, any>(); if (parksResult.error) throw parksResult.error;
(parksResult.data || []).forEach(p => entitiesMap.set(p.id, p)); if (ridesResult.error) throw ridesResult.error;
(ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r)); if (companiesResult.error) throw companiesResult.error;
(companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c));
// Create entities map for quick lookup (properly typed)
type EntityData = NonNullable<ListItemWithEntity['entity']>;
const entitiesMap = new Map<string, EntityData>();
(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 // Map entities to items
return items.map(item => ({ const result = items.map(item => ({
...item, ...item,
entity: entitiesMap.get(item.entity_id), 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, enabled: enabled && !!listId,
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes

View File

@@ -7,6 +7,7 @@ import { validateMultipleItems } from '@/lib/entityValidationSchemas';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import type { User } from '@supabase/supabase-js'; import type { User } from '@supabase/supabase-js';
import type { ModerationItem } from '@/types/moderation'; import type { ModerationItem } from '@/types/moderation';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
/** /**
* Configuration for moderation actions * Configuration for moderation actions
@@ -29,16 +30,43 @@ export interface ModerationActions {
} }
/** /**
* Hook for moderation action handlers * Moderation Actions Hook
* Extracted from useModerationQueueManager for better separation of concerns
* *
* @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 * @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 { export function useModerationActions(config: ModerationActionsConfig): ModerationActions {
const { user, onActionStart, onActionComplete } = config; const { user, onActionStart, onActionComplete } = config;
const { toast } = useToast(); const { toast } = useToast();
// Cache invalidation for moderation and affected entities
const invalidation = useQueryInvalidation();
/** /**
* Perform moderation action (approve/reject) * Perform moderation action (approve/reject)
*/ */
@@ -263,6 +291,30 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
description: `The ${item.type} has been ${action}`, 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}`); logger.log(`✅ Action ${action} completed for ${item.id}`);
} catch (error: unknown) { } catch (error: unknown) {
logger.error('❌ Error performing action:', { error: getErrorMessage(error) }); logger.error('❌ Error performing action:', { error: getErrorMessage(error) });

View File

@@ -1,21 +1,54 @@
/** /**
* Entity Photos Hook * 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 { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys'; import { queryKeys } from '@/lib/queryKeys';
interface EntityPhoto {
id: string;
url: string;
caption?: string;
title?: string;
user_id: string;
created_at: string;
}
export function useEntityPhotos( export function useEntityPhotos(
entityType: string, entityType: string,
entityId: string, entityId: string,
sortBy: 'newest' | 'oldest' = 'newest' sortBy: 'newest' | 'oldest' = 'newest'
) { ): UseQueryResult<EntityPhoto[]> {
return useQuery({ return useQuery({
queryKey: queryKeys.photos.entity(entityType, entityId, sortBy), queryKey: queryKeys.photos.entity(entityType, entityId, sortBy),
queryFn: async () => { queryFn: async () => {
const startTime = performance.now();
const { data, error } = await supabase const { data, error } = await supabase
.from('photos') .from('photos')
.select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index') .select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index')
@@ -25,7 +58,7 @@ export function useEntityPhotos(
if (error) throw error; if (error) throw error;
return data?.map((photo) => ({ const result = data?.map((photo) => ({
id: photo.id, id: photo.id,
url: photo.cloudflare_image_url, url: photo.cloudflare_image_url,
caption: photo.caption || undefined, caption: photo.caption || undefined,
@@ -33,6 +66,16 @@ export function useEntityPhotos(
user_id: photo.submitted_by, user_id: photo.submitted_by,
created_at: photo.created_at, 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, enabled: !!entityType && !!entityId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,

View File

@@ -1,22 +1,54 @@
/** /**
* Profile Activity Hook * Profile Activity Hook
* *
* Fetches user activity feed with privacy checks and batch optimization. * Fetches user activity feed with privacy checks and optimized batch fetching.
* Eliminates N+1 queries for photo submissions. * 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 { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys'; 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( export function useProfileActivity(
userId: string | undefined, userId: string | undefined,
isOwnProfile: boolean, isOwnProfile: boolean,
isModerator: boolean isModerator: boolean
) { ): UseQueryResult<ActivityItem[]> {
return useQuery({ return useQuery({
queryKey: queryKeys.profile.activity(userId || '', isOwnProfile, isModerator), queryKey: queryKeys.profile.activity(userId || '', isOwnProfile, isModerator),
queryFn: async () => { queryFn: async () => {
const startTime = performance.now();
if (!userId) return []; if (!userId) return [];
// Check privacy settings first // 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 || []) : [] rideIds.length ? supabase.from('rides').select('id, name, slug, parks!inner(name, slug)').in('id', rideIds).then(r => r.data || []) : []
]); ]);
// Create lookup maps // Create lookup maps with proper typing
const photoSubMap = new Map(photoSubs.map(ps => [ps.submission_id, ps])); interface PhotoSubmissionData {
const photoItemsMap = new Map<string, any[]>(); id: string;
photoItems?.forEach(item => { 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<string, PhotoSubmissionData>(
photoSubs.map(ps => [ps.submission_id, ps as PhotoSubmissionData])
);
const photoItemsMap = new Map<string, PhotoItem[]>();
photoItems?.forEach((item: PhotoItem) => {
if (!photoItemsMap.has(item.photo_submission_id)) { if (!photoItemsMap.has(item.photo_submission_id)) {
photoItemsMap.set(item.photo_submission_id, []); photoItemsMap.set(item.photo_submission_id, []);
} }
photoItemsMap.get(item.photo_submission_id)!.push(item); photoItemsMap.get(item.photo_submission_id)!.push(item);
}); });
const entityMap = new Map<string, any>([
...parks.map((p: any) => [p.id, p] as [string, any]), const entityMap = new Map<string, EntityData>([
...rides.map((r: any) => [r.id, r] as [string, any]) ...parks.map((p: any): [string, EntityData] => [p.id, p]),
...rides.map((r: any): [string, EntityData] => [r.id, r])
]); ]);
// Enrich submissions // Enrich submissions
@@ -137,7 +196,7 @@ export function useProfileActivity(
} }
// Combine and sort // Combine and sort
const combined = [ const combined: ActivityItem[] = [
...reviews.map(r => ({ ...r, type: 'review' as const })), ...reviews.map(r => ({ ...r, type: 'review' as const })),
...credits.map(c => ({ ...c, type: 'credit' as const })), ...credits.map(c => ({ ...c, type: 'credit' as const })),
...submissions.map(s => ({ ...s, type: 'submission' 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()) ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 15); .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; return combined;
}, },
enabled: !!userId, enabled: !!userId,

View File

@@ -34,6 +34,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph'; import { useOpenGraph } from '@/hooks/useOpenGraph';
import { useProfileActivity } from '@/hooks/profile/useProfileActivity'; import { useProfileActivity } from '@/hooks/profile/useProfileActivity';
import { useProfileStats } from '@/hooks/profile/useProfileStats'; import { useProfileStats } from '@/hooks/profile/useProfileStats';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
// Activity type definitions // Activity type definitions
interface SubmissionActivity { interface SubmissionActivity {
@@ -150,6 +151,9 @@ export default function Profile() {
const [avatarUrl, setAvatarUrl] = useState<string>(''); const [avatarUrl, setAvatarUrl] = useState<string>('');
const [avatarImageId, setAvatarImageId] = useState<string>(''); const [avatarImageId, setAvatarImageId] = useState<string>('');
// Query invalidation for cache updates
const { invalidateProfileActivity, invalidateProfileStats } = useQueryInvalidation();
// User role checking // User role checking
const { isModerator, loading: rolesLoading } = useUserRole(); const { isModerator, loading: rolesLoading } = useUserRole();
@@ -325,6 +329,13 @@ export default function Profile() {
error error
} = await supabase.from('profiles').update(updateData).eq('user_id', currentUser.id); } = await supabase.from('profiles').update(updateData).eq('user_id', currentUser.id);
if (error) throw error; if (error) throw error;
// Invalidate profile caches across the app
if (currentUser.id) {
invalidateProfileActivity(currentUser.id);
invalidateProfileStats(currentUser.id);
}
setProfile(prev => prev ? { setProfile(prev => prev ? {
...prev, ...prev,
...updateData ...updateData
@@ -374,6 +385,11 @@ export default function Profile() {
}).eq('user_id', currentUser.id); }).eq('user_id', currentUser.id);
if (error) throw error; if (error) throw error;
// Invalidate profile activity cache (avatar shows in activity)
if (currentUser.id) {
invalidateProfileActivity(currentUser.id);
}
// Update local profile state // Update local profile state
setProfile(prev => prev ? { setProfile(prev => prev ? {
...prev, ...prev,