mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:11:12 -05:00
Refactor: Implement API and cache improvements
This commit is contained in:
@@ -98,12 +98,12 @@ if (import.meta.env.DEV) {
|
||||
const cache = queryClient.getQueryCache();
|
||||
const queries = cache.getAll();
|
||||
|
||||
// Remove oldest queries if cache exceeds 150 items
|
||||
if (queries.length > 150) {
|
||||
// Remove oldest queries if cache exceeds 250 items (increased limit)
|
||||
if (queries.length > 250) {
|
||||
const sortedByLastUpdated = queries
|
||||
.sort((a, b) => (a.state.dataUpdatedAt || 0) - (b.state.dataUpdatedAt || 0));
|
||||
|
||||
const toRemove = sortedByLastUpdated.slice(0, queries.length - 100);
|
||||
const toRemove = sortedByLastUpdated.slice(0, queries.length - 200);
|
||||
toRemove.forEach(query => {
|
||||
queryClient.removeQueries({ queryKey: query.queryKey });
|
||||
});
|
||||
|
||||
@@ -19,11 +19,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { useReportMutation } from '@/hooks/reports/useReportMutation';
|
||||
|
||||
interface ReportButtonProps {
|
||||
entityType: 'review' | 'profile' | 'content_submission';
|
||||
@@ -43,49 +40,23 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Cache invalidation for moderation queue
|
||||
const { invalidateModerationQueue, invalidateModerationStats } = useQueryInvalidation();
|
||||
const reportMutation = useReportMutation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = () => {
|
||||
if (!user || !reportType) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
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;
|
||||
|
||||
// Invalidate moderation caches
|
||||
invalidateModerationQueue();
|
||||
invalidateModerationStats();
|
||||
|
||||
toast({
|
||||
title: "Report Submitted",
|
||||
description: "Thank you for your report. We'll review it shortly.",
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
setReportType('');
|
||||
setReason('');
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
reportMutation.mutate(
|
||||
{ entityType, entityId, reportType, reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setReportType('');
|
||||
setReason('');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
@@ -144,10 +115,10 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!reportType || loading}
|
||||
disabled={!reportType || reportMutation.isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
{loading ? 'Submitting...' : 'Submit Report'}
|
||||
{reportMutation.isPending ? 'Submitting...' : 'Submit Report'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,36 +19,42 @@ export function ParkCard({ park }: ParkCardProps) {
|
||||
navigate(`/parks/${park.slug}`);
|
||||
};
|
||||
|
||||
// Prefetch park detail data on hover
|
||||
// Smart prefetch - only if not already cached
|
||||
const handleMouseEnter = () => {
|
||||
// Prefetch park detail page data
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.parks.detail(park.slug),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('parks')
|
||||
.select('*')
|
||||
.eq('slug', park.slug)
|
||||
.single();
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// Check if already cached before prefetching
|
||||
const detailCached = queryClient.getQueryData(queryKeys.parks.detail(park.slug));
|
||||
const photosCached = queryClient.getQueryData(queryKeys.photos.entity('park', park.id));
|
||||
|
||||
// Prefetch park photos (first 10)
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.photos.entity('park', park.id),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('photos')
|
||||
.select('*')
|
||||
.eq('entity_type', 'park')
|
||||
.eq('entity_id', park.id)
|
||||
.limit(10);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
if (!detailCached) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.parks.detail(park.slug),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('parks')
|
||||
.select('*')
|
||||
.eq('slug', park.slug)
|
||||
.single();
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!photosCached) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.photos.entity('park', park.id),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('photos')
|
||||
.select('*')
|
||||
.eq('entity_type', 'park')
|
||||
.eq('entity_id', park.id)
|
||||
.limit(10);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
|
||||
173
src/docs/API_PATTERNS.md
Normal file
173
src/docs/API_PATTERNS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# API and Cache Patterns
|
||||
|
||||
## Mutation Pattern (PREFERRED)
|
||||
|
||||
Always use `useMutation` hooks for data modifications instead of direct Supabase calls.
|
||||
|
||||
### ✅ CORRECT Pattern
|
||||
|
||||
```typescript
|
||||
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';
|
||||
|
||||
export function useMyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateRelatedCache } = useQueryInvalidation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (params) => {
|
||||
const { error } = await supabase
|
||||
.from('table')
|
||||
.insert(params);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onMutate: async (params) => {
|
||||
// Optional: Optimistic updates
|
||||
await queryClient.cancelQueries({ queryKey: ['my-data'] });
|
||||
const previous = queryClient.getQueryData(['my-data']);
|
||||
|
||||
queryClient.setQueryData(['my-data'], (old) => {
|
||||
// Update optimistically
|
||||
});
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Rollback optimistic updates
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['my-data'], context.previous);
|
||||
}
|
||||
|
||||
toast.error("Error", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateRelatedCache();
|
||||
toast.success("Success", {
|
||||
description: "Operation completed successfully.",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ INCORRECT Pattern (Direct Supabase)
|
||||
|
||||
```typescript
|
||||
// DON'T DO THIS
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('table').insert(data);
|
||||
if (error) throw error;
|
||||
toast.success('Success');
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
### ✅ CORRECT: Use onError callback
|
||||
|
||||
```typescript
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
const { error } = await supabase.from('table').insert(data);
|
||||
if (error) throw error;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Error", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ INCORRECT: try/catch in component
|
||||
|
||||
```typescript
|
||||
// Avoid this pattern
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await mutation.mutateAsync(data);
|
||||
} catch (error) {
|
||||
// Error already handled in mutation
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Query Keys Pattern
|
||||
|
||||
### ✅ CORRECT: Use centralized queryKeys
|
||||
|
||||
```typescript
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.parks.detail(slug),
|
||||
queryFn: fetchParkDetail,
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ INCORRECT: Inline query keys
|
||||
|
||||
```typescript
|
||||
// Don't do this
|
||||
const { data } = useQuery({
|
||||
queryKey: ['parks', 'detail', slug],
|
||||
queryFn: fetchParkDetail,
|
||||
});
|
||||
```
|
||||
|
||||
## Cache Invalidation Pattern
|
||||
|
||||
### ✅ CORRECT: Use invalidation helpers
|
||||
|
||||
```typescript
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
|
||||
const { invalidateParks, invalidateHomepageData } = useQueryInvalidation();
|
||||
|
||||
// In mutation onSuccess:
|
||||
onSuccess: () => {
|
||||
invalidateParks();
|
||||
invalidateHomepageData('parks');
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ INCORRECT: Manual invalidation
|
||||
|
||||
```typescript
|
||||
// Avoid this
|
||||
queryClient.invalidateQueries({ queryKey: ['parks'] });
|
||||
```
|
||||
|
||||
## Benefits of This Pattern
|
||||
|
||||
1. **Automatic retry logic**: Failed mutations can be retried automatically
|
||||
2. **Loading states**: `isPending` flag for UI feedback
|
||||
3. **Optimistic updates**: Update UI before server confirms
|
||||
4. **Consistent error handling**: Centralized error handling
|
||||
5. **Cache coordination**: Proper invalidation timing
|
||||
6. **Testing**: Easier to mock and test
|
||||
7. **Type safety**: Better TypeScript support
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When migrating a component:
|
||||
|
||||
- [ ] Create custom mutation hook in appropriate directory
|
||||
- [ ] Use `useMutation` instead of direct Supabase calls
|
||||
- [ ] Implement `onError` callback with toast notifications
|
||||
- [ ] Implement `onSuccess` callback with cache invalidation
|
||||
- [ ] Use centralized `queryKeys` for query identification
|
||||
- [ ] Use `useQueryInvalidation` helpers for cache management
|
||||
- [ ] Replace loading state with `mutation.isPending`
|
||||
- [ ] Remove try/catch blocks from component
|
||||
- [ ] Test optimistic updates if applicable
|
||||
77
src/hooks/profile/useProfileUpdateMutation.ts
Normal file
77
src/hooks/profile/useProfileUpdateMutation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
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] });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousProfile = queryClient.getQueryData(['profile', userId]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(['profile', userId], (old: any) => ({
|
||||
...old,
|
||||
...updates,
|
||||
}));
|
||||
|
||||
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 changed, invalidate user search results
|
||||
if (updates.display_name) {
|
||||
invalidateUserSearch();
|
||||
}
|
||||
|
||||
toast.success("Profile Updated", {
|
||||
description: "Your changes have been saved.",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
48
src/hooks/reports/useReportMutation.ts
Normal file
48
src/hooks/reports/useReportMutation.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -95,6 +95,70 @@ export function useQueryInvalidation() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate user profile cache
|
||||
* Call this after profile updates
|
||||
*/
|
||||
invalidateUserProfile: (userId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.profile.detail(userId) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate profile stats cache
|
||||
* Call this after profile-related changes
|
||||
*/
|
||||
invalidateProfileStats: (userId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.profile.stats(userId) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate profile activity cache
|
||||
* Call this after activity changes
|
||||
*/
|
||||
invalidateProfileActivity: (userId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile', 'activity', userId] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate user search results
|
||||
* Call this when display names change
|
||||
*/
|
||||
invalidateUserSearch: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users', 'search'] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate admin settings cache
|
||||
* Call this after updating admin settings
|
||||
*/
|
||||
invalidateAdminSettings: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.settings() });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate audit logs cache
|
||||
* Call this after inserting audit log entries
|
||||
*/
|
||||
invalidateAuditLogs: (userId?: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.auditLogs(userId) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate contact submissions cache
|
||||
* Call this after updating contact submissions
|
||||
*/
|
||||
invalidateContactSubmissions: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate blog posts cache
|
||||
* Call this after creating/updating/deleting blog posts
|
||||
*/
|
||||
invalidateBlogPosts: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.blogPosts() });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate parks listing cache
|
||||
* Call this after creating/updating/deleting parks
|
||||
@@ -211,22 +275,6 @@ export function useQueryInvalidation() {
|
||||
queryClient.invalidateQueries({ queryKey: ['companies', 'parks', id, type] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate profile activity cache
|
||||
* Call this after user activity changes
|
||||
*/
|
||||
invalidateProfileActivity: (userId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile', 'activity', userId] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate profile stats cache
|
||||
* Call this after user stats changes
|
||||
*/
|
||||
invalidateProfileStats: (userId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.profile.stats(userId) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate ride model detail cache
|
||||
* Call this after updating a ride model
|
||||
@@ -324,14 +372,5 @@ export function useQueryInvalidation() {
|
||||
queryKey: queryKeys.entities.name(entityType, entityId)
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate user profile cache
|
||||
*/
|
||||
invalidateUserProfile: (userId: string) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['profiles', userId]
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@ export const queryKeys = {
|
||||
// Admin queries
|
||||
admin: {
|
||||
versionAudit: ['admin', 'version-audit'] as const,
|
||||
settings: () => ['admin-settings'] as const,
|
||||
blogPosts: () => ['admin-blog-posts'] as const,
|
||||
contactSubmissions: (statusFilter?: string, categoryFilter?: string, searchQuery?: string, showArchived?: boolean) =>
|
||||
['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived] as const,
|
||||
auditLogs: (userId?: string) => ['admin', 'audit-logs', userId] as const,
|
||||
},
|
||||
|
||||
// Moderation queries
|
||||
|
||||
Reference in New Issue
Block a user