mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 14:11:13 -05:00
Reverted to commit 0091584677
This commit is contained in:
@@ -1,55 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
interface AuditLogFilters {
|
||||
userId?: string;
|
||||
action?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for querying audit logs with proper caching
|
||||
* Provides: paginated audit log queries with filtering
|
||||
*/
|
||||
export function useAuditLogs(filters: AuditLogFilters = {}) {
|
||||
const { userId, action, page = 1, pageSize = 50 } = filters;
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.admin.auditLogs(userId),
|
||||
queryFn: async () => {
|
||||
let query = supabase
|
||||
.from('profile_audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (userId) {
|
||||
query = query.eq('user_id', userId);
|
||||
}
|
||||
|
||||
if (action) {
|
||||
query = query.eq('action', action);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize - 1;
|
||||
query = query.range(startIndex, endIndex);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
logs: data || [],
|
||||
total: count || 0,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil((count || 0) / pageSize),
|
||||
};
|
||||
},
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* useBlogPost Hook
|
||||
*
|
||||
* Fetches a published blog post by slug with author profile information.
|
||||
* Extracted from BlogPost.tsx for reusability and better caching.
|
||||
*
|
||||
* Features:
|
||||
* - Caches blog posts for 5 minutes
|
||||
* - Includes author profile data (username, display_name, avatar)
|
||||
* - Only returns published posts
|
||||
*
|
||||
* @param slug - URL slug of the blog post
|
||||
* @returns TanStack Query result with blog post data
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: post, isLoading } = useBlogPost(slug);
|
||||
* ```
|
||||
*/
|
||||
export function useBlogPost(slug: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.blog.post(slug || ''),
|
||||
queryFn: async () => {
|
||||
if (!slug) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('blog_posts')
|
||||
.select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)')
|
||||
.eq('slug', slug)
|
||||
.eq('status', 'published')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: !!slug,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes (blog content)
|
||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Company Detail Hook
|
||||
*
|
||||
* Fetches company details with caching for efficient data access.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useCompanyDetail(slug: string | undefined, companyType: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.companies.detail(slug || '', companyType),
|
||||
queryFn: async () => {
|
||||
if (!slug) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('slug', slug)
|
||||
.eq('company_type', companyType)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: !!slug,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 15 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Company Parks Hook
|
||||
*
|
||||
* Fetches parks operated/owned by a company with caching.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useCompanyParks(
|
||||
companyId: string | undefined,
|
||||
companyType: 'operator' | 'property_owner',
|
||||
limit = 6
|
||||
) {
|
||||
const field = companyType === 'operator' ? 'operator_id' : 'property_owner_id';
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.companies.parks(companyId || '', companyType, limit),
|
||||
queryFn: async () => {
|
||||
if (!companyId) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select('*, location:locations(*)')
|
||||
.eq(field, companyId)
|
||||
.order('name')
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
},
|
||||
enabled: !!companyId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 15 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Company Statistics Hook
|
||||
*
|
||||
* 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, UseQueryResult } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
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({
|
||||
queryKey: queryKeys.companies.statistics(companyId || '', companyType),
|
||||
queryFn: async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
if (!companyId) return null;
|
||||
|
||||
if (companyType === 'manufacturer') {
|
||||
const [ridesRes, modelsRes, photosRes] = await Promise.all([
|
||||
supabase.from('rides').select('id', { count: 'exact', head: true }).eq('manufacturer_id', companyId),
|
||||
supabase.from('ride_models').select('id', { count: 'exact', head: true }).eq('manufacturer_id', companyId),
|
||||
supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'manufacturer').eq('entity_id', companyId)
|
||||
]);
|
||||
|
||||
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)
|
||||
]);
|
||||
|
||||
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';
|
||||
|
||||
const [parksRes, ridesRes, photosRes] = await Promise.all([
|
||||
supabase.from('parks').select('id', { count: 'exact', head: true }).eq(parkField, companyId),
|
||||
supabase.from('rides')
|
||||
.select('id, parks!inner(operator_id, property_owner_id)', { count: 'exact', head: true })
|
||||
.eq('status', 'operating')
|
||||
.eq(`parks.${parkField}`, companyId),
|
||||
supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', companyType).eq('entity_id', companyId)
|
||||
]);
|
||||
|
||||
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,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes - stats change rarely
|
||||
gcTime: 20 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* useEntityName Hook
|
||||
*
|
||||
* Fetches the name of an entity for display purposes.
|
||||
* Replaces multiple sequential direct queries with a single cached hook.
|
||||
*
|
||||
* Features:
|
||||
* - Caches entity names for 10 minutes (rarely change)
|
||||
* - Supports parks, rides, ride_models, and all company types
|
||||
* - Returns 'Unknown' for invalid types or missing data
|
||||
*
|
||||
* @param entityType - Type of entity ('park', 'ride', 'ride_model', 'manufacturer', etc.)
|
||||
* @param entityId - UUID of the entity
|
||||
* @returns TanStack Query result with entity name string
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: entityName = 'Unknown' } = useEntityName('park', parkId);
|
||||
* ```
|
||||
*/
|
||||
export function useEntityName(entityType: string, entityId: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.entities.name(entityType, entityId),
|
||||
queryFn: async () => {
|
||||
// Type-safe approach: separate queries for each table type
|
||||
try {
|
||||
if (entityType === 'park') {
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select('name')
|
||||
.eq('id', entityId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data?.name || 'Unknown';
|
||||
}
|
||||
|
||||
if (entityType === 'ride') {
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select('name')
|
||||
.eq('id', entityId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data?.name || 'Unknown';
|
||||
}
|
||||
|
||||
if (entityType === 'ride_model') {
|
||||
const { data, error } = await supabase
|
||||
.from('ride_models')
|
||||
.select('name')
|
||||
.eq('id', entityId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data?.name || 'Unknown';
|
||||
}
|
||||
|
||||
if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', entityId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data?.name || 'Unknown';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch entity name:', error);
|
||||
return 'Unknown';
|
||||
}
|
||||
},
|
||||
enabled: !!entityType && !!entityId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes (entity names rarely change)
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toDateOnly } from '@/lib/dateUtils';
|
||||
|
||||
export function useHomepageRecentlyClosedParks(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -14,9 +13,9 @@ export function useHomepageRecentlyClosedParks(enabled = true) {
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||
.gte('closing_date', toDateOnly(oneYearAgo))
|
||||
.lte('closing_date', toDateOnly(today))
|
||||
.order('closing_date', { ascending: false })
|
||||
.gte('closed_date', oneYearAgo.toISOString())
|
||||
.lte('closed_date', today.toISOString())
|
||||
.order('closed_date', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
@@ -39,10 +38,10 @@ export function useHomepageRecentlyClosedRides(enabled = true) {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`*, park:parks(*, location:locations(*))`)
|
||||
.gte('closing_date', toDateOnly(oneYearAgo))
|
||||
.lte('closing_date', toDateOnly(today))
|
||||
.order('closing_date', { ascending: false })
|
||||
.select(`*, park:parks(*), location:locations(*)`)
|
||||
.gte('closed_date', oneYearAgo.toISOString())
|
||||
.lte('closed_date', today.toISOString())
|
||||
.order('closed_date', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toDateOnly } from '@/lib/dateUtils';
|
||||
|
||||
export function useHomepageClosingSoonParks(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -14,9 +13,9 @@ export function useHomepageClosingSoonParks(enabled = true) {
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||
.gte('closing_date', toDateOnly(today))
|
||||
.lte('closing_date', toDateOnly(sixMonthsFromNow))
|
||||
.order('closing_date', { ascending: true })
|
||||
.gte('closed_date', today.toISOString())
|
||||
.lte('closed_date', sixMonthsFromNow.toISOString())
|
||||
.order('closed_date', { ascending: true })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
@@ -39,10 +38,10 @@ export function useHomepageClosingSoonRides(enabled = true) {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`*, park:parks(*, location:locations(*))`)
|
||||
.gte('closing_date', toDateOnly(today))
|
||||
.lte('closing_date', toDateOnly(sixMonthsFromNow))
|
||||
.order('closing_date', { ascending: true })
|
||||
.select(`*, park:parks(*), location:locations(*)`)
|
||||
.gte('closed_date', today.toISOString())
|
||||
.lte('closed_date', sixMonthsFromNow.toISOString())
|
||||
.order('closed_date', { ascending: true })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toDateOnly } from '@/lib/dateUtils';
|
||||
|
||||
export function useHomepageRecentlyOpenedParks(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -13,8 +12,8 @@ export function useHomepageRecentlyOpenedParks(enabled = true) {
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||
.gte('opening_date', toDateOnly(oneYearAgo))
|
||||
.order('opening_date', { ascending: false })
|
||||
.gte('opened_date', oneYearAgo.toISOString())
|
||||
.order('opened_date', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
@@ -36,9 +35,9 @@ export function useHomepageRecentlyOpenedRides(enabled = true) {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`*, park:parks(*, location:locations(*))`)
|
||||
.gte('opening_date', toDateOnly(oneYearAgo))
|
||||
.order('opening_date', { ascending: false })
|
||||
.select(`*, park:parks(*), location:locations(*)`)
|
||||
.gte('opened_date', oneYearAgo.toISOString())
|
||||
.order('opened_date', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toDateOnly } from '@/lib/dateUtils';
|
||||
|
||||
export function useHomepageOpeningSoonParks(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -14,9 +13,9 @@ export function useHomepageOpeningSoonParks(enabled = true) {
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||
.gte('opening_date', toDateOnly(today))
|
||||
.lte('opening_date', toDateOnly(sixMonthsFromNow))
|
||||
.order('opening_date', { ascending: true })
|
||||
.gte('opened_date', today.toISOString())
|
||||
.lte('opened_date', sixMonthsFromNow.toISOString())
|
||||
.order('opened_date', { ascending: true })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
@@ -39,10 +38,10 @@ export function useHomepageOpeningSoonRides(enabled = true) {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`*, park:parks(*, location:locations(*))`)
|
||||
.gte('opening_date', toDateOnly(today))
|
||||
.lte('opening_date', toDateOnly(sixMonthsFromNow))
|
||||
.order('opening_date', { ascending: true })
|
||||
.select(`*, park:parks(*), location:locations(*)`)
|
||||
.gte('opened_date', today.toISOString())
|
||||
.lte('opened_date', sixMonthsFromNow.toISOString())
|
||||
.order('opened_date', { ascending: true })
|
||||
.limit(12);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useHomepageHighestRatedRides(enabled = true) {
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`*, park:parks(*, location:locations(*))`)
|
||||
.select(`*, park:parks(*), location:locations(*)`)
|
||||
.not('average_rating', 'is', null)
|
||||
.order('average_rating', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useHomepageRecentRides(enabled = true) {
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`*, park:parks(*, location:locations(*))`)
|
||||
.select(`*, park:parks(*), location:locations(*)`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
|
||||
@@ -1,30 +1,4 @@
|
||||
/**
|
||||
* 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 { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
@@ -44,35 +18,17 @@ interface RecentChange {
|
||||
changeReason?: string;
|
||||
}
|
||||
|
||||
export function useHomepageRecentChanges(
|
||||
enabled = true
|
||||
): UseQueryResult<RecentChange[]> {
|
||||
export function useHomepageRecentChanges(enabled = true) {
|
||||
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;
|
||||
|
||||
interface DatabaseRecentChange {
|
||||
entity_id: string;
|
||||
entity_name: string;
|
||||
entity_type: string;
|
||||
entity_slug: string;
|
||||
park_slug?: string;
|
||||
image_url?: string;
|
||||
change_type: string;
|
||||
changed_at: string;
|
||||
changed_by_username?: string;
|
||||
changed_by_avatar?: string;
|
||||
change_reason?: string;
|
||||
}
|
||||
|
||||
// Transform the database response to match our interface
|
||||
const result: RecentChange[] = (data as unknown as DatabaseRecentChange[] || []).map((item) => ({
|
||||
return (data || []).map((item: any) => ({
|
||||
id: item.entity_id,
|
||||
name: item.entity_name,
|
||||
type: item.entity_type as 'park' | 'ride' | 'company',
|
||||
@@ -86,17 +42,7 @@ export function useHomepageRecentChanges(
|
||||
avatarUrl: item.changed_by_avatar || undefined
|
||||
} : 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;
|
||||
})) as RecentChange[];
|
||||
},
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useHomepageTrendingRides(enabled = true) {
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`*, park:parks(*, location:locations(*))`)
|
||||
.select(`*, park:parks(*), location:locations(*)`)
|
||||
.order('view_count_30d', { ascending: false })
|
||||
.limit(12);
|
||||
|
||||
|
||||
@@ -1,66 +1,14 @@
|
||||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* List Item with Entity Data
|
||||
* Hook to fetch list items with entities (batch fetching to avoid N+1)
|
||||
*/
|
||||
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[]> {
|
||||
export function useListItems(listId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.lists.items(listId || ''),
|
||||
queryFn: async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
if (!listId) return [];
|
||||
|
||||
// Get items
|
||||
@@ -78,47 +26,30 @@ export function useListItems(
|
||||
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 with error handling
|
||||
// Batch fetch all entities in parallel
|
||||
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: [], error: null }),
|
||||
: Promise.resolve({ data: [] }),
|
||||
rideIds.length > 0
|
||||
? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
: Promise.resolve({ data: [] }),
|
||||
companyIds.length > 0
|
||||
? supabase.from('companies').select('id, name, slug, company_type').in('id', companyIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
// 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<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));
|
||||
// Create entities map for quick lookup
|
||||
const entitiesMap = new Map<string, any>();
|
||||
(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));
|
||||
|
||||
// Map entities to items
|
||||
const result = items.map(item => ({
|
||||
return 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
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -30,42 +29,15 @@ export interface ModerationActions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Moderation Actions Hook
|
||||
* Hook for moderation action handlers
|
||||
* Extracted from useModerationQueueManager for better separation of concerns
|
||||
*
|
||||
* 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
|
||||
* @param config - Configuration object with user, callbacks, and dependencies
|
||||
* @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)
|
||||
@@ -291,30 +263,6 @@ 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) });
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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';
|
||||
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 as 'approved' | 'rejected',
|
||||
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 === 'resolved' ? 'reviewed' : 'dismissed') as 'reviewed' | 'dismissed',
|
||||
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: 'flagged' as const,
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* usePrefetchNextPage Hook
|
||||
*
|
||||
* Prefetches the next page of paginated data for instant navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Automatically prefetches when user is on a page with more data
|
||||
* - Short staleTime (2 minutes) prevents over-caching
|
||||
* - Generic implementation works with any paginated data
|
||||
*
|
||||
* @param baseQueryKey - Base query key array for the paginated query
|
||||
* @param currentPage - Current page number
|
||||
* @param hasNextPage - Boolean indicating if there is a next page
|
||||
* @param queryFn - Function to fetch the next page
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* usePrefetchNextPage(
|
||||
* queryKeys.parks.all(),
|
||||
* currentPage,
|
||||
* hasNextPage,
|
||||
* (page) => fetchParks(page)
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function usePrefetchNextPage<T>(
|
||||
baseQueryKey: readonly unknown[],
|
||||
currentPage: number,
|
||||
hasNextPage: boolean,
|
||||
queryFn: (page: number) => Promise<T>
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage) return;
|
||||
|
||||
// Prefetch next page
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: [...baseQueryKey, currentPage + 1],
|
||||
queryFn: () => queryFn(currentPage + 1),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
}, [currentPage, hasNextPage, baseQueryKey, queryFn, queryClient]);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Entity Photos Hook
|
||||
*
|
||||
* 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, UseQueryResult, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
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',
|
||||
enableRealtime = false // New parameter for opt-in real-time updates
|
||||
): UseQueryResult<EntityPhoto[]> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = 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')
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
.order('created_at', { ascending: sortBy === 'oldest' });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const result = data?.map((photo) => ({
|
||||
id: photo.id,
|
||||
url: photo.cloudflare_image_url,
|
||||
caption: photo.caption || undefined,
|
||||
title: photo.title || undefined,
|
||||
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,
|
||||
gcTime: 15 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Real-time subscription for photo uploads (opt-in)
|
||||
useEffect(() => {
|
||||
if (!enableRealtime || !entityType || !entityId) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`photos-${entityType}-${entityId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'photos',
|
||||
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`,
|
||||
},
|
||||
(payload) => {
|
||||
console.log('📸 New photo uploaded:', payload.new);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.photos.entity(entityType, entityId)
|
||||
});
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [enableRealtime, entityType, entityId, queryClient, sortBy]);
|
||||
|
||||
return query;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface UnblockUserParams {
|
||||
blockId: string;
|
||||
blockedUserId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for user blocking/unblocking mutations
|
||||
* Provides: unblock user with automatic audit logging and cache invalidation
|
||||
*/
|
||||
export function useBlockUserMutation() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateAuditLogs } = useQueryInvalidation();
|
||||
|
||||
const unblockUser = useMutation({
|
||||
mutationFn: async ({ blockId, blockedUserId, username }: UnblockUserParams) => {
|
||||
if (!user) throw new Error('Authentication required');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_blocks')
|
||||
.delete()
|
||||
.eq('id', blockId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log to audit trail
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'user_unblocked',
|
||||
changes: JSON.parse(JSON.stringify({
|
||||
blocked_user_id: blockedUserId,
|
||||
username,
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
}]);
|
||||
|
||||
return { blockedUserId, username };
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Error", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, { username }) => {
|
||||
// Invalidate blocked users cache
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-users'] });
|
||||
invalidateAuditLogs();
|
||||
|
||||
toast.success("User Unblocked", {
|
||||
description: `You have unblocked @${username}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
unblockUser,
|
||||
isUnblocking: unblockUser.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { UserBlock } from '@/types/privacy';
|
||||
|
||||
/**
|
||||
* Hook to fetch blocked users for the authenticated user
|
||||
* Provides: automatic caching, refetch on window focus, and loading states
|
||||
*/
|
||||
export function useBlockedUsers(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['blocked-users', userId],
|
||||
queryFn: async () => {
|
||||
if (!userId) throw new Error('User ID required');
|
||||
|
||||
// Fetch blocked user IDs
|
||||
const { data: blocks, error: blocksError } = await supabase
|
||||
.from('user_blocks')
|
||||
.select('id, blocked_id, reason, created_at')
|
||||
.eq('blocker_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (blocksError) {
|
||||
logger.error('Failed to fetch user blocks', {
|
||||
userId,
|
||||
action: 'fetch_blocked_users',
|
||||
error: blocksError.message,
|
||||
errorCode: blocksError.code
|
||||
});
|
||||
throw blocksError;
|
||||
}
|
||||
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch profile information for blocked users
|
||||
const blockedIds = blocks.map(b => b.blocked_id);
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('profiles')
|
||||
.select('user_id, username, display_name, avatar_url')
|
||||
.in('user_id', blockedIds);
|
||||
|
||||
if (profilesError) {
|
||||
logger.error('Failed to fetch blocked user profiles', {
|
||||
userId,
|
||||
action: 'fetch_blocked_user_profiles',
|
||||
error: profilesError.message,
|
||||
errorCode: profilesError.code
|
||||
});
|
||||
throw profilesError;
|
||||
}
|
||||
|
||||
// Combine the data
|
||||
const blockedUsersWithProfiles: UserBlock[] = blocks.map(block => ({
|
||||
...block,
|
||||
blocker_id: userId,
|
||||
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
|
||||
}));
|
||||
|
||||
logger.info('Blocked users fetched successfully', {
|
||||
userId,
|
||||
action: 'fetch_blocked_users',
|
||||
count: blockedUsersWithProfiles.length
|
||||
});
|
||||
|
||||
return blockedUsersWithProfiles;
|
||||
},
|
||||
enabled: !!userId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { PrivacyFormData } from '@/types/privacy';
|
||||
|
||||
/**
|
||||
* Hook for privacy settings mutations
|
||||
*
|
||||
* Features:
|
||||
* - Update privacy level and visibility settings
|
||||
* - Optimistic updates with rollback on error
|
||||
* - Automatic audit trail logging
|
||||
* - Smart cache invalidation affecting search visibility
|
||||
* - Updates both profile and user_preferences tables
|
||||
*
|
||||
* Modifies:
|
||||
* - `profiles` table (privacy_level, show_pronouns)
|
||||
* - `user_preferences` table (privacy_settings)
|
||||
* - `profile_audit_log` table (audit trail)
|
||||
*
|
||||
* Cache Invalidation:
|
||||
* - User profile data (`invalidateUserProfile`)
|
||||
* - Audit logs (`invalidateAuditLogs`)
|
||||
* - User search results (`invalidateUserSearch`) - privacy affects visibility
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { updatePrivacy, isUpdating } = usePrivacyMutations();
|
||||
*
|
||||
* updatePrivacy.mutate({
|
||||
* privacy_level: 'private',
|
||||
* show_pronouns: false,
|
||||
* show_email: false,
|
||||
* show_location: true
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function usePrivacyMutations() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
invalidateUserProfile,
|
||||
invalidateAuditLogs,
|
||||
invalidateUserSearch
|
||||
} = useQueryInvalidation();
|
||||
|
||||
const updatePrivacy = useMutation({
|
||||
mutationFn: async (data: PrivacyFormData) => {
|
||||
if (!user) throw new Error('Authentication required');
|
||||
|
||||
// Update profile privacy settings
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
privacy_level: data.privacy_level,
|
||||
show_pronouns: data.show_pronouns,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (profileError) throw profileError;
|
||||
|
||||
// Extract privacy settings (exclude profile fields)
|
||||
const { privacy_level, show_pronouns, ...privacySettings } = data;
|
||||
|
||||
// Update user preferences
|
||||
const { error: prefsError } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert([{
|
||||
user_id: user.id,
|
||||
privacy_settings: privacySettings,
|
||||
updated_at: new Date().toISOString()
|
||||
}]);
|
||||
|
||||
if (prefsError) throw prefsError;
|
||||
|
||||
// Log to audit trail
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'privacy_settings_updated',
|
||||
changes: {
|
||||
updated: privacySettings,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}]);
|
||||
|
||||
return { privacySettings };
|
||||
},
|
||||
onMutate: async (newData) => {
|
||||
// Cancel outgoing queries
|
||||
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
|
||||
|
||||
// Snapshot current value
|
||||
interface Profile {
|
||||
privacy_level?: string;
|
||||
show_pronouns?: boolean;
|
||||
}
|
||||
|
||||
const previousProfile = queryClient.getQueryData<Profile>(['profile', user?.id]);
|
||||
|
||||
// Optimistically update cache
|
||||
if (previousProfile) {
|
||||
queryClient.setQueryData<Profile>(['profile', user?.id], (old) =>
|
||||
old ? {
|
||||
...old,
|
||||
privacy_level: newData.privacy_level,
|
||||
show_pronouns: newData.show_pronouns,
|
||||
} : old
|
||||
);
|
||||
}
|
||||
|
||||
return { previousProfile };
|
||||
},
|
||||
onError: (error: unknown, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousProfile && user) {
|
||||
queryClient.setQueryData(['profile', user.id], context.previousProfile);
|
||||
}
|
||||
|
||||
toast.error("Update Failed", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate all related caches
|
||||
if (user) {
|
||||
invalidateUserProfile(user.id);
|
||||
invalidateAuditLogs(user.id);
|
||||
invalidateUserSearch(); // Privacy affects search visibility
|
||||
}
|
||||
|
||||
toast.success("Privacy Updated", {
|
||||
description: "Your privacy preferences have been successfully saved.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updatePrivacy,
|
||||
isUpdating: updatePrivacy.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Profile Activity Hook
|
||||
*
|
||||
* 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, 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<ActivityItem[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.profile.activity(userId || '', isOwnProfile, isModerator),
|
||||
queryFn: async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
if (!userId) return [];
|
||||
|
||||
// Check privacy settings first
|
||||
const { data: preferences } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('privacy_settings')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
const privacySettings = preferences?.privacy_settings as { activity_visibility?: string } | null;
|
||||
const activityVisibility = privacySettings?.activity_visibility || 'public';
|
||||
|
||||
if (activityVisibility !== 'public' && !isOwnProfile && !isModerator) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build queries with conditional filters
|
||||
const reviewsQuery = supabase.from('reviews')
|
||||
.select('id, rating, title, created_at, moderation_status, park_id, ride_id, parks(name, slug), rides(name, slug, parks(name, slug))')
|
||||
.eq('user_id', userId);
|
||||
if (!isOwnProfile && !isModerator) {
|
||||
reviewsQuery.eq('moderation_status', 'approved');
|
||||
}
|
||||
|
||||
const submissionsQuery = supabase.from('content_submissions')
|
||||
.select('id, submission_type, content, status, created_at')
|
||||
.eq('user_id', userId);
|
||||
if (!isOwnProfile && !isModerator) {
|
||||
submissionsQuery.eq('status', 'approved');
|
||||
}
|
||||
|
||||
const rankingsQuery = supabase.from('user_top_lists')
|
||||
.select('id, title, description, list_type, created_at')
|
||||
.eq('user_id', userId);
|
||||
if (!isOwnProfile) {
|
||||
rankingsQuery.eq('is_public', true);
|
||||
}
|
||||
|
||||
// Fetch all activity types in parallel
|
||||
const [reviews, credits, submissions, rankings] = await Promise.all([
|
||||
reviewsQuery.order('created_at', { ascending: false }).limit(10).then(res => res.data || []),
|
||||
supabase.from('user_ride_credits')
|
||||
.select('id, ride_count, first_ride_date, created_at, rides(name, slug, parks(name, slug))')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
.then(res => res.data || []),
|
||||
submissionsQuery.order('created_at', { ascending: false }).limit(10).then(res => res.data || []),
|
||||
rankingsQuery.order('created_at', { ascending: false }).limit(10).then(res => res.data || [])
|
||||
]);
|
||||
|
||||
// Enrich photo submissions in batch
|
||||
const photoSubmissions = submissions.filter(s => s.submission_type === 'photo');
|
||||
const photoSubmissionIds = photoSubmissions.map(s => s.id);
|
||||
|
||||
if (photoSubmissionIds.length > 0) {
|
||||
// Batch fetch photo submission data
|
||||
const { data: photoSubs } = await supabase
|
||||
.from('photo_submissions')
|
||||
.select('id, submission_id, entity_type, entity_id')
|
||||
.in('submission_id', photoSubmissionIds);
|
||||
|
||||
if (photoSubs) {
|
||||
// Batch fetch photo items
|
||||
const photoSubIds = photoSubs.map(ps => ps.id);
|
||||
const { data: photoItems } = await supabase
|
||||
.from('photo_submission_items')
|
||||
.select('photo_submission_id, cloudflare_image_url')
|
||||
.in('photo_submission_id', photoSubIds)
|
||||
.order('order_index', { ascending: true });
|
||||
|
||||
// Group entity IDs by type for batch fetching
|
||||
const parkIds = photoSubs.filter(ps => ps.entity_type === 'park').map(ps => ps.entity_id);
|
||||
const rideIds = photoSubs.filter(ps => ps.entity_type === 'ride').map(ps => ps.entity_id);
|
||||
|
||||
// Batch fetch entities
|
||||
const [parks, rides] = await Promise.all([
|
||||
parkIds.length ? supabase.from('parks').select('id, name, slug').in('id', parkIds).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 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<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)) {
|
||||
photoItemsMap.set(item.photo_submission_id, []);
|
||||
}
|
||||
photoItemsMap.get(item.photo_submission_id)!.push(item);
|
||||
});
|
||||
|
||||
interface DatabaseEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const entityMap = new Map<string, EntityData>([
|
||||
...parks.map((p: DatabaseEntity): [string, EntityData] => [p.id, p]),
|
||||
...rides.map((r: DatabaseEntity): [string, EntityData] => [r.id, r])
|
||||
]);
|
||||
|
||||
interface PhotoSubmissionWithAllFields {
|
||||
id: string;
|
||||
photo_count?: number;
|
||||
photo_preview?: string;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
// Enrich submissions
|
||||
photoSubmissions.forEach((sub: PhotoSubmissionWithAllFields) => {
|
||||
const photoSub = photoSubMap.get(sub.id);
|
||||
if (photoSub) {
|
||||
const items = photoItemsMap.get(photoSub.id) || [];
|
||||
sub.photo_count = items.length;
|
||||
sub.photo_preview = items[0]?.cloudflare_image_url;
|
||||
sub.entity_type = photoSub.entity_type;
|
||||
sub.entity_id = photoSub.entity_id;
|
||||
|
||||
const entity = entityMap.get(photoSub.entity_id);
|
||||
if (entity) {
|
||||
sub.content = {
|
||||
...(typeof sub.content === 'object' ? sub.content : {}),
|
||||
entity_name: entity.name,
|
||||
entity_slug: entity.slug,
|
||||
...(entity.parks && { park_name: entity.parks.name, park_slug: entity.parks.slug })
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Combine and sort
|
||||
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 })),
|
||||
...rankings.map(r => ({ ...r, type: 'ranking' as const }))
|
||||
].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,
|
||||
staleTime: 3 * 60 * 1000, // 3 minutes - activity updates frequently
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { LocationFormData } from '@/types/location';
|
||||
|
||||
/**
|
||||
* Hook for profile location mutations
|
||||
* Provides: location updates with automatic audit logging and cache invalidation
|
||||
*/
|
||||
export function useProfileLocationMutation() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
invalidateUserProfile,
|
||||
invalidateProfileStats,
|
||||
invalidateAuditLogs
|
||||
} = useQueryInvalidation();
|
||||
|
||||
const updateLocation = useMutation({
|
||||
mutationFn: async (data: LocationFormData) => {
|
||||
if (!user) throw new Error('Authentication required');
|
||||
|
||||
const previousProfile = {
|
||||
personal_location: data.personal_location,
|
||||
home_park_id: data.home_park_id,
|
||||
timezone: data.timezone,
|
||||
preferred_language: data.preferred_language,
|
||||
preferred_pronouns: data.preferred_pronouns
|
||||
};
|
||||
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
preferred_pronouns: data.preferred_pronouns || null,
|
||||
timezone: data.timezone,
|
||||
preferred_language: data.preferred_language,
|
||||
personal_location: data.personal_location || null,
|
||||
home_park_id: data.home_park_id || null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (profileError) throw profileError;
|
||||
|
||||
// Log to audit trail
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'location_info_updated',
|
||||
changes: JSON.parse(JSON.stringify({
|
||||
previous: { profile: previousProfile },
|
||||
updated: { profile: data },
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
}]);
|
||||
|
||||
return data;
|
||||
},
|
||||
onMutate: async (newData) => {
|
||||
// Cancel outgoing queries
|
||||
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
|
||||
|
||||
// Snapshot current value
|
||||
interface Profile {
|
||||
personal_location?: string;
|
||||
home_park_id?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
const previousProfile = queryClient.getQueryData<Profile>(['profile', user?.id]);
|
||||
|
||||
// Optimistically update cache
|
||||
if (previousProfile) {
|
||||
queryClient.setQueryData<Profile>(['profile', user?.id], (old) =>
|
||||
old ? {
|
||||
...old,
|
||||
personal_location: newData.personal_location,
|
||||
home_park_id: newData.home_park_id,
|
||||
timezone: newData.timezone,
|
||||
preferred_language: newData.preferred_language,
|
||||
preferred_pronouns: newData.preferred_pronouns,
|
||||
} : old
|
||||
);
|
||||
}
|
||||
|
||||
return { previousProfile };
|
||||
},
|
||||
onError: (error: unknown, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousProfile && user) {
|
||||
queryClient.setQueryData(['profile', user.id], context.previousProfile);
|
||||
}
|
||||
|
||||
toast.error("Update Failed", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (user) {
|
||||
invalidateUserProfile(user.id);
|
||||
invalidateProfileStats(user.id); // Location affects stats display
|
||||
invalidateAuditLogs(user.id);
|
||||
}
|
||||
|
||||
toast.success("Settings Saved", {
|
||||
description: "Your location and personal information have been updated.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateLocation,
|
||||
isUpdating: updateLocation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Profile Stats Hook
|
||||
*
|
||||
* Fetches calculated user statistics (rides, coasters, parks).
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useProfileStats(userId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.profile.stats(userId || ''),
|
||||
queryFn: async () => {
|
||||
if (!userId) return { rideCount: 0, coasterCount: 0, parkCount: 0 };
|
||||
|
||||
const { data: ridesData } = await supabase
|
||||
.from('user_ride_credits')
|
||||
.select('ride_count, rides!inner(category, park_id)')
|
||||
.eq('user_id', userId);
|
||||
|
||||
const totalRides = ridesData?.reduce((sum, credit) => sum + (credit.ride_count || 0), 0) || 0;
|
||||
const coasterRides = ridesData?.filter(credit => credit.rides?.category === 'roller_coaster') || [];
|
||||
const uniqueCoasters = new Set(coasterRides.map(credit => credit.rides));
|
||||
const coasterCount = uniqueCoasters.size;
|
||||
const parkRides = ridesData?.map(credit => credit.rides?.park_id).filter(Boolean) || [];
|
||||
const uniqueParks = new Set(parkRides);
|
||||
const parkCount = uniqueParks.size;
|
||||
|
||||
return { rideCount: totalRides, coasterCount, parkCount };
|
||||
},
|
||||
enabled: !!userId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 15 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
|
||||
interface ProfileUpdateParams {
|
||||
userId: string;
|
||||
updates: {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
location_id?: string | null;
|
||||
website?: string | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for profile update mutations
|
||||
*
|
||||
* Features:
|
||||
* - Optimistic updates for instant UI feedback
|
||||
* - Automatic rollback on error
|
||||
* - Smart cache invalidation (profile, stats, activity)
|
||||
* - Conditional search invalidation when name changes
|
||||
* - Comprehensive error handling with toast notifications
|
||||
*
|
||||
* Modifies:
|
||||
* - `profiles` table
|
||||
*
|
||||
* Cache Invalidation:
|
||||
* - User profile data (`invalidateUserProfile`)
|
||||
* - Profile stats (`invalidateProfileStats`)
|
||||
* - Profile activity feed (`invalidateProfileActivity`)
|
||||
* - User search results if name changed (`invalidateUserSearch`)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const mutation = useProfileUpdateMutation();
|
||||
*
|
||||
* mutation.mutate({
|
||||
* userId: user.id,
|
||||
* updates: {
|
||||
* display_name: 'New Name',
|
||||
* bio: 'Updated bio',
|
||||
* website: 'https://example.com'
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useProfileUpdateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
invalidateUserProfile,
|
||||
invalidateProfileStats,
|
||||
invalidateProfileActivity,
|
||||
invalidateUserSearch
|
||||
} = useQueryInvalidation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId, updates }: ProfileUpdateParams) => {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update(updates)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onMutate: async ({ userId, updates }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['profile', userId] });
|
||||
|
||||
interface Profile {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
location_id?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
// Snapshot previous value
|
||||
const previousProfile = queryClient.getQueryData<Profile>(['profile', userId]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<Profile>(['profile', userId], (old) =>
|
||||
old ? { ...old, ...updates } : old
|
||||
);
|
||||
|
||||
return { previousProfile, userId };
|
||||
},
|
||||
onError: (error: unknown, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousProfile) {
|
||||
queryClient.setQueryData(['profile', context.userId], context.previousProfile);
|
||||
}
|
||||
|
||||
toast.error("Update Failed", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, { userId, updates }) => {
|
||||
// Invalidate all related caches
|
||||
invalidateUserProfile(userId);
|
||||
invalidateProfileStats(userId);
|
||||
invalidateProfileActivity(userId);
|
||||
|
||||
// If display name or username changed, invalidate user search results
|
||||
if (updates.display_name || updates.username) {
|
||||
invalidateUserSearch();
|
||||
}
|
||||
|
||||
toast.success("Profile Updated", {
|
||||
description: "Your changes have been saved.",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface ReportActionParams {
|
||||
reportId: string;
|
||||
action: 'reviewed' | 'dismissed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for report action mutations
|
||||
* Provides: report resolution/dismissal with automatic audit logging and cache invalidation
|
||||
*/
|
||||
export function useReportActionMutation() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateModerationQueue, invalidateModerationStats, invalidateAuditLogs } = useQueryInvalidation();
|
||||
|
||||
const resolveReport = useMutation({
|
||||
mutationFn: async ({ reportId, action }: ReportActionParams) => {
|
||||
if (!user) throw new Error('Authentication required');
|
||||
|
||||
// Fetch full report details for audit log
|
||||
const { data: reportData } = await supabase
|
||||
.from('reports')
|
||||
.select('reporter_id, reported_entity_type, reported_entity_id, reason')
|
||||
.eq('id', reportId)
|
||||
.single();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('reports')
|
||||
.update({
|
||||
status: action,
|
||||
reviewed_by: user.id,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', reportId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log audit trail for report resolution
|
||||
if (reportData) {
|
||||
try {
|
||||
await supabase.rpc('log_admin_action', {
|
||||
_admin_user_id: user.id,
|
||||
_target_user_id: reportData.reporter_id,
|
||||
_action: action === 'reviewed' ? 'report_resolved' : 'report_dismissed',
|
||||
_details: {
|
||||
report_id: reportId,
|
||||
reported_entity_type: reportData.reported_entity_type,
|
||||
reported_entity_id: reportData.reported_entity_id,
|
||||
report_reason: reportData.reason,
|
||||
action: action
|
||||
}
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error('Failed to log report action audit:', auditError);
|
||||
}
|
||||
}
|
||||
|
||||
return { action, reportData };
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Error", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, { action }) => {
|
||||
invalidateModerationQueue();
|
||||
invalidateModerationStats();
|
||||
invalidateAuditLogs();
|
||||
|
||||
toast.success(`Report ${action}`, {
|
||||
description: `The report has been marked as ${action}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
resolveReport,
|
||||
isResolving: resolveReport.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
|
||||
interface ReportParams {
|
||||
entityType: 'review' | 'profile' | 'content_submission';
|
||||
entityId: string;
|
||||
reportType: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for content reporting mutations
|
||||
*
|
||||
* Features:
|
||||
* - Submit reports for review/profile/submission abuse
|
||||
* - Automatic moderation queue invalidation
|
||||
* - Audit logging via database trigger
|
||||
* - User-friendly success/error notifications
|
||||
*
|
||||
* Modifies:
|
||||
* - `reports` table
|
||||
*
|
||||
* Cache Invalidation:
|
||||
* - Moderation queue (`invalidateModerationQueue`)
|
||||
* - Moderation stats (`invalidateModerationStats`)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const mutation = useReportMutation();
|
||||
*
|
||||
* mutation.mutate({
|
||||
* entityType: 'review',
|
||||
* entityId: 'review-123',
|
||||
* reportType: 'spam',
|
||||
* reason: 'This is clearly spam content'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useReportMutation() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateModerationQueue, invalidateModerationStats } = useQueryInvalidation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ entityType, entityId, reportType, reason }: ReportParams) => {
|
||||
if (!user) throw new Error('Authentication required');
|
||||
|
||||
const { error } = await supabase.from('reports').insert({
|
||||
reporter_id: user.id,
|
||||
reported_entity_type: entityType,
|
||||
reported_entity_id: entityId,
|
||||
report_type: reportType,
|
||||
reason: reason?.trim() || null,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateModerationQueue();
|
||||
invalidateModerationStats();
|
||||
|
||||
toast.success("Report Submitted", {
|
||||
description: "Thank you for your report. We'll review it shortly.",
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Error", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch reviews for a specific entity (park or ride)
|
||||
*/
|
||||
export function useEntityReviews(
|
||||
entityType: 'park' | 'ride',
|
||||
entityId: string | undefined,
|
||||
enabled = true,
|
||||
enableRealtime = false // New parameter for opt-in real-time updates
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
export function useEntityReviews(entityType: 'park' | 'ride', entityId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.reviews.entity(entityType, entityId || ''),
|
||||
queryFn: async () => {
|
||||
if (!entityId) return [];
|
||||
@@ -43,34 +35,4 @@ export function useEntityReviews(
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Real-time subscription for new reviews (opt-in)
|
||||
useEffect(() => {
|
||||
if (!enableRealtime || !entityId || !enabled) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`reviews-${entityType}-${entityId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'reviews',
|
||||
filter: `${entityType}_id=eq.${entityId},moderation_status=eq.approved`,
|
||||
},
|
||||
(payload) => {
|
||||
console.log('⭐ New review posted:', payload.new);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.reviews.entity(entityType, entityId)
|
||||
});
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [enableRealtime, entityType, entityId, enabled, queryClient]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Model Rides Hook
|
||||
*
|
||||
* Fetches rides using a specific ride model with caching.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useModelRides(modelId: string | undefined, limit?: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.rideModels.rides(modelId || '', limit),
|
||||
queryFn: async () => {
|
||||
if (!modelId) return [];
|
||||
|
||||
let query = supabase
|
||||
.from('rides')
|
||||
.select(`
|
||||
*,
|
||||
park:parks!inner(name, slug, location:locations(*)),
|
||||
manufacturer:companies!rides_manufacturer_id_fkey(*),
|
||||
ride_model:ride_models(id, name, slug, manufacturer_id, category)
|
||||
`)
|
||||
.eq('ride_model_id', modelId)
|
||||
.order('name');
|
||||
|
||||
if (limit) query = query.limit(limit);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
},
|
||||
enabled: !!modelId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 15 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Model Statistics Hook
|
||||
*
|
||||
* Fetches ride model statistics (ride count, photo count) with parallel queries.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useModelStatistics(modelId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.rideModels.statistics(modelId || ''),
|
||||
queryFn: async () => {
|
||||
if (!modelId) return { rideCount: 0, photoCount: 0 };
|
||||
|
||||
const [ridesResult, photosResult] = await Promise.all([
|
||||
supabase.from('rides').select('id', { count: 'exact', head: true }).eq('ride_model_id', modelId),
|
||||
supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'ride_model').eq('entity_id', modelId)
|
||||
]);
|
||||
|
||||
return {
|
||||
rideCount: ridesResult.count || 0,
|
||||
photoCount: photosResult.count || 0
|
||||
};
|
||||
},
|
||||
enabled: !!modelId,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
gcTime: 20 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Ride Model Detail Hook
|
||||
*
|
||||
* Fetches ride model and manufacturer data with caching.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useRideModelDetail(
|
||||
manufacturerSlug: string | undefined,
|
||||
modelSlug: string | undefined
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.rideModels.detail(manufacturerSlug || '', modelSlug || ''),
|
||||
queryFn: async () => {
|
||||
if (!manufacturerSlug || !modelSlug) return null;
|
||||
|
||||
// Fetch manufacturer first
|
||||
const { data: manufacturer, error: mfgError } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('slug', manufacturerSlug)
|
||||
.eq('company_type', 'manufacturer')
|
||||
.maybeSingle();
|
||||
|
||||
if (mfgError) throw mfgError;
|
||||
if (!manufacturer) return null;
|
||||
|
||||
// Fetch ride model
|
||||
const { data: model, error: modelError } = await supabase
|
||||
.from('ride_models')
|
||||
.select('*')
|
||||
.eq('slug', modelSlug)
|
||||
.eq('manufacturer_id', manufacturer.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (modelError) throw modelError;
|
||||
|
||||
return model ? { model, manufacturer } : null;
|
||||
},
|
||||
enabled: !!manufacturerSlug && !!modelSlug,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 15 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
|
||||
interface ReorderCreditParams {
|
||||
creditId: string;
|
||||
newPosition: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for ride credits mutations
|
||||
* Provides: reorder ride credits with automatic cache invalidation
|
||||
*/
|
||||
export function useRideCreditsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateRideDetail } = useQueryInvalidation();
|
||||
|
||||
const reorderCredit = useMutation({
|
||||
mutationFn: async ({ creditId, newPosition }: ReorderCreditParams) => {
|
||||
const { error } = await supabase.rpc('reorder_ride_credit', {
|
||||
p_credit_id: creditId,
|
||||
p_new_position: newPosition
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return { creditId, newPosition };
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Reorder Failed", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate ride credits queries
|
||||
queryClient.invalidateQueries({ queryKey: ['ride-credits'] });
|
||||
|
||||
toast.success("Order Updated", {
|
||||
description: "Ride credit order has been saved.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reorderCredit,
|
||||
isReordering: reorderCredit.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { notificationService } from '@/lib/notificationService';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
interface EmailChangeParams {
|
||||
newEmail: string;
|
||||
currentEmail: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for email change mutations
|
||||
* Provides: email changes with automatic audit logging and cache invalidation
|
||||
*/
|
||||
export function useEmailChangeMutation() {
|
||||
const { invalidateAuditLogs } = useQueryInvalidation();
|
||||
|
||||
const changeEmail = useMutation({
|
||||
mutationFn: async ({ newEmail, currentEmail, userId }: EmailChangeParams) => {
|
||||
// Update email address
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: newEmail
|
||||
});
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Log the email change attempt
|
||||
await supabase.from('admin_audit_log').insert({
|
||||
admin_user_id: userId,
|
||||
target_user_id: userId,
|
||||
action: 'email_change_initiated',
|
||||
details: {
|
||||
old_email: currentEmail,
|
||||
new_email: newEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
// Send security notifications (non-blocking)
|
||||
if (notificationService.isEnabled()) {
|
||||
notificationService.trigger({
|
||||
workflowId: 'security-alert',
|
||||
subscriberId: userId,
|
||||
payload: {
|
||||
alert_type: 'email_change_initiated',
|
||||
old_email: currentEmail,
|
||||
new_email: newEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}).catch(error => {
|
||||
logger.error('Failed to send security notification', {
|
||||
userId,
|
||||
action: 'email_change_notification',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { newEmail };
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Update Failed", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, { userId }) => {
|
||||
invalidateAuditLogs(userId);
|
||||
|
||||
toast.success("Email Change Initiated", {
|
||||
description: "Check both email addresses for confirmation links.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
changeEmail,
|
||||
isChanging: changeEmail.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export interface EmailChangeStatus {
|
||||
has_pending_change: boolean;
|
||||
current_email?: string;
|
||||
new_email?: string;
|
||||
current_email_verified?: boolean;
|
||||
new_email_verified?: boolean;
|
||||
change_sent_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query email change verification status
|
||||
* Provides: automatic polling every 30 seconds, cache management, loading states
|
||||
*/
|
||||
export function useEmailChangeStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['email-change-status'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_email_change_status');
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to fetch email change status', {
|
||||
action: 'fetch_email_change_status',
|
||||
error: error.message,
|
||||
errorCode: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as EmailChangeStatus;
|
||||
},
|
||||
refetchInterval: 30000, // Poll every 30 seconds
|
||||
staleTime: 15000, // 15 seconds
|
||||
});
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
interface PasswordUpdateParams {
|
||||
password: string;
|
||||
hasMFA: boolean;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for password update mutations
|
||||
* Provides: password updates with automatic audit logging and cache invalidation
|
||||
*/
|
||||
export function usePasswordUpdateMutation() {
|
||||
const { invalidateAuditLogs } = useQueryInvalidation();
|
||||
|
||||
const updatePassword = useMutation({
|
||||
mutationFn: async ({ password, hasMFA, userId }: PasswordUpdateParams) => {
|
||||
// Update password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password
|
||||
});
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Log audit trail
|
||||
await supabase.from('admin_audit_log').insert({
|
||||
admin_user_id: userId,
|
||||
target_user_id: userId,
|
||||
action: 'password_changed',
|
||||
details: {
|
||||
timestamp: new Date().toISOString(),
|
||||
method: hasMFA ? 'password_with_mfa' : 'password_only',
|
||||
user_agent: navigator.userAgent
|
||||
}
|
||||
});
|
||||
|
||||
// Send security notification (non-blocking)
|
||||
try {
|
||||
await invokeWithTracking(
|
||||
'trigger-notification',
|
||||
{
|
||||
workflowId: 'security-alert',
|
||||
subscriberId: userId,
|
||||
payload: {
|
||||
alert_type: 'password_changed',
|
||||
timestamp: new Date().toISOString(),
|
||||
device: navigator.userAgent.split(' ')[0]
|
||||
}
|
||||
},
|
||||
userId
|
||||
);
|
||||
} catch (notifError) {
|
||||
logger.error('Failed to send password change notification', {
|
||||
userId,
|
||||
action: 'password_change_notification',
|
||||
error: getErrorMessage(notifError)
|
||||
});
|
||||
// Don't fail the password update if notification fails
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Update Failed", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, { userId }) => {
|
||||
invalidateAuditLogs(userId);
|
||||
|
||||
toast.success("Password Updated", {
|
||||
description: "Your password has been successfully changed.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updatePassword,
|
||||
isUpdating: updatePassword.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
|
||||
interface RevokeSessionParams {
|
||||
sessionId: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for session management mutations
|
||||
* Provides: session revocation with automatic cache invalidation
|
||||
*/
|
||||
export function useSecurityMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateSessions, invalidateAuditLogs } = useQueryInvalidation();
|
||||
|
||||
const revokeSession = useMutation({
|
||||
mutationFn: async ({ sessionId }: RevokeSessionParams) => {
|
||||
const { error } = await supabase.rpc('revoke_my_session', {
|
||||
session_id: sessionId
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Error", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, { isCurrent }) => {
|
||||
invalidateSessions();
|
||||
invalidateAuditLogs();
|
||||
|
||||
toast.success("Success", {
|
||||
description: "Session revoked successfully",
|
||||
});
|
||||
|
||||
// Redirect to login if current session was revoked
|
||||
if (isCurrent) {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/auth';
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
revokeSession,
|
||||
isRevoking: revokeSession.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { AuthSession } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Hook to fetch active user sessions
|
||||
* Provides: automatic caching, refetch on window focus, loading states
|
||||
*/
|
||||
export function useSessions(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['sessions', userId],
|
||||
queryFn: async () => {
|
||||
if (!userId) throw new Error('User ID required');
|
||||
|
||||
const { data, error } = await supabase.rpc('get_my_sessions');
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to fetch sessions', {
|
||||
userId,
|
||||
action: 'fetch_sessions',
|
||||
error: error.message,
|
||||
errorCode: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (data as AuthSession[]) || [];
|
||||
},
|
||||
enabled: !!userId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useAuth } from './useAuth';
|
||||
import { useUserRole } from './useUserRole';
|
||||
import { useToast } from './use-toast';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
interface AdminSetting {
|
||||
id: string;
|
||||
@@ -25,7 +24,7 @@ export function useAdminSettings() {
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.admin.settings(),
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_settings')
|
||||
@@ -60,7 +59,7 @@ export function useAdminSettings() {
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.settings() });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
toast({
|
||||
title: "Setting Updated",
|
||||
description: "The setting has been saved successfully.",
|
||||
|
||||
@@ -15,7 +15,7 @@ interface AuthContextType {
|
||||
loading: boolean;
|
||||
pendingEmail: string | null;
|
||||
sessionError: string | null;
|
||||
signOut: (scope?: 'global' | 'local' | 'others') => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
verifySession: () => Promise<boolean>;
|
||||
clearPendingEmail: () => void;
|
||||
checkAalStepUp: () => Promise<CheckAalResult>;
|
||||
@@ -123,24 +123,6 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
||||
await supabase.auth.signOut();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enhanced session monitoring: Proactively refresh tokens before expiry
|
||||
const expiresAt = session.expires_at;
|
||||
if (expiresAt) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const timeUntilExpiry = expiresAt - now;
|
||||
|
||||
// Refresh 5 minutes (300 seconds) before expiry
|
||||
if (timeUntilExpiry < 300 && timeUntilExpiry > 0) {
|
||||
authLog('[Auth] Token expiring soon, refreshing session...');
|
||||
const { error } = await supabase.auth.refreshSession();
|
||||
if (error) {
|
||||
authError('[Auth] Session refresh failed:', error);
|
||||
} else {
|
||||
authLog('[Auth] Session refreshed successfully');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setAal(null);
|
||||
}
|
||||
@@ -236,23 +218,12 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const signOut = async (scope: 'global' | 'local' | 'others' = 'global') => {
|
||||
authLog('[Auth] Signing out with scope:', scope);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut({ scope });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Clear all auth flags (only on global/local sign out)
|
||||
if (scope !== 'others') {
|
||||
clearAllAuthFlags();
|
||||
}
|
||||
|
||||
authLog('[Auth] Sign out successful');
|
||||
} catch (error) {
|
||||
authError('[Auth] Error signing out:', error);
|
||||
throw error;
|
||||
const signOut = async () => {
|
||||
authLog('[Auth] Signing out...');
|
||||
const result = await signOutUser();
|
||||
if (!result.success) {
|
||||
authError('Error signing out:', result.error);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export type AvatarUploadState = {
|
||||
url: string;
|
||||
@@ -15,8 +13,6 @@ export const useAvatarUpload = (
|
||||
initialImageId: string = '',
|
||||
username: string
|
||||
) => {
|
||||
const { user } = useAuth();
|
||||
const { invalidateUserProfile } = useQueryInvalidation();
|
||||
const [state, setState] = useState<AvatarUploadState>({
|
||||
url: initialUrl,
|
||||
imageId: initialImageId,
|
||||
@@ -52,11 +48,6 @@ export const useAvatarUpload = (
|
||||
setState(prev => ({ ...prev, isUploading: false }));
|
||||
handleSuccess('Avatar updated', 'Your avatar has been successfully updated.');
|
||||
|
||||
// Invalidate user profile cache for instant UI update
|
||||
if (user?.id) {
|
||||
invalidateUserProfile(user.id);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
// Rollback on error
|
||||
@@ -73,7 +64,7 @@ export const useAvatarUpload = (
|
||||
|
||||
return { success: false, error };
|
||||
}
|
||||
}, [username, initialUrl, initialImageId, user?.id, invalidateUserProfile]);
|
||||
}, [username, initialUrl, initialImageId]);
|
||||
|
||||
const resetAvatar = useCallback(() => {
|
||||
setState({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export interface CoasterStat {
|
||||
id: string;
|
||||
@@ -16,7 +15,7 @@ export interface CoasterStat {
|
||||
|
||||
export function useCoasterStats(rideId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.stats.coaster(rideId || ''),
|
||||
queryKey: ['coaster-stats', rideId],
|
||||
queryFn: async () => {
|
||||
if (!rideId) return [];
|
||||
|
||||
|
||||
@@ -93,14 +93,7 @@ export function useEntityVersions(entityType: EntityType, entityId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
interface DatabaseVersion {
|
||||
profiles?: {
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const versionsWithProfiles = (data as DatabaseVersion[] || []).map((v) => ({
|
||||
const versionsWithProfiles = (data || []).map((v: any) => ({
|
||||
...v,
|
||||
profiles: v.profiles || {
|
||||
username: 'Unknown',
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch public Novu settings accessible to all authenticated users
|
||||
*/
|
||||
export function usePublicNovuSettings() {
|
||||
const { data: settings, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.settings.publicNovu(),
|
||||
queryKey: ['public-novu-settings'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_settings')
|
||||
|
||||
@@ -43,7 +43,6 @@ export function useRequireMFA() {
|
||||
isEnrolled,
|
||||
needsEnrollment: requiresMFA && !isEnrolled,
|
||||
needsVerification,
|
||||
isBlocked: requiresMFA && (!isEnrolled || (isEnrolled && aal === 'aal1')), // Convenience flag
|
||||
aal,
|
||||
loading: loading || roleLoading,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
|
||||
const [filters, setFilters] = useState<RideCreditFilters>({});
|
||||
const debouncedSearchQuery = useDebounce(filters.searchQuery || '', 300);
|
||||
|
||||
const updateFilter = useCallback((key: keyof RideCreditFilters, value: RideCreditFilters[typeof key]) => {
|
||||
const updateFilter = useCallback((key: keyof RideCreditFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type ValidRole = 'admin' | 'moderator' | 'user';
|
||||
|
||||
interface GrantRoleParams {
|
||||
userId: string;
|
||||
role: ValidRole;
|
||||
}
|
||||
|
||||
interface RevokeRoleParams {
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
interface UserWithRoles {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useRoleMutations Hook
|
||||
*
|
||||
* Provides TanStack Query mutations for granting and revoking user roles
|
||||
* with optimistic updates for instant UI feedback.
|
||||
*
|
||||
* Features:
|
||||
* - Optimistic updates for immediate UI response
|
||||
* - Automatic cache invalidation on success
|
||||
* - Error handling with rollback
|
||||
* - Toast notifications
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { grantRole, revokeRole } = useRoleMutations();
|
||||
*
|
||||
* grantRole.mutate({ userId: 'user-id', role: 'moderator' });
|
||||
* revokeRole.mutate({ roleId: 'role-id' });
|
||||
* ```
|
||||
*/
|
||||
export function useRoleMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const grantRole = useMutation({
|
||||
mutationFn: async ({ userId, role }: GrantRoleParams) => {
|
||||
const { data, error } = await supabase
|
||||
.from('user_roles')
|
||||
.insert([{ user_id: userId, role }])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onMutate: async ({ userId, role }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.users.roles() });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousUsers = queryClient.getQueryData<UserWithRoles[]>(queryKeys.users.roles());
|
||||
|
||||
// Optimistically update cache - add role to user
|
||||
queryClient.setQueryData<UserWithRoles[]>(queryKeys.users.roles(), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((user) =>
|
||||
user.user_id === userId
|
||||
? { ...user, role } // Optimistically assign role
|
||||
: user
|
||||
);
|
||||
});
|
||||
|
||||
return { previousUsers };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousUsers) {
|
||||
queryClient.setQueryData(queryKeys.users.roles(), context.previousUsers);
|
||||
}
|
||||
toast.error(`Failed to grant role: ${error.message}`);
|
||||
},
|
||||
onSuccess: (data, { role }) => {
|
||||
toast.success(`Role ${role} granted successfully`);
|
||||
},
|
||||
onSettled: () => {
|
||||
// Refetch to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.roles() });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-roles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeRole = useMutation({
|
||||
mutationFn: async ({ roleId }: RevokeRoleParams) => {
|
||||
const { error } = await supabase
|
||||
.from('user_roles')
|
||||
.delete()
|
||||
.eq('id', roleId);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onMutate: async ({ roleId }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.users.roles() });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousUsers = queryClient.getQueryData<UserWithRoles[]>(queryKeys.users.roles());
|
||||
|
||||
// Optimistically remove role from cache
|
||||
queryClient.setQueryData<UserWithRoles[]>(queryKeys.users.roles(), (old) => {
|
||||
if (!old) return old;
|
||||
// Remove the user from the list since they no longer have a role
|
||||
return old.filter((user) => user.id !== roleId);
|
||||
});
|
||||
|
||||
return { previousUsers };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousUsers) {
|
||||
queryClient.setQueryData(queryKeys.users.roles(), context.previousUsers);
|
||||
}
|
||||
toast.error(`Failed to revoke role: ${error.message}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Role revoked successfully');
|
||||
},
|
||||
onSettled: () => {
|
||||
// Refetch to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.roles() });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-roles'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
grantRole,
|
||||
revokeRole,
|
||||
};
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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