Implement API improvements Phases 1-4

This commit is contained in:
gpt-engineer-app[bot]
2025-10-31 12:33:27 +00:00
parent ca9aa757ae
commit c70c5a4150
13 changed files with 214 additions and 119 deletions

View File

@@ -13,6 +13,7 @@ import { RideCreditFilters } from './RideCreditFilters';
import { UserRideCredit } from '@/types/database'; import { UserRideCredit } from '@/types/database';
import { useRideCreditFilters } from '@/hooks/useRideCreditFilters'; import { useRideCreditFilters } from '@/hooks/useRideCreditFilters';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { useRideCreditsMutation } from '@/hooks/rides/useRideCreditsMutation';
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@@ -39,6 +40,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { reorderCredit, isReordering } = useRideCreditsMutation();
// Use the filter hook // Use the filter hook
const { const {
@@ -246,24 +248,16 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
} }
}; };
const handleReorder = async (creditId: string, newPosition: number) => { const handleReorder = (creditId: string, newPosition: number) => {
try { return new Promise<void>((resolve, reject) => {
const { error } = await supabase.rpc('reorder_ride_credit', { reorderCredit.mutate(
p_credit_id: creditId, { creditId, newPosition },
p_new_position: newPosition {
}); onSuccess: () => resolve(),
onError: (error) => reject(error)
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 handleDragEnd = async (event: DragEndEvent) => { const handleDragEnd = async (event: DragEndEvent) => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; 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 { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { handleError, handleSuccess } from '@/lib/errorHandler'; import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useEmailChangeStatus } from '@/hooks/security/useEmailChangeStatus';
interface EmailChangeStatusProps { interface EmailChangeStatusProps {
currentEmail: string; currentEmail: string;
@@ -15,55 +16,19 @@ interface EmailChangeStatusProps {
onCancel: () => void; 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({ export function EmailChangeStatus({
currentEmail, currentEmail,
pendingEmail, pendingEmail,
onCancel onCancel
}: EmailChangeStatusProps) { }: EmailChangeStatusProps) {
const [verificationStatus, setVerificationStatus] = useState({
oldEmailVerified: false,
newEmailVerified: false
});
const [loading, setLoading] = useState(true);
const [resending, setResending] = useState(false); const [resending, setResending] = useState(false);
const { data: emailStatus, isLoading } = useEmailChangeStatus();
const checkVerificationStatus = async () => { const verificationStatus = {
try { oldEmailVerified: emailStatus?.current_email_verified || false,
const { data, error } = await supabase.rpc('get_email_change_status'); newEmailVerified: emailStatus?.new_email_verified || false
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);
}
}; };
useEffect(() => {
checkVerificationStatus();
// Poll every 30 seconds
const interval = setInterval(checkVerificationStatus, 30000);
return () => clearInterval(interval);
}, []);
const handleResendVerification = async () => { const handleResendVerification = async () => {
setResending(true); setResending(true);
try { try {
@@ -88,7 +53,7 @@ export function EmailChangeStatus({
(verificationStatus.oldEmailVerified ? 50 : 0) + (verificationStatus.oldEmailVerified ? 50 : 0) +
(verificationStatus.newEmailVerified ? 50 : 0); (verificationStatus.newEmailVerified ? 50 : 0);
if (loading) { if (isLoading) {
return ( return (
<Card className="border-blue-500/30"> <Card className="border-blue-500/30">
<CardContent className="flex items-center justify-center py-8"> <CardContent className="flex items-center justify-center py-8">

View File

@@ -14,6 +14,7 @@ import { TOTPSetup } from '@/components/auth/TOTPSetup';
import { GoogleIcon } from '@/components/icons/GoogleIcon'; import { GoogleIcon } from '@/components/icons/GoogleIcon';
import { DiscordIcon } from '@/components/icons/DiscordIcon'; import { DiscordIcon } from '@/components/icons/DiscordIcon';
import { PasswordUpdateDialog } from './PasswordUpdateDialog'; import { PasswordUpdateDialog } from './PasswordUpdateDialog';
import { useSessions } from '@/hooks/security/useSessions';
import { import {
getUserIdentities, getUserIdentities,
checkDisconnectSafety, checkDisconnectSafety,
@@ -37,14 +38,14 @@ export function SecurityTab() {
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null); const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
const [hasPassword, setHasPassword] = useState(false); const [hasPassword, setHasPassword] = useState(false);
const [addingPassword, setAddingPassword] = useState(false); const [addingPassword, setAddingPassword] = useState(false);
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [loadingSessions, setLoadingSessions] = useState(true);
const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null); 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 // Load user identities on mount
useEffect(() => { useEffect(() => {
loadIdentities(); loadIdentities();
fetchSessions();
}, []); }, []);
const loadIdentities = async () => { const loadIdentities = async () => {
@@ -145,35 +146,6 @@ export function SecurityTab() {
setAddingPassword(false); 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) => { const initiateSessionRevoke = async (sessionId: string) => {
// Get current session to check if revoking self // Get current session to check if revoking self
const { data: { session: currentSession } } = await supabase.auth.getSession(); const { data: { session: currentSession } } = await supabase.auth.getSession();
@@ -192,7 +164,7 @@ export function SecurityTab() {
{ {
onSuccess: () => { onSuccess: () => {
if (!sessionToRevoke.isCurrent) { if (!sessionToRevoke.isCurrent) {
fetchSessions(); refetchSessions();
} }
setSessionToRevoke(null); setSessionToRevoke(null);
}, },

View File

@@ -306,6 +306,41 @@ When migrating a component:
- Features: Automatic caching, refetch on window focus, 5-minute stale time - Features: Automatic caching, refetch on window focus, 5-minute stale time
- Returns: Array of blocked users with profile information - 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>(['profile', userId], (old) =>
old ? { ...old, ...updates } : old
);
// ❌ WRONG - Using any type
queryClient.setQueryData(['profile', userId], (old: any) => ({
...old,
...updates
}));
```
--- ---
## Cache Invalidation Guidelines ## Cache Invalidation Guidelines

View File

@@ -57,8 +57,22 @@ export function useHomepageRecentChanges(
if (error) throw error; 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 // 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, id: item.entity_id,
name: item.entity_name, name: item.entity_name,
type: item.entity_type as 'park' | 'ride' | 'company', type: item.entity_type as 'park' | 'ride' | 'company',

View File

@@ -67,15 +67,22 @@ export function usePrivacyMutations() {
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] }); await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
// Snapshot current value // Snapshot current value
const previousProfile = queryClient.getQueryData(['profile', user?.id]); interface Profile {
privacy_level?: string;
show_pronouns?: boolean;
}
const previousProfile = queryClient.getQueryData<Profile>(['profile', user?.id]);
// Optimistically update cache // Optimistically update cache
if (previousProfile) { if (previousProfile) {
queryClient.setQueryData(['profile', user?.id], (old: any) => ({ queryClient.setQueryData<Profile>(['profile', user?.id], (old) =>
...old, old ? {
privacy_level: newData.privacy_level, ...old,
show_pronouns: newData.show_pronouns, privacy_level: newData.privacy_level,
})); show_pronouns: newData.show_pronouns,
} : old
);
} }
return { previousProfile }; return { previousProfile };

View File

@@ -166,13 +166,28 @@ export function useProfileActivity(
photoItemsMap.get(item.photo_submission_id)!.push(item); photoItemsMap.get(item.photo_submission_id)!.push(item);
}); });
interface DatabaseEntity {
id: string;
name: string;
slug: string;
}
const entityMap = new Map<string, EntityData>([ const entityMap = new Map<string, EntityData>([
...parks.map((p: any): [string, EntityData] => [p.id, p]), ...parks.map((p: DatabaseEntity): [string, EntityData] => [p.id, p]),
...rides.map((r: any): [string, EntityData] => [r.id, r]) ...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 // Enrich submissions
photoSubmissions.forEach((sub: any) => { photoSubmissions.forEach((sub: PhotoSubmissionWithAllFields) => {
const photoSub = photoSubMap.get(sub.id); const photoSub = photoSubMap.get(sub.id);
if (photoSub) { if (photoSub) {
const items = photoItemsMap.get(photoSub.id) || []; const items = photoItemsMap.get(photoSub.id) || [];

View File

@@ -64,18 +64,26 @@ export function useProfileLocationMutation() {
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] }); await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
// Snapshot current value // 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>(['profile', user?.id]);
// Optimistically update cache // Optimistically update cache
if (previousProfile) { if (previousProfile) {
queryClient.setQueryData(['profile', user?.id], (old: any) => ({ queryClient.setQueryData<Profile>(['profile', user?.id], (old) =>
...old, old ? {
personal_location: newData.personal_location, ...old,
home_park_id: newData.home_park_id, personal_location: newData.personal_location,
timezone: newData.timezone, home_park_id: newData.home_park_id,
preferred_language: newData.preferred_language, timezone: newData.timezone,
preferred_pronouns: newData.preferred_pronouns, preferred_language: newData.preferred_language,
})); preferred_pronouns: newData.preferred_pronouns,
} : old
);
} }
return { previousProfile }; return { previousProfile };

View File

@@ -37,14 +37,20 @@ export function useProfileUpdateMutation() {
// Cancel outgoing refetches // Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['profile', userId] }); await queryClient.cancelQueries({ queryKey: ['profile', userId] });
interface Profile {
display_name?: string;
bio?: string;
location_id?: string;
website?: string;
}
// Snapshot previous value // Snapshot previous value
const previousProfile = queryClient.getQueryData(['profile', userId]); const previousProfile = queryClient.getQueryData<Profile>(['profile', userId]);
// Optimistically update // Optimistically update
queryClient.setQueryData(['profile', userId], (old: any) => ({ queryClient.setQueryData<Profile>(['profile', userId], (old) =>
...old, old ? { ...old, ...updates } : old
...updates, );
}));
return { previousProfile, userId }; return { previousProfile, userId };
}, },

View File

@@ -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
});
}

View File

@@ -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,
});
}

View File

@@ -93,7 +93,14 @@ export function useEntityVersions(entityType: EntityType, entityId: string) {
return; return;
} }
const versionsWithProfiles = (data || []).map((v: any) => ({ interface DatabaseVersion {
profiles?: {
username?: string;
display_name?: string;
};
}
const versionsWithProfiles = (data as DatabaseVersion[] || []).map((v) => ({
...v, ...v,
profiles: v.profiles || { profiles: v.profiles || {
username: 'Unknown', username: 'Unknown',

View File

@@ -7,7 +7,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
const [filters, setFilters] = useState<RideCreditFilters>({}); const [filters, setFilters] = useState<RideCreditFilters>({});
const debouncedSearchQuery = useDebounce(filters.searchQuery || '', 300); 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 })); setFilters(prev => ({ ...prev, [key]: value }));
}, []); }, []);