From c70c5a4150d53412827582719aac72b0a164c338 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:33:27 +0000 Subject: [PATCH] Implement API improvements Phases 1-4 --- src/components/profile/RideCreditsManager.tsx | 30 +++++------- src/components/settings/EmailChangeStatus.tsx | 49 +++---------------- src/components/settings/SecurityTab.tsx | 38 ++------------ src/docs/API_PATTERNS.md | 35 +++++++++++++ .../homepage/useHomepageRecentChanges.ts | 16 +++++- src/hooks/privacy/usePrivacyMutations.ts | 19 ++++--- src/hooks/profile/useProfileActivity.ts | 21 ++++++-- .../profile/useProfileLocationMutation.ts | 26 ++++++---- src/hooks/profile/useProfileUpdateMutation.ts | 16 ++++-- src/hooks/security/useEmailChangeStatus.ts | 38 ++++++++++++++ src/hooks/security/useSessions.ts | 34 +++++++++++++ src/hooks/useEntityVersions.ts | 9 +++- src/hooks/useRideCreditFilters.ts | 2 +- 13 files changed, 214 insertions(+), 119 deletions(-) create mode 100644 src/hooks/security/useEmailChangeStatus.ts create mode 100644 src/hooks/security/useSessions.ts diff --git a/src/components/profile/RideCreditsManager.tsx b/src/components/profile/RideCreditsManager.tsx index 71e16ef2..9a313e9d 100644 --- a/src/components/profile/RideCreditsManager.tsx +++ b/src/components/profile/RideCreditsManager.tsx @@ -13,6 +13,7 @@ import { RideCreditFilters } from './RideCreditFilters'; import { UserRideCredit } from '@/types/database'; import { useRideCreditFilters } from '@/hooks/useRideCreditFilters'; import { useIsMobile } from '@/hooks/use-mobile'; +import { useRideCreditsMutation } from '@/hooks/rides/useRideCreditsMutation'; import { DndContext, DragEndEvent, @@ -39,6 +40,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const isMobile = useIsMobile(); + const { reorderCredit, isReordering } = useRideCreditsMutation(); // Use the filter hook const { @@ -246,24 +248,16 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { } }; - const handleReorder = async (creditId: string, newPosition: number) => { - try { - const { error } = await supabase.rpc('reorder_ride_credit', { - p_credit_id: creditId, - p_new_position: newPosition - }); - - if (error) throw error; - - // No refetch - optimistic update is already applied - } catch (error: unknown) { - handleError(error, { - action: 'Reorder Ride Credit', - userId, - metadata: { creditId, newPosition } - }); - throw error; - } + const handleReorder = (creditId: string, newPosition: number) => { + return new Promise((resolve, reject) => { + reorderCredit.mutate( + { creditId, newPosition }, + { + onSuccess: () => resolve(), + onError: (error) => reject(error) + } + ); + }); }; const handleDragEnd = async (event: DragEndEvent) => { diff --git a/src/components/settings/EmailChangeStatus.tsx b/src/components/settings/EmailChangeStatus.tsx index 2301c4dd..17ecd46a 100644 --- a/src/components/settings/EmailChangeStatus.tsx +++ b/src/components/settings/EmailChangeStatus.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -8,6 +8,7 @@ import { Progress } from '@/components/ui/progress'; import { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; import { handleError, handleSuccess } from '@/lib/errorHandler'; +import { useEmailChangeStatus } from '@/hooks/security/useEmailChangeStatus'; interface EmailChangeStatusProps { currentEmail: string; @@ -15,55 +16,19 @@ interface EmailChangeStatusProps { onCancel: () => void; } -type EmailChangeData = { - has_pending_change: boolean; - current_email?: string; - new_email?: string; - current_email_verified?: boolean; - new_email_verified?: boolean; - change_sent_at?: string; -}; - export function EmailChangeStatus({ currentEmail, pendingEmail, onCancel }: EmailChangeStatusProps) { - const [verificationStatus, setVerificationStatus] = useState({ - oldEmailVerified: false, - newEmailVerified: false - }); - const [loading, setLoading] = useState(true); const [resending, setResending] = useState(false); + const { data: emailStatus, isLoading } = useEmailChangeStatus(); - const checkVerificationStatus = async () => { - try { - const { data, error } = await supabase.rpc('get_email_change_status'); - - if (error) throw error; - - const emailData = data as EmailChangeData; - - if (emailData.has_pending_change) { - setVerificationStatus({ - oldEmailVerified: emailData.current_email_verified || false, - newEmailVerified: emailData.new_email_verified || false - }); - } - } catch (error: unknown) { - handleError(error, { action: 'Check verification status' }); - } finally { - setLoading(false); - } + const verificationStatus = { + oldEmailVerified: emailStatus?.current_email_verified || false, + newEmailVerified: emailStatus?.new_email_verified || false }; - useEffect(() => { - checkVerificationStatus(); - // Poll every 30 seconds - const interval = setInterval(checkVerificationStatus, 30000); - return () => clearInterval(interval); - }, []); - const handleResendVerification = async () => { setResending(true); try { @@ -88,7 +53,7 @@ export function EmailChangeStatus({ (verificationStatus.oldEmailVerified ? 50 : 0) + (verificationStatus.newEmailVerified ? 50 : 0); - if (loading) { + if (isLoading) { return ( diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index 78d759b9..51711c84 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -14,6 +14,7 @@ import { TOTPSetup } from '@/components/auth/TOTPSetup'; import { GoogleIcon } from '@/components/icons/GoogleIcon'; import { DiscordIcon } from '@/components/icons/DiscordIcon'; import { PasswordUpdateDialog } from './PasswordUpdateDialog'; +import { useSessions } from '@/hooks/security/useSessions'; import { getUserIdentities, checkDisconnectSafety, @@ -37,14 +38,14 @@ export function SecurityTab() { const [disconnectingProvider, setDisconnectingProvider] = useState(null); const [hasPassword, setHasPassword] = useState(false); const [addingPassword, setAddingPassword] = useState(false); - const [sessions, setSessions] = useState([]); - const [loadingSessions, setLoadingSessions] = useState(true); const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null); + + // Fetch sessions using hook + const { data: sessions = [], isLoading: loadingSessions, refetch: refetchSessions } = useSessions(user?.id); // Load user identities on mount useEffect(() => { loadIdentities(); - fetchSessions(); }, []); const loadIdentities = async () => { @@ -145,35 +146,6 @@ export function SecurityTab() { setAddingPassword(false); }; - const fetchSessions = async () => { - if (!user) return; - - setLoadingSessions(true); - - try { - const { data, error } = await supabase.rpc('get_my_sessions'); - - if (error) { - throw error; - } - - setSessions((data as AuthSession[]) || []); - } catch (error: unknown) { - logger.error('Failed to fetch sessions', { - userId: user.id, - action: 'fetch_sessions', - error: error instanceof Error ? error.message : String(error) - }); - handleError(error, { - action: 'Load active sessions', - userId: user.id - }); - setSessions([]); - } finally { - setLoadingSessions(false); - } - }; - const initiateSessionRevoke = async (sessionId: string) => { // Get current session to check if revoking self const { data: { session: currentSession } } = await supabase.auth.getSession(); @@ -192,7 +164,7 @@ export function SecurityTab() { { onSuccess: () => { if (!sessionToRevoke.isCurrent) { - fetchSessions(); + refetchSessions(); } setSessionToRevoke(null); }, diff --git a/src/docs/API_PATTERNS.md b/src/docs/API_PATTERNS.md index 90490885..26e0aff7 100644 --- a/src/docs/API_PATTERNS.md +++ b/src/docs/API_PATTERNS.md @@ -306,6 +306,41 @@ When migrating a component: - Features: Automatic caching, refetch on window focus, 5-minute stale time - Returns: Array of blocked users with profile information +### Security +- **`useEmailChangeStatus`** - Query email change verification status + - Queries: `get_email_change_status` RPC function + - Features: Automatic polling every 30 seconds, 15-second stale time + - Returns: Email change status with verification flags + +- **`useSessions`** - Fetch active user sessions + - Queries: `get_my_sessions` RPC function + - Features: Automatic caching, refetch on window focus, 5-minute stale time + - Returns: Array of active sessions with device info + +--- + +## Type Safety Guidelines + +Always use proper TypeScript types in hooks: + +```typescript +// ✅ CORRECT - Define proper interfaces +interface Profile { + display_name?: string; + bio?: string; +} + +queryClient.setQueryData(['profile', userId], (old) => + old ? { ...old, ...updates } : old +); + +// ❌ WRONG - Using any type +queryClient.setQueryData(['profile', userId], (old: any) => ({ + ...old, + ...updates +})); +``` + --- ## Cache Invalidation Guidelines diff --git a/src/hooks/homepage/useHomepageRecentChanges.ts b/src/hooks/homepage/useHomepageRecentChanges.ts index 0c0ed4b2..702ccbd6 100644 --- a/src/hooks/homepage/useHomepageRecentChanges.ts +++ b/src/hooks/homepage/useHomepageRecentChanges.ts @@ -57,8 +57,22 @@ export function useHomepageRecentChanges( 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 || []).map((item: any) => ({ + const result: RecentChange[] = (data as unknown as DatabaseRecentChange[] || []).map((item) => ({ id: item.entity_id, name: item.entity_name, type: item.entity_type as 'park' | 'ride' | 'company', diff --git a/src/hooks/privacy/usePrivacyMutations.ts b/src/hooks/privacy/usePrivacyMutations.ts index f941ed83..d831e5eb 100644 --- a/src/hooks/privacy/usePrivacyMutations.ts +++ b/src/hooks/privacy/usePrivacyMutations.ts @@ -67,15 +67,22 @@ export function usePrivacyMutations() { await queryClient.cancelQueries({ queryKey: ['profile', user?.id] }); // Snapshot current value - const previousProfile = queryClient.getQueryData(['profile', user?.id]); + interface Profile { + privacy_level?: string; + show_pronouns?: boolean; + } + + const previousProfile = queryClient.getQueryData(['profile', user?.id]); // Optimistically update cache if (previousProfile) { - queryClient.setQueryData(['profile', user?.id], (old: any) => ({ - ...old, - privacy_level: newData.privacy_level, - show_pronouns: newData.show_pronouns, - })); + queryClient.setQueryData(['profile', user?.id], (old) => + old ? { + ...old, + privacy_level: newData.privacy_level, + show_pronouns: newData.show_pronouns, + } : old + ); } return { previousProfile }; diff --git a/src/hooks/profile/useProfileActivity.ts b/src/hooks/profile/useProfileActivity.ts index 3048693f..beb350e7 100644 --- a/src/hooks/profile/useProfileActivity.ts +++ b/src/hooks/profile/useProfileActivity.ts @@ -166,13 +166,28 @@ export function useProfileActivity( photoItemsMap.get(item.photo_submission_id)!.push(item); }); + interface DatabaseEntity { + id: string; + name: string; + slug: string; + } + const entityMap = new Map([ - ...parks.map((p: any): [string, EntityData] => [p.id, p]), - ...rides.map((r: any): [string, EntityData] => [r.id, r]) + ...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: any) => { + photoSubmissions.forEach((sub: PhotoSubmissionWithAllFields) => { const photoSub = photoSubMap.get(sub.id); if (photoSub) { const items = photoItemsMap.get(photoSub.id) || []; diff --git a/src/hooks/profile/useProfileLocationMutation.ts b/src/hooks/profile/useProfileLocationMutation.ts index 61d969ca..cc27607f 100644 --- a/src/hooks/profile/useProfileLocationMutation.ts +++ b/src/hooks/profile/useProfileLocationMutation.ts @@ -64,18 +64,26 @@ export function useProfileLocationMutation() { await queryClient.cancelQueries({ queryKey: ['profile', user?.id] }); // Snapshot current value - const previousProfile = queryClient.getQueryData(['profile', user?.id]); + interface Profile { + personal_location?: string; + home_park_id?: string; + timezone?: string; + } + + const previousProfile = queryClient.getQueryData(['profile', user?.id]); // Optimistically update cache if (previousProfile) { - queryClient.setQueryData(['profile', user?.id], (old: any) => ({ - ...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, - })); + queryClient.setQueryData(['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 }; diff --git a/src/hooks/profile/useProfileUpdateMutation.ts b/src/hooks/profile/useProfileUpdateMutation.ts index 33df36d6..3eb3008e 100644 --- a/src/hooks/profile/useProfileUpdateMutation.ts +++ b/src/hooks/profile/useProfileUpdateMutation.ts @@ -37,14 +37,20 @@ export function useProfileUpdateMutation() { // 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', userId]); + const previousProfile = queryClient.getQueryData(['profile', userId]); // Optimistically update - queryClient.setQueryData(['profile', userId], (old: any) => ({ - ...old, - ...updates, - })); + queryClient.setQueryData(['profile', userId], (old) => + old ? { ...old, ...updates } : old + ); return { previousProfile, userId }; }, diff --git a/src/hooks/security/useEmailChangeStatus.ts b/src/hooks/security/useEmailChangeStatus.ts new file mode 100644 index 00000000..582dc5c6 --- /dev/null +++ b/src/hooks/security/useEmailChangeStatus.ts @@ -0,0 +1,38 @@ +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 + }); +} diff --git a/src/hooks/security/useSessions.ts b/src/hooks/security/useSessions.ts new file mode 100644 index 00000000..a0288410 --- /dev/null +++ b/src/hooks/security/useSessions.ts @@ -0,0 +1,34 @@ +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, + }); +} diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts index 471ee430..c3bdc75e 100644 --- a/src/hooks/useEntityVersions.ts +++ b/src/hooks/useEntityVersions.ts @@ -93,7 +93,14 @@ export function useEntityVersions(entityType: EntityType, entityId: string) { return; } - const versionsWithProfiles = (data || []).map((v: any) => ({ + interface DatabaseVersion { + profiles?: { + username?: string; + display_name?: string; + }; + } + + const versionsWithProfiles = (data as DatabaseVersion[] || []).map((v) => ({ ...v, profiles: v.profiles || { username: 'Unknown', diff --git a/src/hooks/useRideCreditFilters.ts b/src/hooks/useRideCreditFilters.ts index 6de3dcdb..7721e927 100644 --- a/src/hooks/useRideCreditFilters.ts +++ b/src/hooks/useRideCreditFilters.ts @@ -7,7 +7,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) { const [filters, setFilters] = useState({}); const debouncedSearchQuery = useDebounce(filters.searchQuery || '', 300); - const updateFilter = useCallback((key: keyof RideCreditFilters, value: any) => { + const updateFilter = useCallback((key: keyof RideCreditFilters, value: RideCreditFilters[typeof key]) => { setFilters(prev => ({ ...prev, [key]: value })); }, []);