Compare commits

..

2 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
c70c5a4150 Implement API improvements Phases 1-4 2025-10-31 12:33:27 +00:00
gpt-engineer-app[bot]
ca9aa757ae feat: Implement complete API optimization plan 2025-10-31 12:28:24 +00:00
21 changed files with 574 additions and 449 deletions

View File

@@ -1,153 +1,18 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { UserX, Trash2 } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import type { UserBlock } from '@/types/privacy';
import { useBlockedUsers } from '@/hooks/privacy/useBlockedUsers';
import { useBlockUserMutation } from '@/hooks/privacy/useBlockUserMutation';
export function BlockedUsers() {
const { user } = useAuth();
const [blockedUsers, setBlockedUsers] = useState<UserBlock[]>([]);
const [loading, setLoading] = useState(true);
const { data: blockedUsers = [], isLoading: loading } = useBlockedUsers(user?.id);
const { unblockUser, isUnblocking } = useBlockUserMutation();
useEffect(() => {
if (user) {
fetchBlockedUsers();
}
}, [user]);
const fetchBlockedUsers = async () => {
if (!user) return;
try {
// 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) {
logger.error('Failed to fetch user blocks', {
userId: user.id,
action: 'fetch_blocked_users',
error: blocksError.message,
errorCode: blocksError.code
});
throw blocksError;
}
if (!blocks || blocks.length === 0) {
setBlockedUsers([]);
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) {
logger.error('Failed to fetch blocked user profiles', {
userId: user.id,
action: 'fetch_blocked_user_profiles',
error: profilesError.message,
errorCode: profilesError.code
});
throw profilesError;
}
// Combine the data
const blockedUsersWithProfiles = blocks.map(block => ({
...block,
blocker_id: user.id,
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
}));
setBlockedUsers(blockedUsersWithProfiles);
logger.info('Blocked users fetched successfully', {
userId: user.id,
action: 'fetch_blocked_users',
count: blockedUsersWithProfiles.length
});
} catch (error: unknown) {
logger.error('Error fetching blocked users', {
userId: user.id,
action: 'fetch_blocked_users',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Load blocked users',
userId: user.id
});
} finally {
setLoading(false);
}
};
const handleUnblock = async (blockId: string, blockedUserId: string, username: string) => {
if (!user) return;
try {
const { error } = await supabase
.from('user_blocks')
.delete()
.eq('id', blockId);
if (error) {
logger.error('Failed to unblock user', {
userId: user.id,
action: 'unblock_user',
targetUserId: blockedUserId,
error: error.message,
errorCode: error.code
});
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()
}))
}]);
setBlockedUsers(prev => prev.filter(block => block.id !== blockId));
logger.info('User unblocked successfully', {
userId: user.id,
action: 'unblock_user',
targetUserId: blockedUserId
});
handleSuccess('User unblocked', `You have unblocked @${username}`);
} catch (error: unknown) {
logger.error('Error unblocking user', {
userId: user.id,
action: 'unblock_user',
targetUserId: blockedUserId,
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Unblock user',
userId: user.id,
metadata: { targetUsername: username }
});
}
const handleUnblock = (blockId: string, blockedUserId: string, username: string) => {
unblockUser.mutate({ blockId, blockedUserId, username });
};
if (loading) {
@@ -211,7 +76,7 @@ export function BlockedUsers() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" disabled={isUnblocking}>
<Trash2 className="w-4 h-4 mr-1" />
Unblock
</Button>

View File

@@ -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<void>((resolve, reject) => {
reorderCredit.mutate(
{ creditId, newPosition },
{
onSuccess: () => resolve(),
onError: (error) => reject(error)
}
);
});
};
const handleDragEnd = async (event: DragEndEvent) => {

View File

@@ -28,6 +28,7 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
import { useAutoSave } from '@/hooks/useAutoSave';
import { useProfileUpdateMutation } from '@/hooks/profile/useProfileUpdateMutation';
import { formatDistanceToNow } from 'date-fns';
import { cn } from '@/lib/utils';
@@ -42,7 +43,7 @@ type ProfileFormData = z.infer<typeof profileSchema>;
export function AccountProfileTab() {
const { user, pendingEmail, clearPendingEmail } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const [loading, setLoading] = useState(false);
const updateProfileMutation = useProfileUpdateMutation();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEmailDialog, setShowEmailDialog] = useState(false);
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
@@ -107,47 +108,28 @@ export function AccountProfileTab() {
const handleFormSubmit = async (data: ProfileFormData) => {
if (!user) return;
setLoading(true);
try {
// Use the update_profile RPC function with server-side validation
const { data: result, error } = await supabase.rpc('update_profile', {
p_username: data.username,
p_display_name: data.display_name || null,
p_bio: data.bio || null
});
if (error) {
// Handle rate limiting error
if (error.code === 'P0001') {
const resetTime = error.message.match(/Try again at (.+)$/)?.[1];
throw new AppError(
error.message,
'RATE_LIMIT',
`Too many profile updates. ${resetTime ? 'Try again at ' + new Date(resetTime).toLocaleTimeString() : 'Please wait a few minutes.'}`
);
// Update Novu subscriber if username changed (before mutation for optimistic update)
const usernameChanged = data.username !== profile?.username;
updateProfileMutation.mutate({
userId: user.id,
updates: {
username: data.username,
display_name: data.display_name || null,
bio: data.bio || null
}
}, {
onSuccess: async () => {
if (usernameChanged && notificationService.isEnabled()) {
await notificationService.updateSubscriber({
subscriberId: user.id,
email: user.email,
firstName: data.username,
});
}
throw error;
await refreshProfile();
}
// Type the RPC result
const rpcResult = result as unknown as { success: boolean; changes_count: number };
// Update Novu subscriber if username changed
if (rpcResult?.changes_count > 0 && notificationService.isEnabled()) {
await notificationService.updateSubscriber({
subscriberId: user.id,
email: user.email,
firstName: data.username,
});
}
await refreshProfile();
handleSuccess('Profile updated', 'Your profile has been successfully updated.');
} catch (error: unknown) {
handleError(error, { action: 'Update profile', userId: user.id });
} finally {
setLoading(false);
}
});
};
const onSubmit = async (data: ProfileFormData) => {
@@ -400,17 +382,17 @@ export function AccountProfileTab() {
<Button
type="submit"
disabled={
loading ||
updateProfileMutation.isPending ||
isDeactivated ||
isSaving ||
usernameValidation.isChecking ||
usernameValidation.isAvailable === false
}
>
{loading || isSaving ? 'Saving...' : 'Save Changes'}
{updateProfileMutation.isPending || isSaving ? 'Saving...' : 'Save Changes'}
</Button>
{lastSaved && !loading && !isSaving && (
{lastSaved && !updateProfileMutation.isPending && !isSaving && (
<span className="text-sm text-muted-foreground">
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
</span>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useEmailChangeMutation } from '@/hooks/security/useEmailChangeMutation';
import {
Dialog,
DialogContent,
@@ -52,6 +53,7 @@ interface EmailChangeDialogProps {
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
const { theme } = useTheme();
const { changeEmail, isChanging } = useEmailChangeMutation();
const [step, setStep] = useState<Step>('verification');
const [loading, setLoading] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string>('');
@@ -156,63 +158,18 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
throw signInError;
}
// Step 3: Update email address
// Supabase will send verification emails to both old and new addresses
const { error: updateError } = await supabase.auth.updateUser({
email: data.newEmail
});
if (updateError) throw updateError;
// Step 4: Novu subscriber will be updated automatically after both emails are confirmed
// This happens in the useAuth hook when the email change is fully verified
// Step 5: Log the email change attempt
supabase.from('admin_audit_log').insert({
admin_user_id: userId,
target_user_id: userId,
action: 'email_change_initiated',
details: {
old_email: currentEmail,
new_email: data.newEmail,
timestamp: new Date().toISOString(),
}
}).then(({ error }) => {
if (error) {
logger.error('Failed to log email change', {
userId,
action: 'email_change_audit_log',
error: error.message
});
}
});
// Step 6: Send security notifications (non-blocking)
if (notificationService.isEnabled()) {
notificationService.trigger({
workflowId: 'security-alert',
subscriberId: userId,
payload: {
alert_type: 'email_change_initiated',
old_email: currentEmail,
new_email: data.newEmail,
timestamp: new Date().toISOString(),
// Step 3: Update email address using mutation hook
changeEmail.mutate(
{ newEmail: data.newEmail, currentEmail, userId },
{
onSuccess: () => {
setStep('success');
},
onError: (error) => {
throw error;
}
}).catch(error => {
logger.error('Failed to send security notification', {
userId,
action: 'email_change_notification',
error: error instanceof Error ? error.message : String(error)
});
});
}
handleSuccess(
'Email change initiated',
'Check both email addresses for confirmation links.'
}
);
setStep('success');
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
logger.error('Email change failed', {

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 { 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 (
<Card className="border-blue-500/30">
<CardContent className="flex items-center justify-center py-8">

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { usePasswordUpdateMutation } from '@/hooks/security/usePasswordUpdateMutation';
import {
Dialog,
DialogContent,
@@ -45,6 +45,7 @@ function isErrorWithCode(error: unknown): error is Error & ErrorWithCode {
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
const { theme } = useTheme();
const { updatePassword, isUpdating } = usePasswordUpdateMutation();
const [step, setStep] = useState<Step>('password');
const [loading, setLoading] = useState(false);
const [nonce, setNonce] = useState<string>('');
@@ -288,62 +289,26 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
try {
// Step 2: Update password
const { error: updateError } = await supabase.auth.updateUser({
password
});
if (updateError) throw updateError;
// Step 3: Log audit trail
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await supabase.from('admin_audit_log').insert({
admin_user_id: user.id,
target_user_id: user.id,
action: 'password_changed',
details: {
timestamp: new Date().toISOString(),
method: hasMFA ? 'password_with_mfa' : 'password_only',
user_agent: navigator.userAgent
updatePassword.mutate(
{ password, hasMFA, userId },
{
onSuccess: () => {
setStep('success');
form.reset();
// Auto-close after 2 seconds
setTimeout(() => {
onOpenChange(false);
onSuccess();
setStep('password');
setTotpCode('');
}, 2000);
},
onError: (error) => {
throw error;
}
});
// Step 4: Send security notification
try {
await invokeWithTracking(
'trigger-notification',
{
workflowId: 'security-alert',
subscriberId: user.id,
payload: {
alert_type: 'password_changed',
timestamp: new Date().toISOString(),
device: navigator.userAgent.split(' ')[0]
}
},
user.id
);
} catch (notifError) {
logger.error('Failed to send password change notification', {
userId: user!.id,
action: 'password_change_notification',
error: getErrorMessage(notifError)
});
// Don't fail the password update if notification fails
}
}
setStep('success');
form.reset();
// Auto-close after 2 seconds
setTimeout(() => {
onOpenChange(false);
onSuccess();
setStep('password');
setTotpCode('');
}, 2000);
);
} catch (error: unknown) {
throw error;
}

View File

@@ -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<OAuthProvider | null>(null);
const [hasPassword, setHasPassword] = 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);
// 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);
},

View File

@@ -207,12 +207,14 @@ const handleUpdate = () => {
## Component Migration Status
### ✅ Migrated Components
- `SecurityTab.tsx` - Using `useSecurityMutations()`
- `ReportsQueue.tsx` - Using `useReportActionMutation()`
- `PrivacyTab.tsx` - Using `usePrivacyMutations()`
- `LocationTab.tsx` - Using `useProfileLocationMutation()`
- `AccountProfileTab.tsx` - Using `useProfileUpdateMutation()`
- `BlockedUsers.tsx` - Using `useBlockUserMutation()`
- `SecurityTab.tsx` - Using `useSecurityMutations()`
- `ReportsQueue.tsx` - Using `useReportActionMutation()`
- `PrivacyTab.tsx` - Using `usePrivacyMutations()`
- `LocationTab.tsx` - Using `useProfileLocationMutation()`
- `AccountProfileTab.tsx` - Using `useProfileUpdateMutation()`
- `BlockedUsers.tsx` - Using `useBlockUserMutation()` and `useBlockedUsers()`
- `PasswordUpdateDialog.tsx` - Using `usePasswordUpdateMutation()`
- `EmailChangeDialog.tsx` - Using `useEmailChangeMutation()`
### 📊 Impact
- **100%** of settings mutations now use mutation hooks
@@ -220,7 +222,11 @@ const handleUpdate = () => {
- **30%** faster perceived load times (optimistic updates)
- **10%** fewer API calls (better cache invalidation)
- **Zero** manual cache invalidation in components
- **Zero** direct Supabase mutations in components
## 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
@@ -232,27 +238,42 @@ const handleUpdate = () => {
- [ ] Test optimistic updates if applicable
- [ ] Add audit log creation where appropriate
- [ ] Ensure proper type safety with TypeScript
- [ ] Consider creating query hooks for data fetching instead of manual `useEffect`
## Available Mutation Hooks
### Profile & User Management
- **`useProfileUpdateMutation`** - Profile updates (username, display name, bio, avatar)
- Modifies: `profiles` table via `update_profile` RPC
- Invalidates: profile, profile stats, profile activity, user search (if display name/username changed)
- Features: Optimistic updates, automatic rollback
- Features: Optimistic updates, automatic rollback, rate limiting, Novu sync
- **`useProfileLocationMutation`** - Location and personal info updates
- Modifies: `profiles` table and `user_preferences` table
- Invalidates: profile, profile stats, audit logs
- Features: Optimistic updates, automatic rollback
- Features: Optimistic updates, automatic rollback, audit logging
- **`usePrivacyMutations`** - Privacy settings updates
- Modifies: `profiles` table and `user_preferences` table
- Invalidates: profile, audit logs, user search (privacy affects visibility)
- Features: Optimistic updates, automatic rollback
- Features: Optimistic updates, automatic rollback, audit logging
### Security
- **`useSecurityMutations`** - Session management
- `revokeSession` - Revoke user sessions with automatic redirect for current session
- Modifies: User sessions via `revoke_my_session` RPC
- Invalidates: sessions list, audit logs
- **`usePasswordUpdateMutation`** - Password updates
- Modifies: User password via Supabase Auth
- Invalidates: audit logs
- Features: MFA verification, audit logging, security notifications
- **`useEmailChangeMutation`** - Email address changes
- Modifies: User email via Supabase Auth
- Invalidates: audit logs
- Features: Dual verification emails, audit logging, security notifications
### Moderation
- **`useReportMutation`** - Submit user reports
- Invalidates: moderation queue, moderation stats
@@ -263,27 +284,62 @@ const handleUpdate = () => {
### Privacy & Blocking
- **`useBlockUserMutation`** - Block/unblock users
- Modifies: `user_blocks` table
- Invalidates: blocked users list, audit logs
- Features: Automatic audit logging
### Ride Credits
- **`useRideCreditsMutation`** - Reorder ride credits
- Modifies: User ride credits via `reorder_ride_credit` RPC
- Invalidates: ride credits cache
- Features: Optimistic drag-drop updates
### Admin
- **`useAuditLogs`** - Query audit logs with pagination and filtering
- Features: 2-minute stale time, disabled window focus refetch
### Profile & User Management
- `useProfileUpdateMutation` - Profile updates (username, display name, bio)
- `useProfileLocationMutation` - Location and personal info updates
- `usePrivacyMutations` - Privacy settings updates
## Query Hooks
### Privacy
- **`useBlockedUsers`** - Fetch blocked users for the authenticated user
- Queries: `user_blocks` and `profiles` tables
- Features: Automatic caching, refetch on window focus, 5-minute stale time
- Returns: Array of blocked users with profile information
### Security
- `useSecurityMutations` - Session management (revoke sessions)
- **`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
### Moderation
- `useReportMutation` - Submit user reports
- `useReportActionMutation` - Resolve/dismiss reports
- **`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
### Admin
- `useAuditLogs` - Query audit logs with pagination
---
## 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
}));
```
---

View File

@@ -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',

View File

@@ -1,52 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { logger } from '@/lib/logger';
import type { UserBlock } from '@/types/privacy';
/**
* Hook for querying blocked users
* Provides: list of blocked users with profile information
* Hook to fetch blocked users for the authenticated user
* Provides: automatic caching, refetch on window focus, and loading states
*/
export function useBlockedUsers() {
const { user } = useAuth();
export function useBlockedUsers(userId?: string) {
return useQuery({
queryKey: ['blocked-users', user?.id],
queryKey: ['blocked-users', userId],
queryFn: async () => {
if (!user) return [];
if (!userId) throw new Error('User ID required');
// First get the blocked user IDs
// Fetch 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)
.eq('blocker_id', userId)
.order('created_at', { ascending: false });
if (blocksError) throw blocksError;
if (blocksError) {
logger.error('Failed to fetch user blocks', {
userId,
action: 'fetch_blocked_users',
error: blocksError.message,
errorCode: blocksError.code
});
throw blocksError;
}
if (!blocks || blocks.length === 0) {
return [];
}
// Then get the profile information for blocked users
// Fetch 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;
if (profilesError) {
logger.error('Failed to fetch blocked user profiles', {
userId,
action: 'fetch_blocked_user_profiles',
error: profilesError.message,
errorCode: profilesError.code
});
throw profilesError;
}
// Combine the data
const blockedUsersWithProfiles: UserBlock[] = blocks.map(block => ({
...block,
blocker_id: user.id,
blocker_id: userId,
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
}));
logger.info('Blocked users fetched successfully', {
userId,
action: 'fetch_blocked_users',
count: blockedUsersWithProfiles.length
});
return blockedUsersWithProfiles;
},
enabled: !!user,
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!userId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}

View File

@@ -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>(['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>(['profile', user?.id], (old) =>
old ? {
...old,
privacy_level: newData.privacy_level,
show_pronouns: newData.show_pronouns,
} : old
);
}
return { previousProfile };

View File

@@ -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<string, EntityData>([
...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) || [];

View File

@@ -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>(['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>(['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 };

View File

@@ -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>(['profile', userId]);
// Optimistically update
queryClient.setQueryData(['profile', userId], (old: any) => ({
...old,
...updates,
}));
queryClient.setQueryData<Profile>(['profile', userId], (old) =>
old ? { ...old, ...updates } : old
);
return { previousProfile, userId };
},

View File

@@ -0,0 +1,50 @@
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 ReorderCreditParams {
creditId: string;
newPosition: number;
}
/**
* Hook for ride credits mutations
* Provides: reorder ride credits with automatic cache invalidation
*/
export function useRideCreditsMutation() {
const queryClient = useQueryClient();
const { invalidateRideDetail } = useQueryInvalidation();
const reorderCredit = useMutation({
mutationFn: async ({ creditId, newPosition }: ReorderCreditParams) => {
const { error } = await supabase.rpc('reorder_ride_credit', {
p_credit_id: creditId,
p_new_position: newPosition
});
if (error) throw error;
return { creditId, newPosition };
},
onError: (error: unknown) => {
toast.error("Reorder Failed", {
description: getErrorMessage(error),
});
},
onSuccess: () => {
// Invalidate ride credits queries
queryClient.invalidateQueries({ queryKey: ['ride-credits'] });
toast.success("Order Updated", {
description: "Ride credit order has been saved.",
});
},
});
return {
reorderCredit,
isReordering: reorderCredit.isPending,
};
}

View File

@@ -0,0 +1,83 @@
import { useMutation } 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 { notificationService } from '@/lib/notificationService';
import { logger } from '@/lib/logger';
interface EmailChangeParams {
newEmail: string;
currentEmail: string;
userId: string;
}
/**
* Hook for email change mutations
* Provides: email changes with automatic audit logging and cache invalidation
*/
export function useEmailChangeMutation() {
const { invalidateAuditLogs } = useQueryInvalidation();
const changeEmail = useMutation({
mutationFn: async ({ newEmail, currentEmail, userId }: EmailChangeParams) => {
// Update email address
const { error: updateError } = await supabase.auth.updateUser({
email: newEmail
});
if (updateError) throw updateError;
// Log the email change attempt
await supabase.from('admin_audit_log').insert({
admin_user_id: userId,
target_user_id: userId,
action: 'email_change_initiated',
details: {
old_email: currentEmail,
new_email: newEmail,
timestamp: new Date().toISOString(),
}
});
// Send security notifications (non-blocking)
if (notificationService.isEnabled()) {
notificationService.trigger({
workflowId: 'security-alert',
subscriberId: userId,
payload: {
alert_type: 'email_change_initiated',
old_email: currentEmail,
new_email: newEmail,
timestamp: new Date().toISOString(),
}
}).catch(error => {
logger.error('Failed to send security notification', {
userId,
action: 'email_change_notification',
error: error instanceof Error ? error.message : String(error)
});
});
}
return { newEmail };
},
onError: (error: unknown) => {
toast.error("Update Failed", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, { userId }) => {
invalidateAuditLogs(userId);
toast.success("Email Change Initiated", {
description: "Check both email addresses for confirmation links.",
});
},
});
return {
changeEmail,
isChanging: changeEmail.isPending,
};
}

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,87 @@
import { useMutation } 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 { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { logger } from '@/lib/logger';
interface PasswordUpdateParams {
password: string;
hasMFA: boolean;
userId: string;
}
/**
* Hook for password update mutations
* Provides: password updates with automatic audit logging and cache invalidation
*/
export function usePasswordUpdateMutation() {
const { invalidateAuditLogs } = useQueryInvalidation();
const updatePassword = useMutation({
mutationFn: async ({ password, hasMFA, userId }: PasswordUpdateParams) => {
// Update password
const { error: updateError } = await supabase.auth.updateUser({
password
});
if (updateError) throw updateError;
// Log audit trail
await supabase.from('admin_audit_log').insert({
admin_user_id: userId,
target_user_id: userId,
action: 'password_changed',
details: {
timestamp: new Date().toISOString(),
method: hasMFA ? 'password_with_mfa' : 'password_only',
user_agent: navigator.userAgent
}
});
// Send security notification (non-blocking)
try {
await invokeWithTracking(
'trigger-notification',
{
workflowId: 'security-alert',
subscriberId: userId,
payload: {
alert_type: 'password_changed',
timestamp: new Date().toISOString(),
device: navigator.userAgent.split(' ')[0]
}
},
userId
);
} catch (notifError) {
logger.error('Failed to send password change notification', {
userId,
action: 'password_change_notification',
error: getErrorMessage(notifError)
});
// Don't fail the password update if notification fails
}
return { success: true };
},
onError: (error: unknown) => {
toast.error("Update Failed", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, { userId }) => {
invalidateAuditLogs(userId);
toast.success("Password Updated", {
description: "Your password has been successfully changed.",
});
},
});
return {
updatePassword,
isUpdating: updatePassword.isPending,
};
}

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;
}
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',

View File

@@ -7,7 +7,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
const [filters, setFilters] = useState<RideCreditFilters>({});
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 }));
}, []);