feat: Implement API and cache improvements

This commit is contained in:
gpt-engineer-app[bot]
2025-10-31 12:11:14 +00:00
parent 2fb983bb4f
commit 0d16bb511c
11 changed files with 584 additions and 9 deletions

View File

@@ -171,3 +171,49 @@ When migrating a component:
- [ ] Replace loading state with `mutation.isPending`
- [ ] Remove try/catch blocks from component
- [ ] Test optimistic updates if applicable
- [ ] Add audit log creation where appropriate
- [ ] Ensure proper type safety with TypeScript
## Available Mutation Hooks
### Profile & User Management
- `useProfileUpdateMutation` - Profile updates (username, display name, bio)
- `useProfileLocationMutation` - Location and personal info updates
- `usePrivacyMutations` - Privacy settings updates
### Security
- `useSecurityMutations` - Session management (revoke sessions)
### Moderation
- `useReportMutation` - Submit user reports
- `useReportActionMutation` - Resolve/dismiss reports
### Admin
- `useAuditLogs` - Query audit logs with pagination
## Cache Invalidation Guidelines
Always invalidate related caches after mutations:
```typescript
// After profile update
invalidateUserProfile(userId);
invalidateProfileStats(userId);
invalidateProfileActivity(userId);
invalidateUserSearch(); // If username/display name changed
// After privacy update
invalidateUserProfile(userId);
invalidateAuditLogs(userId);
invalidateUserSearch(); // If privacy level changed
// After report action
invalidateModerationQueue();
invalidateModerationStats();
invalidateAuditLogs();
// After security action
invalidateSessions();
invalidateAuditLogs();
invalidateEmailChangeStatus(); // For email changes
```

View File

@@ -0,0 +1,55 @@
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,
});
}

View File

@@ -0,0 +1,68 @@
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,
};
}

View File

@@ -0,0 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import type { UserBlock } from '@/types/privacy';
/**
* Hook for querying blocked users
* Provides: list of blocked users with profile information
*/
export function useBlockedUsers() {
const { user } = useAuth();
return useQuery({
queryKey: ['blocked-users', user?.id],
queryFn: async () => {
if (!user) return [];
// First get the blocked user IDs
const { data: blocks, error: blocksError } = await supabase
.from('user_blocks')
.select('id, blocked_id, reason, created_at')
.eq('blocker_id', user.id)
.order('created_at', { ascending: false });
if (blocksError) throw blocksError;
if (!blocks || blocks.length === 0) {
return [];
}
// Then get the 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) throw profilesError;
// Combine the data
const blockedUsersWithProfiles: UserBlock[] = blocks.map(block => ({
...block,
blocker_id: user.id,
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
}));
return blockedUsersWithProfiles;
},
enabled: !!user,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}

View File

@@ -0,0 +1,92 @@
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
* Provides: privacy settings updates with automatic audit logging and cache invalidation
*/
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 };
},
onError: (error: unknown) => {
toast.error("Update Failed", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, variables) => {
// Invalidate all related caches
if (user) {
invalidateUserProfile(user.id);
invalidateAuditLogs(user.id);
// If privacy level changed, invalidate user search
if (variables.privacy_level) {
invalidateUserSearch();
}
}
toast.success("Privacy Updated", {
description: "Your privacy preferences have been successfully saved.",
});
},
});
return {
updatePrivacy,
isUpdating: updatePrivacy.isPending,
};
}

View File

@@ -0,0 +1,82 @@
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,
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;
},
onError: (error: unknown) => {
toast.error("Update Failed", {
description: getErrorMessage(error),
});
},
onSuccess: () => {
if (user) {
invalidateUserProfile(user.id);
invalidateAuditLogs(user.id);
}
toast.success("Settings Saved", {
description: "Your location and personal information have been updated.",
});
},
});
return {
updateLocation,
isUpdating: updateLocation.isPending,
};
}

View File

@@ -0,0 +1,86 @@
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,
};
}

View File

@@ -0,0 +1,54 @@
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,
};
}

View File

@@ -4,6 +4,7 @@ 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;
@@ -24,7 +25,7 @@ export function useAdminSettings() {
isLoading,
error
} = useQuery({
queryKey: ['admin-settings'],
queryKey: queryKeys.admin.settings(),
queryFn: async () => {
const { data, error } = await supabase
.from('admin_settings')
@@ -59,7 +60,7 @@ export function useAdminSettings() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: queryKeys.admin.settings() });
toast({
title: "Setting Updated",
description: "The setting has been saved successfully.",

View File

@@ -331,6 +331,26 @@ export function useQueryInvalidation() {
});
},
/**
* Invalidate email change status cache
* Call this after email change operations
*/
invalidateEmailChangeStatus: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.security.emailChangeStatus()
});
},
/**
* Invalidate sessions cache
* Call this after session operations (login, logout, revoke)
*/
invalidateSessions: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.security.sessions()
});
},
/**
* Invalidate security queries
* Call this after security-related changes (email, sessions)

View File

@@ -80,6 +80,7 @@ import { logger } from '@/lib/logger';
import { contactCategories } from '@/lib/contactValidation';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { queryKeys } from '@/lib/queryKeys';
interface ContactSubmission {
id: string;
@@ -159,7 +160,7 @@ export default function AdminContact() {
// Fetch contact submissions
const { data: submissions, isLoading } = useQuery({
queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived],
queryKey: queryKeys.admin.contactSubmissions(statusFilter, categoryFilter, searchQuery, showArchived),
queryFn: async () => {
let query = supabase
.from('contact_submissions')
@@ -282,7 +283,10 @@ export default function AdminContact() {
.order('created_at', { ascending: true })
.then(({ data }) => setEmailThreads((data as EmailThread[]) || []));
}
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
},
onError: (error: Error) => {
handleError(error, { action: 'Send Email Reply' });
@@ -320,7 +324,10 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
handleSuccess('Status Updated', 'Contact submission status has been updated');
setSelectedSubmission(null);
setAdminNotes('');
@@ -345,7 +352,10 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
handleSuccess('Archived', 'Contact submission has been archived');
setSelectedSubmission(null);
},
@@ -368,7 +378,10 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
handleSuccess('Restored', 'Contact submission has been restored from archive');
setSelectedSubmission(null);
},
@@ -388,7 +401,10 @@ export default function AdminContact() {
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
handleSuccess('Deleted', 'Contact submission has been permanently deleted');
setSelectedSubmission(null);
},
@@ -428,7 +444,10 @@ export default function AdminContact() {
};
const handleRefreshSubmissions = () => {
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
queryClient.invalidateQueries({
queryKey: ['admin-contact-submissions'],
exact: false
});
};
const handleCopyTicket = (ticketNumber: string) => {