diff --git a/src/docs/API_PATTERNS.md b/src/docs/API_PATTERNS.md index 0689ca41..77f5a2c0 100644 --- a/src/docs/API_PATTERNS.md +++ b/src/docs/API_PATTERNS.md @@ -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 +``` diff --git a/src/hooks/admin/useAuditLogs.ts b/src/hooks/admin/useAuditLogs.ts new file mode 100644 index 00000000..f09a506a --- /dev/null +++ b/src/hooks/admin/useAuditLogs.ts @@ -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, + }); +} diff --git a/src/hooks/privacy/useBlockUserMutation.ts b/src/hooks/privacy/useBlockUserMutation.ts new file mode 100644 index 00000000..0b16e16a --- /dev/null +++ b/src/hooks/privacy/useBlockUserMutation.ts @@ -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, + }; +} diff --git a/src/hooks/privacy/useBlockedUsers.ts b/src/hooks/privacy/useBlockedUsers.ts new file mode 100644 index 00000000..fdf895c0 --- /dev/null +++ b/src/hooks/privacy/useBlockedUsers.ts @@ -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 + }); +} diff --git a/src/hooks/privacy/usePrivacyMutations.ts b/src/hooks/privacy/usePrivacyMutations.ts new file mode 100644 index 00000000..e9781722 --- /dev/null +++ b/src/hooks/privacy/usePrivacyMutations.ts @@ -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, + }; +} diff --git a/src/hooks/profile/useProfileLocationMutation.ts b/src/hooks/profile/useProfileLocationMutation.ts new file mode 100644 index 00000000..d88a8755 --- /dev/null +++ b/src/hooks/profile/useProfileLocationMutation.ts @@ -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, + }; +} diff --git a/src/hooks/reports/useReportActionMutation.ts b/src/hooks/reports/useReportActionMutation.ts new file mode 100644 index 00000000..b5ba573f --- /dev/null +++ b/src/hooks/reports/useReportActionMutation.ts @@ -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, + }; +} diff --git a/src/hooks/security/useSecurityMutations.ts b/src/hooks/security/useSecurityMutations.ts new file mode 100644 index 00000000..6ae389b1 --- /dev/null +++ b/src/hooks/security/useSecurityMutations.ts @@ -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, + }; +} diff --git a/src/hooks/useAdminSettings.ts b/src/hooks/useAdminSettings.ts index fbcc2e52..6247755e 100644 --- a/src/hooks/useAdminSettings.ts +++ b/src/hooks/useAdminSettings.ts @@ -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.", diff --git a/src/lib/queryInvalidation.ts b/src/lib/queryInvalidation.ts index c97b15b3..7be30724 100644 --- a/src/lib/queryInvalidation.ts +++ b/src/lib/queryInvalidation.ts @@ -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) diff --git a/src/pages/admin/AdminContact.tsx b/src/pages/admin/AdminContact.tsx index e4deeca9..1c6ccbfd 100644 --- a/src/pages/admin/AdminContact.tsx +++ b/src/pages/admin/AdminContact.tsx @@ -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) => {