Reverted to commit 0091584677

This commit is contained in:
gpt-engineer-app[bot]
2025-11-01 15:22:30 +00:00
parent 26e5753807
commit 133141d474
125 changed files with 2316 additions and 9102 deletions

View File

@@ -30,9 +30,4 @@ VITE_ALLOW_CAPTCHA_BYPASS=false
# For self-hosted Novu, replace with your instance URLs
VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier
VITE_NOVU_SOCKET_URL=wss://ws.novu.co
VITE_NOVU_API_URL=https://api.novu.co
# Auth0 Configuration
# Get these from your Auth0 dashboard: https://manage.auth0.com
VITE_AUTH0_DOMAIN=your-tenant.auth0.com
VITE_AUTH0_CLIENT_ID=your-spa-client-id
VITE_NOVU_API_URL=https://api.novu.co

View File

@@ -1,257 +0,0 @@
# ThrillWiki Authentication System
## Overview
ThrillWiki implements a comprehensive authentication system following Supabase best practices with multiple sign-in methods, MFA support, and advanced security features.
## Supported Authentication Methods
### 1. Email & Password
- Standard email/password authentication
- Password strength validation (minimum 6 characters)
- Email confirmation required by default
- Password reset flow with secure email links
### 2. Magic Link (Passwordless)
- One-click email authentication
- No password required
- Expires after 1 hour
- Rate limited to prevent abuse (1 request per 60 seconds)
### 3. Email OTP (One-Time Password)
- 6-digit verification code sent via email
- Alternative to Magic Links
- Expires after 1 hour
- Ideal for mobile apps
### 4. OAuth Social Login
- **Google**: Full profile and email access
- **Discord**: Identity and email access
- Automatic identity linking with matching emails
- Manual identity linking for logged-in users
## Security Features
### Multi-Factor Authentication (MFA/TOTP)
- App-based authenticator (Google Authenticator, Authy, etc.)
- Required for moderators and administrators
- QR code enrollment with manual secret fallback
- AAL2 (Authenticator Assurance Level 2) enforcement
- Automatic session step-up challenges
### Session Management
- Multiple active sessions across devices
- Session expiry monitoring with proactive refresh
- Device and browser detection
- IP address tracking (last 8 chars of hash for privacy)
- Revoke individual sessions or all others
### Sign Out Options
- **Global**: Sign out from all devices (default)
- **Local**: Sign out from current device only
- **Others**: Sign out from all other devices
### Identity Management
- Link multiple OAuth providers to one account
- Disconnect OAuth identities (with safety checks)
- Add password backup to OAuth-only accounts
- Prevents account orphaning (requires at least 1 auth method)
- AAL2 required for disconnecting identities
## Implementation Details
### Critical Security Fixes
1. **Email Redirect URLs**: All signup flows include `emailRedirectTo` parameter
2. **MFA Bypass Prevention**: Canceling MFA prompt triggers forced sign-out
3. **Non-Dismissable MFA Modal**: Cannot close MFA challenge without completing
4. **Session Monitoring**: Tokens refreshed 5 minutes before expiry
### Automatic Identity Linking
When a user signs in with OAuth using an email that already exists:
- Supabase automatically links the new identity
- Removes unconfirmed identities to prevent takeover attacks
- Logs linking event to audit trail
- Only works with confirmed email addresses
### Manual Identity Linking
Users can manually link additional OAuth providers:
```typescript
import { linkOAuthIdentity } from '@/lib/identityService';
// Link a new provider
const result = await linkOAuthIdentity('google');
```
### Password Reset Flow
For OAuth-only accounts that want to add password:
```typescript
import { addPasswordToAccount } from '@/lib/identityService';
// Triggers password setup email
const result = await addPasswordToAccount();
// User receives email with link to set password
```
## Email OTP Implementation
### For Developers
1. Modify Supabase email template to include `{{ .Token }}`
2. Use the `EmailOTPInput` component:
```tsx
import { EmailOTPInput } from '@/components/auth/EmailOTPInput';
<EmailOTPInput
email={userEmail}
onVerify={async (code) => {
const { error } = await supabase.auth.verifyOtp({
email: userEmail,
token: code,
type: 'email',
});
}}
onCancel={() => setShowOTP(false)}
onResend={async () => {
await supabase.auth.signInWithOtp({ email: userEmail });
}}
/>
```
## User Flows
### Sign Up with Email
1. User enters email, password, username, display name
2. System validates email (no disposable addresses)
3. CAPTCHA verification (if enabled)
4. Confirmation email sent to user
5. User clicks link → Redirected to `/auth/callback`
6. Account confirmed and logged in
### Sign In with MFA Enrolled
1. User enters email and password
2. System authenticates credentials
3. If MFA enrolled → Shows MFA challenge
4. User enters 6-digit TOTP code
5. Session upgraded to AAL2
6. User granted full access
### Linking Additional Provider
1. User navigates to Settings → Security
2. Clicks "Connect" on desired OAuth provider
3. Completes OAuth flow with provider
4. Returns to ThrillWiki with linked identity
5. Can now sign in with either method
### Disconnecting Provider
1. User navigates to Settings → Security
2. Clicks "Disconnect" on OAuth provider
3. System verifies:
- Not the last identity
- Has password backup OR another OAuth provider
- User has AAL2 session (MFA verified if enrolled)
4. Identity disconnected and logged to audit
## Error Handling
### Common Error Codes
- `email_exists`: Email already registered
- `invalid_credentials`: Wrong email/password
- `email_not_confirmed`: Email not verified yet
- `mfa_verification_failed`: Wrong TOTP code
- `identity_already_exists`: Provider already linked
- `single_identity_not_deletable`: Cannot remove last auth method
- `insufficient_aal`: MFA verification required
### User-Friendly Messages
All errors are converted to user-friendly messages:
```typescript
import { handleError } from '@/lib/errorHandler';
try {
// Auth operation
} catch (error) {
handleError(error, { action: 'Sign In' });
// Shows toast with friendly message
}
```
## Configuration
### Email Templates
Configure in Supabase Dashboard → Authentication → Email Templates
**Magic Link Template:**
```html
<h2>Magic Link</h2>
<p>Follow this link to login:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
```
**OTP Template:**
```html
<h2>Verification Code</h2>
<p>Your verification code is:</p>
<h1 style="font-size: 32px; letter-spacing: 8px;">{{ .Token }}</h1>
<p>This code expires in 1 hour.</p>
```
### Redirect URLs
Configure in Supabase Dashboard → Authentication → URL Configuration:
- **Site URL**: `https://www.thrillwiki.com`
- **Redirect URLs**:
- `https://www.thrillwiki.com/auth/callback`
- `http://localhost:8080/auth/callback` (development)
## Best Practices
### For Users
- Enable MFA for additional security
- Link multiple providers for backup access
- Use strong, unique passwords (if using email/password)
- Review active sessions regularly
- Sign out from unfamiliar devices
### For Developers
- Always include `emailRedirectTo` in signup calls
- Validate emails to prevent disposable addresses
- Never log sensitive data (passwords, tokens)
- Use AAL2 checks for critical operations
- Implement proper error handling with user-friendly messages
- Test all authentication flows thoroughly
## Audit Logging
All authentication events are logged:
- Sign in/sign out
- MFA enrollment/verification
- Identity linking/unlinking
- Password changes
- Session revocations
Access logs via `admin_audit_log` table or Settings → Security → Sessions.
## Testing Checklist
- [ ] Sign up with email/password
- [ ] Confirm email and log in
- [ ] Reset password
- [ ] Magic link sign in
- [ ] OAuth sign in (Google)
- [ ] OAuth sign in (Discord)
- [ ] Enroll MFA
- [ ] Sign in with MFA
- [ ] Cancel MFA (should sign out)
- [ ] Link additional OAuth provider
- [ ] Disconnect OAuth provider
- [ ] Add password to OAuth-only account
- [ ] Revoke session
- [ ] Sign out (all scopes)
## Support
For issues or questions about authentication:
- Check Supabase auth logs in dashboard
- Review browser console for client errors
- Check network requests in DevTools
- Verify email template configuration
- Confirm redirect URLs are correct

61
package-lock.json generated
View File

@@ -8,8 +8,6 @@
"name": "vite_react_shadcn_ts",
"version": "0.0.0",
"dependencies": {
"@auth0/auth0-react": "^2.8.0",
"@auth0/auth0-spa-js": "^2.8.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -65,7 +63,6 @@
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jose": "^6.1.0",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
@@ -116,30 +113,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth0/auth0-react": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.8.0.tgz",
"integrity": "sha512-f3KOkq+TW7AC3T+ZAo9G0hNL339z15C9q00QDVrMGCzZAPyp8lvDHKcAs21d/u+GzhU5zmssvJTQggDR7JqxSA==",
"license": "MIT",
"dependencies": {
"@auth0/auth0-spa-js": "^2.7.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17 || ^18 || ^19",
"react-dom": "^16.11.0 || ^17 || ^18 || ^19"
}
},
"node_modules/@auth0/auth0-spa-js": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz",
"integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==",
"license": "MIT",
"dependencies": {
"browser-tabs-lock": "^1.2.15",
"dpop": "^2.1.1",
"es-cookie": "~1.3.2"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -6034,16 +6007,6 @@
"node": ">=8"
}
},
"node_modules/browser-tabs-lock": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz",
"integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"lodash": ">=4.17.21"
}
},
"node_modules/browserslist": {
"version": "4.27.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
@@ -6783,15 +6746,6 @@
"react": ">=16.12.0"
}
},
"node_modules/dpop": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
"integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6963,12 +6917,6 @@
"node": ">=10.0.0"
}
},
"node_modules/es-cookie": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
"integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -8538,15 +8486,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@@ -11,8 +11,6 @@
"preview": "vite preview"
},
"dependencies": {
"@auth0/auth0-react": "^2.8.0",
"@auth0/auth0-spa-js": "^2.8.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -68,7 +66,6 @@
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jose": "^6.1.0",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",

View File

@@ -3,12 +3,10 @@ import { lazy, Suspense } from "react";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider, QueryCache } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { CacheMonitor } from "@/components/dev/CacheMonitor";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import { Auth0Provider } from "@/contexts/Auth0Provider";
import { AuthModalProvider } from "@/contexts/AuthModalContext";
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
import { Analytics } from "@vercel/analytics/react";
@@ -63,7 +61,6 @@ const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings")
const Profile = lazy(() => import("./pages/Profile"));
const UserSettings = lazy(() => import("./pages/UserSettings"));
const AuthCallback = lazy(() => import("./pages/AuthCallback"));
const Auth0Callback = lazy(() => import("./pages/Auth0Callback"));
// Utility routes (lazy-loaded)
const NotFound = lazy(() => import("./pages/NotFound"));
@@ -80,41 +77,8 @@ const queryClient = new QueryClient({
gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache for 5 mins
},
},
// Add cache size management
queryCache: new QueryCache({
onSuccess: () => {
// Monitor cache size in development
if (import.meta.env.DEV) {
const cacheSize = queryClient.getQueryCache().getAll().length;
if (cacheSize > 100) {
console.warn(`⚠️ Query cache size: ${cacheSize} queries`);
}
}
},
}),
});
// Add cache size monitoring and automatic cleanup (dev mode)
if (import.meta.env.DEV) {
setInterval(() => {
const cache = queryClient.getQueryCache();
const queries = cache.getAll();
// Remove oldest queries if cache exceeds 250 items (increased limit)
if (queries.length > 250) {
const sortedByLastUpdated = queries
.sort((a, b) => (a.state.dataUpdatedAt || 0) - (b.state.dataUpdatedAt || 0));
const toRemove = sortedByLastUpdated.slice(0, queries.length - 200);
toRemove.forEach(query => {
queryClient.removeQueries({ queryKey: query.queryKey });
});
console.log(`🧹 Removed ${toRemove.length} stale queries from cache`);
}
}, 60000); // Check every minute
}
function AppContent(): React.JSX.Element {
return (
<TooltipProvider>
@@ -161,7 +125,6 @@ function AppContent(): React.JSX.Element {
{/* User routes - lazy loaded */}
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/auth0-callback" element={<Auth0Callback />} />
<Route path="/profile" element={<Profile />} />
<Route path="/profile/:username" element={<Profile />} />
<Route path="/settings" element={<UserSettings />} />
@@ -193,19 +156,12 @@ function AppContent(): React.JSX.Element {
const App = (): React.JSX.Element => (
<QueryClientProvider client={queryClient}>
<Auth0Provider>
<AuthProvider>
<AuthModalProvider>
<AppContent />
</AuthModalProvider>
</AuthProvider>
</Auth0Provider>
{import.meta.env.DEV && (
<>
<ReactQueryDevtools initialIsOpen={false} position="bottom" />
<CacheMonitor />
</>
)}
<AuthProvider>
<AuthModalProvider>
<AppContent />
</AuthModalProvider>
</AuthProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
<Analytics />
</QueryClientProvider>
);

View File

@@ -35,8 +35,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
const [mfaPendingUserId, setMfaPendingUserId] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
password: '',
@@ -72,57 +70,73 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
setSignInCaptchaToken(null);
try {
// Call server-side auth check with MFA detection
const { data: authResult, error: authError } = await supabase.functions.invoke(
'auth-with-mfa-check',
{
body: {
email: formData.email,
password: formData.password,
captchaToken: tokenToUse,
},
}
);
if (authError || authResult.error) {
throw new Error(authResult?.error || authError?.message || 'Authentication failed');
const signInOptions: any = {
email: formData.email,
password: formData.password,
};
if (tokenToUse) {
signInOptions.options = { captchaToken: tokenToUse };
}
// Check if user is banned
if (authResult.banned) {
const reason = authResult.banReason
? `Reason: ${authResult.banReason}`
: 'Contact support for assistance.';
const { data, error } = await supabase.auth.signInWithPassword(signInOptions);
if (error) throw error;
// CRITICAL: Check ban status immediately after successful authentication
const { data: profile } = await supabase
.from('profiles')
.select('banned, ban_reason')
.eq('user_id', data.user.id)
.single();
if (profile?.banned) {
// Sign out immediately
await supabase.auth.signOut();
const reason = profile.ban_reason
? `Reason: ${profile.ban_reason}`
: 'Contact support for assistance.';
toast({
variant: "destructive",
title: "Account Suspended",
description: `Your account has been suspended. ${reason}`,
duration: 10000,
duration: 10000
});
setLoading(false);
return;
return; // Stop authentication flow
}
// Check if MFA is required
if (authResult.mfaRequired) {
// NO SESSION EXISTS YET - show MFA challenge
console.log('[AuthModal] MFA required - no session created yet');
setMfaFactorId(authResult.factorId);
setMfaChallengeId(authResult.challengeId);
setMfaPendingUserId(authResult.userId);
setLoading(false);
return; // User has NO session - MFA modal will show
// Check if MFA is required (user exists but no session)
if (data.user && !data.session) {
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return;
}
}
// No MFA required - user has session
console.log('[AuthModal] No MFA required - user authenticated');
// Set the session in Supabase client
if (authResult.session) {
await supabase.auth.setSession(authResult.session);
}
// Track auth method for audit logging
setAuthMethod('password');
// Check if MFA step-up is required
const { handlePostAuthFlow } = await import('@/lib/authService');
const postAuthResult = await handlePostAuthFlow(data.session, 'password');
if (postAuthResult.success && postAuthResult.data.shouldRedirect) {
// Get the TOTP factor ID
const { data: factors } = await supabase.auth.mfa.listFactors();
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return; // Stay in modal, show MFA challenge
}
}
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
@@ -148,34 +162,30 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
};
const handleMfaSuccess = async () => {
console.log('[AuthModal] MFA verification succeeded - no further action needed');
// Verify AAL upgrade was successful
const { data: { session } } = await supabase.auth.getSession();
const verification = await verifyMfaUpgrade(session);
if (!verification.success) {
toast({
variant: "destructive",
title: "MFA Verification Failed",
description: verification.error || "Failed to upgrade session. Please try again."
});
// Force sign out on verification failure
await supabase.auth.signOut();
setMfaFactorId(null);
return;
}
// Clear state
setMfaFactorId(null);
setMfaChallengeId(null);
setMfaPendingUserId(null);
toast({
title: "Authentication complete",
description: "You've been signed in successfully.",
});
onOpenChange(false);
};
const handleMfaCancel = async () => {
console.log('[AuthModal] User cancelled MFA verification');
// Clear state
const handleMfaCancel = () => {
setMfaFactorId(null);
setMfaChallengeId(null);
setMfaPendingUserId(null);
setSignInCaptchaKey(prev => prev + 1);
toast({
title: "Authentication cancelled",
description: "Please sign in again when you're ready to complete two-factor authentication.",
});
};
const handleSignUp = async (e: React.FormEvent) => {
@@ -234,7 +244,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
email: formData.email,
password: formData.password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
data: {
username: formData.username,
display_name: formData.displayName
@@ -369,8 +378,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
{mfaFactorId ? (
<MFAChallenge
factorId={mfaFactorId}
challengeId={mfaChallengeId}
userId={mfaPendingUserId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>

View File

@@ -1,101 +0,0 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Mail } from 'lucide-react';
interface EmailOTPInputProps {
email: string;
onVerify: (code: string) => Promise<void>;
onCancel: () => void;
onResend: () => Promise<void>;
loading?: boolean;
}
export function EmailOTPInput({
email,
onVerify,
onCancel,
onResend,
loading = false
}: EmailOTPInputProps) {
const [code, setCode] = useState('');
const [resending, setResending] = useState(false);
const handleVerify = async () => {
if (code.length === 6) {
await onVerify(code);
}
};
const handleResend = async () => {
setResending(true);
try {
await onResend();
setCode(''); // Reset code input
} finally {
setResending(false);
}
};
return (
<div className="space-y-4">
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
We've sent a 6-digit verification code to <strong>{email}</strong>
</AlertDescription>
</Alert>
<div className="flex flex-col items-center gap-4">
<div className="text-sm text-muted-foreground text-center">
Enter the 6-digit code
</div>
<InputOTP
maxLength={6}
value={code}
onChange={setCode}
disabled={loading}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<div className="flex gap-2 w-full">
<Button
variant="outline"
onClick={onCancel}
className="flex-1"
disabled={loading || resending}
>
Cancel
</Button>
<Button
onClick={handleVerify}
className="flex-1"
disabled={code.length !== 6 || loading || resending}
>
{loading ? 'Verifying...' : 'Verify'}
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleResend}
disabled={loading || resending}
className="text-xs"
>
{resending ? 'Sending...' : "Didn't receive a code? Resend"}
</Button>
</div>
</div>
);
}

View File

@@ -5,18 +5,15 @@ import { getErrorMessage } from '@/lib/errorHandler';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Shield, AlertCircle } from 'lucide-react';
import { Shield } from 'lucide-react';
interface MFAChallengeProps {
factorId: string;
challengeId?: string | null;
userId?: string | null;
onSuccess: () => void;
onCancel: () => void;
}
export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCancel }: MFAChallengeProps) {
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
const { toast } = useToast();
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
@@ -26,38 +23,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
setLoading(true);
try {
// NEW SERVER-SIDE FLOW: If we have challengeId and userId, use edge function
if (challengeId && userId) {
const { data: result, error: verifyError } = await supabase.functions.invoke(
'verify-mfa-and-login',
{
body: {
challengeId,
factorId,
code: code.trim(),
userId,
},
}
);
if (verifyError || result.error) {
throw new Error(result?.error || verifyError?.message || 'Verification failed');
}
// Set the session in Supabase client
if (result.session) {
await supabase.auth.setSession(result.session);
}
toast({
title: "Welcome back!",
description: "MFA verification successful."
});
onSuccess();
return;
}
// OLD FLOW: For OAuth/Magic Link step-up (existing session)
// Create fresh challenge for each verification attempt
const { data: challengeData, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId });
@@ -94,14 +59,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
return (
<div className="space-y-4">
<Alert className="border-destructive/50 text-destructive dark:border-destructive dark:text-destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Security Verification Required</AlertTitle>
<AlertDescription>
Cancelling will sign you out completely. Two-factor authentication must be completed to access your account.
</AlertDescription>
</Alert>
<div className="flex items-center gap-2 text-primary">
<Shield className="w-5 h-5" />
<h3 className="font-semibold">Two-Factor Authentication</h3>
@@ -139,7 +96,7 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
className="flex-1"
disabled={loading}
>
Cancel & Sign Out
Cancel
</Button>
<Button
onClick={handleVerify}

View File

@@ -12,11 +12,7 @@ interface MFAStepUpModalProps {
export function MFAStepUpModal({ open, factorId, onSuccess, onCancel }: MFAStepUpModalProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />

View File

@@ -1,105 +0,0 @@
/**
* Migration Banner Component
*
* Alerts existing Supabase users about Auth0 migration
*/
import { useState, useEffect } from 'react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Info, X } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { isAuth0Configured } from '@/lib/auth0Config';
const DISMISSED_KEY = 'auth0_migration_dismissed';
const DISMISS_DURATION_DAYS = 7;
export function MigrationBanner() {
const { user } = useAuth();
const { data: profile } = useProfile(user?.id);
const [isDismissed, setIsDismissed] = useState(true);
useEffect(() => {
// Check if banner should be shown
if (!user || !profile || !isAuth0Configured()) {
return;
}
// Don't show if user already has Auth0 sub
if ((profile as any).auth0_sub) {
return;
}
// Check if user dismissed the banner
const dismissedUntil = localStorage.getItem(DISMISSED_KEY);
if (dismissedUntil) {
const dismissedDate = new Date(dismissedUntil);
if (dismissedDate > new Date()) {
return;
}
}
setIsDismissed(false);
}, [user, profile]);
const handleDismiss = () => {
const dismissUntil = new Date();
dismissUntil.setDate(dismissUntil.getDate() + DISMISS_DURATION_DAYS);
localStorage.setItem(DISMISSED_KEY, dismissUntil.toISOString());
setIsDismissed(true);
};
const handleMigrate = () => {
// TODO: Implement migration flow
// For now, just direct to settings
window.location.href = '/settings?tab=security';
};
if (isDismissed) {
return null;
}
return (
<Alert className="mb-4 border-blue-500 bg-blue-50 dark:bg-blue-950">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<div className="flex items-start justify-between gap-4 flex-1">
<div className="flex-1">
<AlertDescription className="text-sm">
<strong className="text-blue-900 dark:text-blue-100">
Important: Account Security Upgrade
</strong>
<p className="mt-1 text-blue-800 dark:text-blue-200">
We're migrating to Auth0 for improved security and authentication.
Your account needs to be migrated to continue using all features.
</p>
</AlertDescription>
<div className="mt-3 flex gap-2">
<Button
size="sm"
onClick={handleMigrate}
className="bg-blue-600 hover:bg-blue-700"
>
Migrate Now
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open('/docs/auth0-migration', '_blank')}
>
Learn More
</Button>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="shrink-0 h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</Alert>
);
}

View File

@@ -1,121 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useQueryClient, QueryCache } from '@tanstack/react-query';
interface InvalidationEvent {
timestamp: number;
queryKey: readonly unknown[];
reason: string;
}
/**
* CacheMonitor Component (Dev Only)
*
* Real-time cache performance monitoring for development.
* Displays total queries, stale queries, fetching queries, cache size, and invalidations.
* Only renders in development mode.
*/
export function CacheMonitor() {
const queryClient = useQueryClient();
const [stats, setStats] = useState({
totalQueries: 0,
staleQueries: 0,
fetchingQueries: 0,
cacheSize: 0,
});
const [recentInvalidations, setRecentInvalidations] = useState<InvalidationEvent[]>([]);
const invalidationsRef = useRef<InvalidationEvent[]>([]);
// Monitor cache stats
useEffect(() => {
const interval = setInterval(() => {
const cache = queryClient.getQueryCache();
const queries = cache.getAll();
setStats({
totalQueries: queries.length,
staleQueries: queries.filter(q => q.isStale()).length,
fetchingQueries: queries.filter(q => q.state.fetchStatus === 'fetching').length,
cacheSize: JSON.stringify(queries).length,
});
// Update invalidations display
setRecentInvalidations([...invalidationsRef.current]);
}, 1000);
return () => clearInterval(interval);
}, [queryClient]);
// Track invalidations
useEffect(() => {
const cache = queryClient.getQueryCache();
const unsubscribe = cache.subscribe((event) => {
if (event?.type === 'removed' || event?.type === 'updated') {
const query = event.query;
if (query && query.state.fetchStatus === 'idle' && query.isStale()) {
const invalidation: InvalidationEvent = {
timestamp: Date.now(),
queryKey: query.queryKey,
reason: event.type,
};
invalidationsRef.current = [invalidation, ...invalidationsRef.current].slice(0, 5);
}
}
});
return () => unsubscribe();
}, [queryClient]);
if (!import.meta.env.DEV) return null;
const formatQueryKey = (key: readonly unknown[]): string => {
return JSON.stringify(key).slice(0, 40) + '...';
};
const formatTime = (timestamp: number): string => {
const diff = Date.now() - timestamp;
if (diff < 1000) return 'just now';
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
return `${Math.floor(diff / 60000)}m ago`;
};
return (
<div className="fixed bottom-4 right-4 bg-black/90 text-white p-4 rounded-lg text-xs font-mono z-50 shadow-xl max-w-sm">
<h3 className="font-bold mb-3 text-primary border-b border-primary/30 pb-2">Cache Monitor</h3>
<div className="space-y-1 mb-3 pb-3 border-b border-white/10">
<div className="flex justify-between">
<span>Total Queries:</span>
<span className="text-green-400 font-bold">{stats.totalQueries}</span>
</div>
<div className="flex justify-between">
<span>Stale:</span>
<span className="text-yellow-400 font-bold">{stats.staleQueries}</span>
</div>
<div className="flex justify-between">
<span>Fetching:</span>
<span className="text-blue-400 font-bold">{stats.fetchingQueries}</span>
</div>
<div className="flex justify-between">
<span>Size:</span>
<span className="text-purple-400 font-bold">{(stats.cacheSize / 1024).toFixed(1)} KB</span>
</div>
</div>
{recentInvalidations.length > 0 && (
<div>
<h4 className="font-bold mb-2 text-accent">Recent Invalidations</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{recentInvalidations.map((inv, i) => (
<div key={i} className="text-[10px] opacity-80 border-l-2 border-accent/50 pl-2">
<div className="text-muted-foreground">{formatTime(inv.timestamp)}</div>
<div className="truncate">{formatQueryKey(inv.queryKey)}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,54 @@
import { useState, useEffect } from 'react';
import { Star, TrendingUp, Award, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Park } from '@/types/database';
import { useFeaturedParks } from '@/hooks/homepage/useFeaturedParks';
import { supabase } from '@/integrations/supabase/client';
import { getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
export function FeaturedParks() {
const { topRated, mostRides } = useFeaturedParks();
const [topRatedParks, setTopRatedParks] = useState<Park[]>([]);
const [mostRidesParks, setMostRidesParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchFeaturedParks();
}, []);
const fetchFeaturedParks = async () => {
try {
// Fetch top rated parks
const { data: topRated } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*)
`)
.order('average_rating', { ascending: false })
.limit(3);
// Fetch parks with most rides
const { data: mostRides } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*)
`)
.order('ride_count', { ascending: false })
.limit(3);
setTopRatedParks(topRated || []);
setMostRidesParks(mostRides || []);
} catch (error: unknown) {
logger.error('Failed to fetch featured parks', { error: getErrorMessage(error) });
} finally {
setLoading(false);
}
};
const FeaturedParkCard = ({ park, icon: Icon, label }: { park: Park; icon: any; label: string }) => (
<Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 cursor-pointer hover:scale-[1.02]">
@@ -63,7 +105,7 @@ export function FeaturedParks() {
</Card>
);
if (topRated.isLoading || mostRides.isLoading) {
if (loading) {
return (
<section className="py-12">
<div className="container mx-auto px-4">

View File

@@ -1,16 +1,78 @@
import { UserTopList, Park, Ride, Company } from "@/types/database";
import { useState, useEffect } from "react";
import { UserTopList, UserTopListItem, Park, Ride, Company } from "@/types/database";
import { supabase } from "@/integrations/supabase/client";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { useListItems } from "@/hooks/lists/useListItems";
interface ListDisplayProps {
list: UserTopList;
}
export function ListDisplay({ list }: ListDisplayProps) {
const { data: items, isLoading } = useListItems(list.id);
interface EnrichedListItem extends UserTopListItem {
entity?: Park | Ride | Company;
}
const getEntityUrl = (item: NonNullable<typeof items>[0]) => {
export function ListDisplay({ list }: ListDisplayProps) {
const [items, setItems] = useState<EnrichedListItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchItemsWithEntities();
}, [list.id]);
const fetchItemsWithEntities = async () => {
setLoading(true);
// First, get the list items
const { data: itemsData, error: itemsError } = await supabase
.from("user_top_list_items")
.select("*")
.eq("list_id", list.id)
.order("position", { ascending: true });
if (itemsError) {
console.error("Error fetching items:", itemsError);
setLoading(false);
return;
}
// Then, fetch the entities for each item
const enrichedItems = await Promise.all(
(itemsData as UserTopListItem[]).map(async (item) => {
let entity = null;
if (item.entity_type === "park") {
const { data } = await supabase
.from("parks")
.select("id, name, slug, park_type, location_id")
.eq("id", item.entity_id)
.single();
entity = data;
} else if (item.entity_type === "ride") {
const { data } = await supabase
.from("rides")
.select("id, name, slug, category, park_id")
.eq("id", item.entity_id)
.single();
entity = data;
} else if (item.entity_type === "company") {
const { data } = await supabase
.from("companies")
.select("id, name, slug, company_type")
.eq("id", item.entity_id)
.single();
entity = data;
}
return { ...item, entity };
})
);
setItems(enrichedItems);
setLoading(false);
};
const getEntityUrl = (item: EnrichedListItem) => {
if (!item.entity) return "#";
const entity = item.entity as { slug?: string };
@@ -27,11 +89,11 @@ export function ListDisplay({ list }: ListDisplayProps) {
return "#";
};
if (isLoading) {
if (loading) {
return <div className="text-center py-4 text-muted-foreground">Loading...</div>;
}
if (!items || items.length === 0) {
if (items.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
This list is empty. Click "Edit" to add items.

View File

@@ -1,28 +1,78 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { PhotoGrid } from '@/components/common/PhotoGrid';
import { usePhotoSubmission } from '@/hooks/moderation/usePhotoSubmission';
import type { PhotoSubmissionItem } from '@/types/photo-submissions';
import type { PhotoItem } from '@/types/photos';
import { getErrorMessage } from '@/lib/errorHandler';
interface PhotoSubmissionDisplayProps {
submissionId: string;
}
export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) {
const { data: photos, isLoading, error } = usePhotoSubmission(submissionId);
const [photos, setPhotos] = useState<PhotoSubmissionItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
if (isLoading) {
useEffect(() => {
fetchPhotos();
}, [submissionId]);
const fetchPhotos = async () => {
try {
// Step 1: Get photo_submission_id from submission_id
const { data: photoSubmission, error: photoSubmissionError } = await supabase
.from('photo_submissions')
.select('id, entity_type, title')
.eq('submission_id', submissionId)
.maybeSingle();
if (photoSubmissionError) {
throw photoSubmissionError;
}
if (!photoSubmission) {
setPhotos([]);
setLoading(false);
return;
}
// Step 2: Get photo items using photo_submission_id
const { data, error } = await supabase
.from('photo_submission_items')
.select('*')
.eq('photo_submission_id', photoSubmission.id)
.order('order_index');
if (error) {
throw error;
}
setPhotos(data || []);
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
setPhotos([]);
setError(errorMsg);
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="text-sm text-muted-foreground">Loading photos...</div>;
}
if (error) {
return (
<div className="text-sm text-destructive">
Error loading photos: {error.message}
Error loading photos: {error}
<br />
<span className="text-xs">Submission ID: {submissionId}</span>
</div>
);
}
if (!photos || photos.length === 0) {
if (photos.length === 0) {
return (
<div className="text-sm text-muted-foreground">
No photos found for this submission
@@ -32,5 +82,15 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP
);
}
return <PhotoGrid photos={photos} />;
// Convert PhotoSubmissionItem[] to PhotoItem[] for PhotoGrid
const photoItems: PhotoItem[] = photos.map(photo => ({
id: photo.id,
url: photo.cloudflare_image_url,
filename: photo.filename || `Photo ${photo.order_index + 1}`,
caption: photo.caption,
title: photo.title,
date_taken: photo.date_taken,
}));
return <PhotoGrid photos={photoItems} />;
}

View File

@@ -1,20 +1,171 @@
import { forwardRef, useImperativeHandle } from 'react';
import { useRecentActivity } from '@/hooks/moderation/useRecentActivity';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { handleError } from '@/lib/errorHandler';
import { ActivityCard } from './ActivityCard';
import { Skeleton } from '@/components/ui/skeleton';
import { Activity as ActivityIcon } from 'lucide-react';
import { smartMergeArray } from '@/lib/smartStateUpdate';
import { useAdminSettings } from '@/hooks/useAdminSettings';
interface ActivityItem {
id: string;
type: 'submission' | 'report' | 'review';
action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged';
entity_type?: string;
entity_name?: string;
timestamp: string;
moderator_id?: string;
moderator?: {
username: string;
display_name?: string;
avatar_url?: string;
};
}
export interface RecentActivityRef {
refresh: () => void;
}
export const RecentActivity = forwardRef<RecentActivityRef>((props, ref) => {
const { data: activities = [], isLoading: loading, refetch } = useRecentActivity();
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const [isSilentRefresh, setIsSilentRefresh] = useState(false);
const { user } = useAuth();
const { getAutoRefreshStrategy } = useAdminSettings();
const refreshStrategy = getAutoRefreshStrategy();
useImperativeHandle(ref, () => ({
refresh: refetch
refresh: () => fetchRecentActivity(false)
}));
const fetchRecentActivity = async (silent = false) => {
if (!user) return;
try {
if (!silent) {
setLoading(true);
} else {
setIsSilentRefresh(true);
}
// Fetch recent approved/rejected submissions
const { data: submissions, error: submissionsError } = await supabase
.from('content_submissions')
.select('id, status, reviewed_at, reviewer_id, submission_type')
.in('status', ['approved', 'rejected'])
.not('reviewed_at', 'is', null)
.order('reviewed_at', { ascending: false })
.limit(15);
if (submissionsError) throw submissionsError;
// Fetch recent report resolutions
const { data: reports, error: reportsError } = await supabase
.from('reports')
.select('id, status, reviewed_at, reviewed_by, reported_entity_type')
.in('status', ['reviewed', 'dismissed'])
.not('reviewed_at', 'is', null)
.order('reviewed_at', { ascending: false })
.limit(15);
if (reportsError) throw reportsError;
// Fetch recent review moderations
const { data: reviews, error: reviewsError } = await supabase
.from('reviews')
.select('id, moderation_status, moderated_at, moderated_by, park_id, ride_id')
.in('moderation_status', ['approved', 'rejected', 'flagged'])
.not('moderated_at', 'is', null)
.order('moderated_at', { ascending: false })
.limit(15);
if (reviewsError) throw reviewsError;
// Get unique moderator IDs
const moderatorIds = [
...(submissions?.map(s => s.reviewer_id).filter(Boolean) || []),
...(reports?.map(r => r.reviewed_by).filter(Boolean) || []),
...(reviews?.map(r => r.moderated_by).filter(Boolean) || []),
].filter((id, index, arr) => id && arr.indexOf(id) === index);
// Fetch moderator profiles
const { data: profiles } = await supabase
.from('profiles')
.select('user_id, username, display_name, avatar_url')
.in('user_id', moderatorIds);
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
// Combine all activities
const allActivities: ActivityItem[] = [
...(submissions?.map(s => ({
id: s.id,
type: 'submission' as const,
action: s.status as 'approved' | 'rejected',
entity_type: s.submission_type,
timestamp: s.reviewed_at!,
moderator_id: s.reviewer_id,
moderator: s.reviewer_id ? profileMap.get(s.reviewer_id) : undefined,
})) || []),
...(reports?.map(r => ({
id: r.id,
type: 'report' as const,
action: r.status as 'reviewed' | 'dismissed',
entity_type: r.reported_entity_type,
timestamp: r.reviewed_at!,
moderator_id: r.reviewed_by,
moderator: r.reviewed_by ? profileMap.get(r.reviewed_by) : undefined,
})) || []),
...(reviews?.map(r => ({
id: r.id,
type: 'review' as const,
action: r.moderation_status as 'approved' | 'rejected' | 'flagged',
timestamp: r.moderated_at!,
moderator_id: r.moderated_by,
moderator: r.moderated_by ? profileMap.get(r.moderated_by) : undefined,
})) || []),
];
// Sort by timestamp (newest first)
allActivities.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
const recentActivities = allActivities.slice(0, 20); // Keep top 20 most recent
// Use smart merging for silent refreshes if strategy is 'merge'
if (silent && refreshStrategy === 'merge') {
const mergeResult = smartMergeArray(activities, recentActivities, {
compareFields: ['timestamp', 'action'],
preserveOrder: false,
addToTop: true,
});
if (mergeResult.hasChanges) {
setActivities(mergeResult.items);
}
} else {
// Full replacement for non-silent refreshes or 'replace' strategy
setActivities(recentActivities);
}
} catch (error: unknown) {
handleError(error, {
action: 'Load Recent Activity',
userId: user?.id
});
} finally {
if (!silent) {
setLoading(false);
}
setIsSilentRefresh(false);
}
};
useEffect(() => {
fetchRecentActivity(false);
}, [user]);
if (loading) {
return (
<div className="space-y-4">

View File

@@ -19,8 +19,10 @@ import {
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useReportMutation } from '@/hooks/reports/useReportMutation';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
interface ReportButtonProps {
entityType: 'review' | 'profile' | 'content_submission';
@@ -40,23 +42,42 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
const [open, setOpen] = useState(false);
const [reportType, setReportType] = useState('');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const { user } = useAuth();
const reportMutation = useReportMutation();
const { toast } = useToast();
const handleSubmit = () => {
const handleSubmit = async () => {
if (!user || !reportType) return;
reportMutation.mutate(
{ entityType, entityId, reportType, reason },
{
onSuccess: () => {
setOpen(false);
setReportType('');
setReason('');
},
}
);
setLoading(true);
try {
const { error } = await supabase.from('reports').insert({
reporter_id: user.id,
reported_entity_type: entityType,
reported_entity_id: entityId,
report_type: reportType,
reason: reason.trim() || null,
});
if (error) throw error;
toast({
title: "Report Submitted",
description: "Thank you for your report. We'll review it shortly.",
});
setOpen(false);
setReportType('');
setReason('');
} catch (error: unknown) {
toast({
title: "Error",
description: getErrorMessage(error),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
if (!user) return null;
@@ -115,10 +136,10 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
</Button>
<Button
onClick={handleSubmit}
disabled={!reportType || reportMutation.isPending}
disabled={!reportType || loading}
variant="destructive"
>
{reportMutation.isPending ? 'Submitting...' : 'Submit Report'}
{loading ? 'Submitting...' : 'Submit Report'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -24,7 +24,6 @@ import { useAuth } from '@/hooks/useAuth';
import { useIsMobile } from '@/hooks/use-mobile';
import { smartMergeArray } from '@/lib/smartStateUpdate';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useReportActionMutation } from '@/hooks/reports/useReportActionMutation';
// Type-safe reported content interfaces
interface ReportedReview {
@@ -116,7 +115,6 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [newReportsCount, setNewReportsCount] = useState(0);
const { user } = useAuth();
const { resolveReport, isResolving } = useReportActionMutation();
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -348,29 +346,67 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
};
}, [user, refreshMode, pollInterval, isInitialLoad]);
const handleReportAction = (reportId: string, action: 'reviewed' | 'dismissed') => {
const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => {
setActionLoading(reportId);
resolveReport.mutate(
{ reportId, action },
{
onSuccess: () => {
// Remove report from queue
setReports(prev => {
const newReports = prev.filter(r => r.id !== reportId);
// If last item on page and not page 1, go to previous page
if (newReports.length === 0 && currentPage > 1) {
setCurrentPage(prev => prev - 1);
try {
// Fetch full report details including reporter_id 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 (user && 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
}
return newReports;
});
setActionLoading(null);
},
onError: () => {
setActionLoading(null);
} catch (auditError) {
console.error('Failed to log report action audit:', auditError);
}
}
);
handleSuccess(`Report ${action}`, `The report has been marked as ${action}`);
// Remove report from queue
setReports(prev => {
const newReports = prev.filter(r => r.id !== reportId);
// If last item on page and not page 1, go to previous page
if (newReports.length === 0 && currentPage > 1) {
setCurrentPage(prev => prev - 1);
}
return newReports;
});
} catch (error: unknown) {
handleError(error, {
action: `${action === 'reviewed' ? 'Resolve' : 'Dismiss'} Report`,
userId: user?.id,
metadata: { reportId, action }
});
} finally {
setActionLoading(null);
}
};
// Sort reports function

View File

@@ -9,11 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { useUserRoles } from '@/hooks/users/useUserRoles';
import { useUserSearch } from '@/hooks/users/useUserSearch';
import { useRoleMutations } from '@/hooks/users/useRoleMutations';
// Type-safe role definitions
const VALID_ROLES = ['admin', 'moderator', 'user'] as const;
@@ -55,36 +52,175 @@ interface UserRole {
};
}
export function UserRoleManager() {
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [newUserSearch, setNewUserSearch] = useState('');
const [newRole, setNewRole] = useState('');
const [selectedUsers, setSelectedUsers] = useState<ProfileSearchResult[]>([]);
const { user } = useAuth();
const { isAdmin, isSuperuser } = useUserRole();
const { data: userRoles = [], isLoading: loading } = useUserRoles();
const { data: searchResults = [] } = useUserSearch(newUserSearch);
const { grantRole, revokeRole } = useRoleMutations();
const handleGrantRole = () => {
const selectedUser = selectedUsers[0];
if (!selectedUser || !newRole || !isValidRole(newRole)) return;
const [searchResults, setSearchResults] = useState<ProfileSearchResult[]>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const {
user
} = useAuth();
const {
isAdmin,
isSuperuser,
permissions
} = useUserRole();
const fetchUserRoles = async () => {
try {
const {
data,
error
} = await supabase.from('user_roles').select(`
id,
user_id,
role,
granted_at
`).order('granted_at', {
ascending: false
});
if (error) throw error;
grantRole.mutate(
{ userId: selectedUser.user_id, role: newRole },
{
onSuccess: () => {
setNewUserSearch('');
setNewRole('');
setSelectedUsers([]);
},
// Get unique user IDs
const userIds = [...new Set((data || []).map(r => r.user_id))];
// Fetch user profiles with emails (for admins)
let profiles: Array<{ user_id: string; username: string; display_name?: string }> | null = null;
const { data: allProfiles, error: rpcError } = await supabase
.rpc('get_users_with_emails');
if (rpcError) {
logger.warn('Failed to fetch users with emails, using basic profiles', { error: getErrorMessage(rpcError) });
const { data: basicProfiles } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.in('user_id', userIds);
profiles = basicProfiles as typeof profiles;
} else {
profiles = allProfiles?.filter(p => userIds.includes(p.user_id)) || null;
}
);
};
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
const handleRevokeRole = (roleId: string) => {
revokeRole.mutate({ roleId });
// Combine data with profiles
const userRolesWithProfiles = (data || []).map(role => ({
...role,
profiles: profileMap.get(role.user_id)
}));
setUserRoles(userRolesWithProfiles);
} catch (error: unknown) {
handleError(error, {
action: 'Load User Roles',
userId: user?.id
});
} finally {
setLoading(false);
}
};
const searchUsers = async (search: string) => {
if (!search.trim()) {
setSearchResults([]);
return;
}
try {
let data;
const { data: allUsers, error: rpcError } = await supabase
.rpc('get_users_with_emails');
if (rpcError) {
logger.warn('Failed to fetch users with emails, using basic profiles', { error: getErrorMessage(rpcError) });
const { data: basicProfiles, error: profilesError } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.ilike('username', `%${search}%`);
if (profilesError) throw profilesError;
data = basicProfiles?.slice(0, 10);
} else {
// Filter by search term
data = allUsers?.filter(user =>
user.username.toLowerCase().includes(search.toLowerCase()) ||
user.display_name?.toLowerCase().includes(search.toLowerCase())
).slice(0, 10);
}
// Filter out users who already have roles
const existingUserIds = userRoles.map(ur => ur.user_id);
const filteredResults = (data || []).filter(profile => !existingUserIds.includes(profile.user_id));
setSearchResults(filteredResults);
} catch (error: unknown) {
logger.error('User search failed', { error: getErrorMessage(error) });
}
};
useEffect(() => {
fetchUserRoles();
}, []);
useEffect(() => {
const debounceTimer = setTimeout(() => {
searchUsers(newUserSearch);
}, 300);
return () => clearTimeout(debounceTimer);
}, [newUserSearch, userRoles]);
const grantRole = async (userId: string, role: ValidRole) => {
if (!isAdmin()) return;
// Double-check role validity before database operation
if (!isValidRole(role)) {
handleError(new Error('Invalid role'), {
action: 'Grant Role',
userId: user?.id,
metadata: { targetUserId: userId, attemptedRole: role }
});
return;
}
setActionLoading('grant');
try {
const {
error
} = await supabase.from('user_roles').insert([{
user_id: userId,
role,
granted_by: user?.id
}]);
if (error) throw error;
handleSuccess('Role Granted', `User has been granted ${getRoleLabel(role)} role`);
setNewUserSearch('');
setNewRole('');
setSearchResults([]);
fetchUserRoles();
} catch (error: unknown) {
handleError(error, {
action: 'Grant Role',
userId: user?.id,
metadata: { targetUserId: userId, role }
});
} finally {
setActionLoading(null);
}
};
const revokeRole = async (roleId: string) => {
if (!isAdmin()) return;
setActionLoading(roleId);
try {
const {
error
} = await supabase.from('user_roles').delete().eq('id', roleId);
if (error) throw error;
handleSuccess('Role Revoked', 'User role has been revoked');
fetchUserRoles();
} catch (error: unknown) {
handleError(error, {
action: 'Revoke Role',
userId: user?.id,
metadata: { roleId }
});
} finally {
setActionLoading(null);
}
};
if (!isAdmin()) {
return <div className="text-center py-8">
<Shield className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
@@ -99,17 +235,7 @@ export function UserRoleManager() {
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>;
}
// Filter existing user IDs for search results
const existingUserIds = userRoles.map(ur => ur.user_id);
const availableSearchResults = searchResults.filter(
profile => !existingUserIds.includes(profile.user_id)
);
const filteredRoles = userRoles.filter(
role =>
role.username?.toLowerCase().includes(searchTerm.toLowerCase()) ||
role.display_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredRoles = userRoles.filter(role => role.profiles?.username?.toLowerCase().includes(searchTerm.toLowerCase()) || role.profiles?.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) || role.role.toLowerCase().includes(searchTerm.toLowerCase()));
return <div className="space-y-6">
{/* Add new role */}
<Card>
@@ -123,10 +249,10 @@ export function UserRoleManager() {
<Input id="user-search" placeholder="Search by username or display name..." value={newUserSearch} onChange={e => setNewUserSearch(e.target.value)} className="pl-10" />
</div>
{availableSearchResults.length > 0 && <div className="mt-2 border rounded-lg bg-background">
{availableSearchResults.map(profile => <div key={profile.user_id} className="p-3 hover:bg-muted/50 cursor-pointer border-b last:border-b-0" onClick={() => {
{searchResults.length > 0 && <div className="mt-2 border rounded-lg bg-background">
{searchResults.map(profile => <div key={profile.user_id} className="p-3 hover:bg-muted/50 cursor-pointer border-b last:border-b-0" onClick={() => {
setNewUserSearch(profile.display_name || profile.username);
setSelectedUsers([profile]);
setSearchResults([profile]);
}}>
<div className="font-medium">
{profile.display_name || profile.username}
@@ -152,13 +278,23 @@ export function UserRoleManager() {
</div>
</div>
<Button
onClick={handleGrantRole}
disabled={!newRole || !isValidRole(newRole) || selectedUsers.length === 0 || grantRole.isPending}
className="w-full md:w-auto"
>
<UserPlus className="w-4 h-4 mr-2" />
{grantRole.isPending ? 'Granting...' : 'Grant Role'}
<Button onClick={() => {
const selectedUser = searchResults.find(p => (p.display_name || p.username) === newUserSearch);
// Type-safe validation before calling grantRole
if (selectedUser && newRole && isValidRole(newRole)) {
grantRole(selectedUser.user_id, newRole);
} else if (selectedUser && newRole) {
// This should never happen due to Select component constraints,
// but provides safety in case of UI bugs
handleError(new Error('Invalid role selected'), {
action: 'Grant Role',
userId: user?.id,
metadata: { selectedUser: selectedUser?.user_id, newRole }
});
}
}} disabled={!newRole || !isValidRole(newRole) || !searchResults.find(p => (p.display_name || p.username) === newUserSearch) || actionLoading === 'grant'} className="w-full md:w-auto">
{actionLoading === 'grant' ? 'Granting...' : 'Grant Role'}
</Button>
</CardContent>
</Card>
@@ -185,31 +321,21 @@ export function UserRoleManager() {
<div className="flex items-center gap-3">
<div>
<div className="font-medium">
{userRole.display_name || userRole.username}
{userRole.profiles?.display_name || userRole.profiles?.username}
</div>
{userRole.display_name && <div className="text-sm text-muted-foreground">
@{userRole.username}
</div>}
{userRole.email && <div className="text-xs text-muted-foreground">
{userRole.email}
{userRole.profiles?.display_name && <div className="text-sm text-muted-foreground">
@{userRole.profiles.username}
</div>}
</div>
<Badge variant={userRole.id === 'admin' ? 'default' : 'secondary'}>
{getRoleLabel(userRole.id)}
<Badge variant={userRole.role === 'admin' ? 'default' : 'secondary'}>
{userRole.role}
</Badge>
</div>
{/* Only show revoke button if current user can manage this role */}
{(isSuperuser() || (isAdmin() && !['admin', 'superuser'].includes(userRole.id))) && (
<Button
variant="outline"
size="sm"
onClick={() => handleRevokeRole(userRole.id)}
disabled={revokeRole.isPending}
>
{(isSuperuser() || isAdmin() && !['admin', 'superuser'].includes(userRole.role)) && <Button variant="outline" size="sm" onClick={() => revokeRole(userRole.id)} disabled={actionLoading === userRole.id}>
<X className="w-4 h-4" />
</Button>
)}
</Button>}
</CardContent>
</Card>)}
</div>

View File

@@ -1,61 +1,19 @@
import { MapPin, Star, Users, Clock, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Park } from '@/types/database';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { queryKeys } from '@/lib/queryKeys';
import { supabase } from '@/integrations/supabase/client';
interface ParkCardProps {
park: Park;
}
export function ParkCard({ park }: ParkCardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleClick = () => {
navigate(`/parks/${park.slug}`);
};
// Smart prefetch - only if not already cached
const handleMouseEnter = () => {
// Check if already cached before prefetching
const detailCached = queryClient.getQueryData(queryKeys.parks.detail(park.slug));
const photosCached = queryClient.getQueryData(queryKeys.photos.entity('park', park.id));
if (!detailCached) {
queryClient.prefetchQuery({
queryKey: queryKeys.parks.detail(park.slug),
queryFn: async () => {
const { data } = await supabase
.from('parks')
.select('*')
.eq('slug', park.slug)
.single();
return data;
},
staleTime: 5 * 60 * 1000,
});
}
if (!photosCached) {
queryClient.prefetchQuery({
queryKey: queryKeys.photos.entity('park', park.id),
queryFn: async () => {
const { data } = await supabase
.from('photos')
.select('*')
.eq('entity_type', 'park')
.eq('entity_id', park.id)
.limit(10);
return data;
},
staleTime: 5 * 60 * 1000,
});
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'operating':
@@ -98,7 +56,7 @@ export function ParkCard({ park }: ParkCardProps) {
const formatParkType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
return <Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300" onClick={handleClick} onMouseEnter={handleMouseEnter}>
return <Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300" onClick={handleClick}>
<div className="relative overflow-hidden">
{/* Image Placeholder with Gradient */}
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 flex items-center justify-center relative">

View File

@@ -1,18 +1,153 @@
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 { useBlockedUsers } from '@/hooks/privacy/useBlockedUsers';
import { useBlockUserMutation } from '@/hooks/privacy/useBlockUserMutation';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import type { UserBlock } from '@/types/privacy';
export function BlockedUsers() {
const { user } = useAuth();
const { data: blockedUsers = [], isLoading: loading } = useBlockedUsers(user?.id);
const { unblockUser, isUnblocking } = useBlockUserMutation();
const [blockedUsers, setBlockedUsers] = useState<UserBlock[]>([]);
const [loading, setLoading] = useState(true);
const handleUnblock = (blockId: string, blockedUserId: string, username: string) => {
unblockUser.mutate({ blockId, blockedUserId, username });
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 }
});
}
};
if (loading) {
@@ -76,7 +211,7 @@ export function BlockedUsers() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={isUnblocking}>
<Button variant="outline" size="sm">
<Trash2 className="w-4 h-4 mr-1" />
Unblock
</Button>

View File

@@ -13,7 +13,6 @@ 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,
@@ -40,7 +39,6 @@ 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 {
@@ -248,16 +246,24 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
}
};
const handleReorder = (creditId: string, newPosition: number) => {
return new Promise<void>((resolve, reject) => {
reorderCredit.mutate(
{ creditId, newPosition },
{
onSuccess: () => resolve(),
onError: (error) => reject(error)
}
);
});
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 handleDragEnd = async (event: DragEndEvent) => {

View File

@@ -17,8 +17,6 @@ import { StarRating } from './StarRating';
import { toDateOnly } from '@/lib/dateUtils';
import { getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
const reviewSchema = z.object({
rating: z.number().min(0.5).max(5).multipleOf(0.5),
title: z.string().optional(),
@@ -43,7 +41,6 @@ export function ReviewForm({
const {
user
} = useAuth();
const { invalidateEntityReviews } = useQueryInvalidation();
const [rating, setRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [photos, setPhotos] = useState<string[]>([]);
@@ -121,10 +118,6 @@ export function ReviewForm({
title: "Review Submitted!",
description: "Thank you for your review. It will be published after moderation."
});
// Invalidate review cache for instant UI update
invalidateEntityReviews(entityType, entityId);
reset();
setRating(0);
setPhotos([]);

View File

@@ -1,13 +1,10 @@
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Star, MapPin, Clock, Zap, FerrisWheel, Waves, Theater, Train, ArrowUp, CheckCircle, Calendar, Hammer, XCircle } from 'lucide-react';
import { MeasurementDisplay } from '@/components/ui/measurement-display';
import { Ride } from '@/types/database';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { queryKeys } from '@/lib/queryKeys';
import { supabase } from '@/integrations/supabase/client';
interface RideCardProps {
ride: Ride;
@@ -18,47 +15,11 @@ interface RideCardProps {
export function RideCard({ ride, showParkName = true, className, parkSlug }: RideCardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleRideClick = () => {
const slug = parkSlug || ride.park?.slug;
navigate(`/parks/${slug}/rides/${ride.slug}`);
};
// Prefetch ride detail data on hover
const handleMouseEnter = () => {
const slug = parkSlug || ride.park?.slug;
if (!slug) return;
// Prefetch ride detail page data
queryClient.prefetchQuery({
queryKey: queryKeys.rides.detail(slug, ride.slug),
queryFn: async () => {
const { data } = await supabase
.from('rides')
.select('*')
.eq('slug', ride.slug)
.single();
return data;
},
staleTime: 5 * 60 * 1000,
});
// Prefetch ride photos (first 10)
queryClient.prefetchQuery({
queryKey: queryKeys.photos.entity('ride', ride.id),
queryFn: async () => {
const { data } = await supabase
.from('photos')
.select('*')
.eq('entity_type', 'ride')
.eq('entity_id', ride.id)
.limit(10);
return data;
},
staleTime: 5 * 60 * 1000,
});
};
const getRideIcon = (category: string) => {
switch (category) {
@@ -100,7 +61,6 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid
<Card
className={`group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300 ${className}`}
onClick={handleRideClick}
onMouseEnter={handleMouseEnter}
>
<div className="relative overflow-hidden">
{/* Image/Icon Section */}

View File

@@ -3,10 +3,7 @@ import { Badge } from '@/components/ui/badge';
import { FerrisWheel } from 'lucide-react';
import { RideModel } from '@/types/database';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { queryKeys } from '@/lib/queryKeys';
import { supabase } from '@/integrations/supabase/client';
interface RideModelCardProps {
model: RideModel;
@@ -15,23 +12,6 @@ interface RideModelCardProps {
export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
// Prefetch ride model detail data on hover
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: queryKeys.rideModels.detail(manufacturerSlug, model.slug),
queryFn: async () => {
const { data } = await supabase
.from('ride_models')
.select('*')
.eq('slug', model.slug)
.single();
return data;
},
staleTime: 5 * 60 * 1000,
});
};
const formatCategory = (category: string | null | undefined) => {
if (!category) return 'Unknown';
@@ -62,7 +42,6 @@ export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
<Card
className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300"
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models/${model.slug}`)}
onMouseEnter={handleMouseEnter}
>
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 relative overflow-hidden">
{(cardImageUrl || cardImageId) ? (

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { RideCard } from '@/components/rides/RideCard';
import { useSimilarRides } from '@/hooks/rides/useSimilarRides';
interface SimilarRidesProps {
currentRideId: string;
@@ -31,9 +32,44 @@ interface SimilarRide {
}
export function SimilarRides({ currentRideId, parkId, parkSlug, category }: SimilarRidesProps) {
const { data: rides, isLoading } = useSimilarRides(currentRideId, parkId, category);
const [rides, setRides] = useState<SimilarRide[]>([]);
const [loading, setLoading] = useState(true);
if (isLoading || !rides || rides.length === 0) {
useEffect(() => {
async function fetchSimilarRides() {
const { data, error } = await supabase
.from('rides')
.select(`
id,
name,
slug,
image_url,
average_rating,
status,
category,
description,
max_speed_kmh,
max_height_meters,
duration_seconds,
review_count,
park:parks!inner(name, slug)
`)
.eq('park_id', parkId)
.eq('category', category)
.neq('id', currentRideId)
.order('average_rating', { ascending: false })
.limit(4);
if (!error && data) {
setRides(data);
}
setLoading(false);
}
fetchSimilarRides();
}, [currentRideId, parkId, category]);
if (loading || rides.length === 0) {
return null;
}

View File

@@ -28,7 +28,6 @@ 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';
@@ -43,7 +42,7 @@ type ProfileFormData = z.infer<typeof profileSchema>;
export function AccountProfileTab() {
const { user, pendingEmail, clearPendingEmail } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const updateProfileMutation = useProfileUpdateMutation();
const [loading, setLoading] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEmailDialog, setShowEmailDialog] = useState(false);
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
@@ -108,28 +107,47 @@ export function AccountProfileTab() {
const handleFormSubmit = async (data: ProfileFormData) => {
if (!user) return;
// 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,
});
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.'}`
);
}
await refreshProfile();
throw error;
}
});
// 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) => {
@@ -382,17 +400,17 @@ export function AccountProfileTab() {
<Button
type="submit"
disabled={
updateProfileMutation.isPending ||
loading ||
isDeactivated ||
isSaving ||
usernameValidation.isChecking ||
usernameValidation.isAvailable === false
}
>
{updateProfileMutation.isPending || isSaving ? 'Saving...' : 'Save Changes'}
{loading || isSaving ? 'Saving...' : 'Save Changes'}
</Button>
{lastSaved && !updateProfileMutation.isPending && !isSaving && (
{lastSaved && !loading && !isSaving && (
<span className="text-sm text-muted-foreground">
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
</span>

View File

@@ -1,176 +0,0 @@
/**
* Auth0 MFA Settings Component
*
* Display MFA status and provide enrollment/unenrollment options via Auth0
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Shield, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useAuth0 } from '@auth0/auth0-react';
import { getMFAStatus, triggerMFAEnrollment } from '@/lib/auth0Management';
import { useToast } from '@/hooks/use-toast';
import type { Auth0MFAStatus } from '@/types/auth0';
export function Auth0MFASettings() {
const { user, isAuthenticated } = useAuth0();
const [mfaStatus, setMfaStatus] = useState<Auth0MFAStatus | null>(null);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
const fetchMFAStatus = async () => {
if (!isAuthenticated || !user?.sub) {
setLoading(false);
return;
}
try {
const status = await getMFAStatus(user.sub);
setMfaStatus(status);
} catch (error) {
console.error('Error fetching MFA status:', error);
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to load MFA status',
});
} finally {
setLoading(false);
}
};
fetchMFAStatus();
}, [isAuthenticated, user, toast]);
const handleEnroll = async () => {
try {
await triggerMFAEnrollment('/settings?tab=security');
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to start MFA enrollment',
});
}
};
if (loading) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account with two-factor authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* MFA Status */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
{mfaStatus?.enrolled ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-amber-500" />
)}
<div>
<p className="font-medium">
MFA Status
</p>
<p className="text-sm text-muted-foreground">
{mfaStatus?.enrolled
? 'Multi-factor authentication is active'
: 'MFA is not enabled on your account'}
</p>
</div>
</div>
<Badge variant={mfaStatus?.enrolled ? 'default' : 'secondary'}>
{mfaStatus?.enrolled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
{/* Enrolled Methods */}
{mfaStatus?.enrolled && mfaStatus.methods.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Active Methods:</p>
<div className="space-y-2">
{mfaStatus.methods.map((method) => (
<div
key={method.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium capitalize">{method.type}</p>
{method.name && (
<p className="text-xs text-muted-foreground">{method.name}</p>
)}
</div>
<Badge variant={method.confirmed ? 'default' : 'secondary'}>
{method.confirmed ? 'Active' : 'Pending'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{mfaStatus?.enrolled ? (
<>
MFA is managed through Auth0. To add or remove authentication methods,
click the button below to manage your MFA settings.
</>
) : (
<>
Enable MFA to protect your account with an additional security layer.
You'll be redirected to Auth0 to set up your preferred authentication method.
</>
)}
</AlertDescription>
</Alert>
{/* Action Buttons */}
<div className="flex gap-2">
{!mfaStatus?.enrolled ? (
<Button onClick={handleEnroll} className="w-full">
Enable MFA
</Button>
) : (
<Button onClick={handleEnroll} variant="outline" className="w-full">
Manage MFA Settings
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -2,7 +2,6 @@ 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,
@@ -53,7 +52,6 @@ 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>('');
@@ -158,18 +156,63 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
throw signInError;
}
// Step 3: Update email address using mutation hook
changeEmail.mutate(
{ newEmail: data.newEmail, currentEmail, userId },
{
onSuccess: () => {
setStep('success');
},
onError: (error) => {
throw error;
}
// 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(),
}
}).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 { useState } from 'react';
import { useEffect, 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,7 +8,6 @@ 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;
@@ -16,19 +15,55 @@ 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 verificationStatus = {
oldEmailVerified: emailStatus?.current_email_verified || false,
newEmailVerified: emailStatus?.new_email_verified || false
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);
}
};
useEffect(() => {
checkVerificationStatus();
// Poll every 30 seconds
const interval = setInterval(checkVerificationStatus, 30000);
return () => clearInterval(interval);
}, []);
const handleResendVerification = async () => {
setResending(true);
try {
@@ -53,7 +88,7 @@ export function EmailChangeStatus({
(verificationStatus.oldEmailVerified ? 50 : 0) +
(verificationStatus.newEmailVerified ? 50 : 0);
if (isLoading) {
if (loading) {
return (
<Card className="border-blue-500/30">
<CardContent className="flex items-center justify-center py-8">

View File

@@ -1,225 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Link, Unlink, Shield, AlertCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
getUserIdentities,
disconnectIdentity,
linkOAuthIdentity,
checkDisconnectSafety,
addPasswordToAccount
} from '@/lib/identityService';
import type { UserIdentity, OAuthProvider } from '@/types/identity';
export function IdentityManagement() {
const { toast } = useToast();
const [identities, setIdentities] = useState<UserIdentity[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
loadIdentities();
}, []);
const loadIdentities = async () => {
setLoading(true);
const data = await getUserIdentities();
setIdentities(data);
setLoading(false);
};
const handleDisconnect = async (provider: OAuthProvider) => {
// Safety check
const safety = await checkDisconnectSafety(provider);
if (!safety.canDisconnect) {
toast({
variant: 'destructive',
title: 'Cannot Disconnect',
description: safety.reason === 'last_identity'
? 'This is your only sign-in method. Add a password or another provider first.'
: 'Please add a password before disconnecting your last social login.',
});
return;
}
setActionLoading(provider);
const result = await disconnectIdentity(provider);
if (result.success) {
toast({
title: 'Provider Disconnected',
description: `${provider} has been removed from your account.`,
});
await loadIdentities();
} else if (result.requiresAAL2) {
toast({
variant: 'destructive',
title: 'MFA Required',
description: result.error || 'Please verify your identity with MFA.',
});
} else {
toast({
variant: 'destructive',
title: 'Failed to Disconnect',
description: result.error,
});
}
setActionLoading(null);
};
const handleLink = async (provider: OAuthProvider) => {
setActionLoading(provider);
const result = await linkOAuthIdentity(provider);
if (result.success) {
// OAuth redirect will happen automatically
toast({
title: 'Redirecting...',
description: `Opening ${provider} sign-in window...`,
});
} else {
toast({
variant: 'destructive',
title: 'Failed to Link',
description: result.error,
});
setActionLoading(null);
}
};
const handleAddPassword = async () => {
setActionLoading('password');
const result = await addPasswordToAccount();
if (result.success) {
toast({
title: 'Check Your Email',
description: `We've sent a password setup link to ${result.email}`,
});
} else {
toast({
variant: 'destructive',
title: 'Failed to Add Password',
description: result.error,
});
}
setActionLoading(null);
};
const hasProvider = (provider: string) =>
identities.some(i => i.provider === provider);
const hasPassword = hasProvider('email');
const providers: { id: OAuthProvider; label: string; icon: string }[] = [
{ id: 'google', label: 'Google', icon: 'G' },
{ id: 'discord', label: 'Discord', icon: 'D' },
];
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Connected Accounts</CardTitle>
<CardDescription>Loading...</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link className="w-5 h-5" />
Connected Accounts
</CardTitle>
<CardDescription>
Link multiple sign-in methods to your account for easy access
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{identities.length === 1 && !hasPassword && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Add a password as a backup sign-in method
</AlertDescription>
</Alert>
)}
{/* Password Authentication */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Shield className="w-5 h-5 text-primary" />
</div>
<div>
<div className="font-medium">Email & Password</div>
<div className="text-sm text-muted-foreground">
{hasPassword ? 'Connected' : 'Not set up'}
</div>
</div>
</div>
{!hasPassword && (
<Button
variant="outline"
size="sm"
onClick={handleAddPassword}
disabled={actionLoading === 'password'}
>
<Link className="w-4 h-4 mr-2" />
{actionLoading === 'password' ? 'Setting up...' : 'Add Password'}
</Button>
)}
</div>
{/* OAuth Providers */}
{providers.map((provider) => {
const isConnected = hasProvider(provider.id);
return (
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center font-bold">
{provider.icon}
</div>
<div>
<div className="font-medium">{provider.label}</div>
<div className="text-sm text-muted-foreground">
{isConnected ? 'Connected' : 'Not connected'}
</div>
</div>
</div>
<Button
variant={isConnected ? 'destructive' : 'outline'}
size="sm"
onClick={() => isConnected
? handleDisconnect(provider.id)
: handleLink(provider.id)
}
disabled={actionLoading === provider.id}
>
{isConnected ? (
<>
<Unlink className="w-4 h-4 mr-2" />
{actionLoading === provider.id ? 'Disconnecting...' : 'Disconnect'}
</>
) : (
<>
<Link className="w-4 h-4 mr-2" />
{actionLoading === provider.id ? 'Connecting...' : 'Connect'}
</>
)}
</Button>
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -13,7 +13,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
import { useProfileLocationMutation } from '@/hooks/profile/useProfileLocationMutation';
import { supabase } from '@/integrations/supabase/client';
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
@@ -31,8 +30,8 @@ export function LocationTab() {
const { user } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences();
const { updateLocation, isUpdating } = useProfileLocationMutation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [parks, setParks] = useState<ParkOption[]>([]);
const [accessibility, setAccessibility] = useState<AccessibilityOptions>(DEFAULT_ACCESSIBILITY_OPTIONS);
@@ -172,11 +171,42 @@ export function LocationTab() {
const onSubmit = async (data: LocationFormData) => {
if (!user) return;
setSaving(true);
try {
const validatedData = locationFormSchema.parse(data);
const validatedAccessibility = accessibilityOptionsSchema.parse(accessibility);
// Update accessibility preferences first
const previousProfile = {
personal_location: profile?.personal_location,
home_park_id: profile?.home_park_id,
timezone: profile?.timezone,
preferred_language: profile?.preferred_language,
preferred_pronouns: profile?.preferred_pronouns
};
const { error: profileError } = await supabase
.from('profiles')
.update({
preferred_pronouns: validatedData.preferred_pronouns || null,
timezone: validatedData.timezone,
preferred_language: validatedData.preferred_language,
personal_location: validatedData.personal_location || null,
home_park_id: validatedData.home_park_id || null,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (profileError) {
logger.error('Failed to update profile', {
userId: user.id,
action: 'update_profile_location',
error: profileError.message,
errorCode: profileError.code
});
throw profileError;
}
const { error: accessibilityError } = await supabase
.from('user_preferences')
.update({
@@ -197,20 +227,34 @@ export function LocationTab() {
await updateUnitPreferences(unitPreferences);
// Update profile via mutation hook with complete validated data
const locationData: LocationFormData = {
personal_location: validatedData.personal_location || null,
home_park_id: validatedData.home_park_id || null,
timezone: validatedData.timezone,
preferred_language: validatedData.preferred_language,
preferred_pronouns: validatedData.preferred_pronouns || null,
};
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,
accessibility: DEFAULT_ACCESSIBILITY_OPTIONS
},
updated: {
profile: validatedData,
accessibility: validatedAccessibility
},
timestamp: new Date().toISOString()
}))
}]);
updateLocation.mutate(locationData, {
onSuccess: () => {
refreshProfile();
}
await refreshProfile();
logger.info('Location and info settings updated', {
userId: user.id,
action: 'update_location_info'
});
handleSuccess(
'Settings saved',
'Your location, personal information, accessibility, and unit preferences have been updated.'
);
} catch (error: unknown) {
logger.error('Error saving location settings', {
userId: user.id,
@@ -233,6 +277,8 @@ export function LocationTab() {
userId: user.id
});
}
} finally {
setSaving(false);
}
};
@@ -512,8 +558,8 @@ export function LocationTab() {
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save Settings'}
<Button type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</form>

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,7 +45,6 @@ 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>('');
@@ -289,26 +288,62 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
try {
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 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
}
});
// 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

@@ -11,7 +11,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { usePrivacyMutations } from '@/hooks/privacy/usePrivacyMutations';
import { supabase } from '@/integrations/supabase/client';
import { Eye, UserX, Shield, Search } from 'lucide-react';
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
@@ -22,7 +21,7 @@ import { z } from 'zod';
export function PrivacyTab() {
const { user } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const { updatePrivacy, isUpdating } = usePrivacyMutations();
const [loading, setLoading] = useState(false);
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
const form = useForm<PrivacyFormData>({
@@ -135,17 +134,106 @@ export function PrivacyTab() {
}
};
const onSubmit = (data: PrivacyFormData) => {
const onSubmit = async (data: PrivacyFormData) => {
if (!user) return;
updatePrivacy.mutate(data, {
onSuccess: () => {
refreshProfile();
// Extract privacy settings (exclude profile fields)
const { privacy_level, show_pronouns, ...privacySettings } = data;
setPreferences(privacySettings);
setLoading(true);
try {
// Validate the form data
const validated = privacyFormSchema.parse(data);
// Update profile privacy settings
const { error: profileError } = await supabase
.from('profiles')
.update({
privacy_level: validated.privacy_level,
show_pronouns: validated.show_pronouns,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (profileError) {
logger.error('Failed to update profile privacy', {
userId: user.id,
action: 'update_profile_privacy',
error: profileError.message,
errorCode: profileError.code
});
throw profileError;
}
});
// Extract privacy settings (exclude profile fields)
const { privacy_level, show_pronouns, ...privacySettings } = validated;
// 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) {
logger.error('Failed to update privacy preferences', {
userId: user.id,
action: 'update_privacy_preferences',
error: prefsError.message,
errorCode: prefsError.code
});
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: JSON.parse(JSON.stringify({
previous: preferences,
updated: privacySettings,
timestamp: new Date().toISOString()
}))
}]);
await refreshProfile();
setPreferences(privacySettings);
logger.info('Privacy settings updated successfully', {
userId: user.id,
action: 'update_privacy_settings'
});
handleSuccess(
'Privacy settings updated',
'Your privacy preferences have been successfully saved.'
);
} catch (error: unknown) {
logger.error('Failed to update privacy settings', {
userId: user.id,
action: 'update_privacy_settings',
error: error instanceof Error ? error.message : String(error)
});
if (error instanceof z.ZodError) {
handleError(
new AppError(
'Invalid privacy settings',
'VALIDATION_ERROR',
error.issues.map(e => e.message).join(', ')
),
{ action: 'Validate privacy settings', userId: user.id }
);
} else {
handleError(error, {
action: 'Update privacy settings',
userId: user.id
});
}
} finally {
setLoading(false);
}
};
return (
@@ -362,8 +450,8 @@ export function PrivacyTab() {
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save Privacy Settings'}
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Privacy Settings'}
</Button>
</div>
</form>

View File

@@ -6,7 +6,6 @@ import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useAuth } from '@/hooks/useAuth';
import { useSecurityMutations } from '@/hooks/security/useSecurityMutations';
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Skeleton } from '@/components/ui/skeleton';
@@ -14,7 +13,6 @@ 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,
@@ -31,21 +29,20 @@ import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog';
export function SecurityTab() {
const { user } = useAuth();
const navigate = useNavigate();
const { revokeSession, isRevoking } = useSecurityMutations();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [identities, setIdentities] = useState<UserIdentity[]>([]);
const [loadingIdentities, setLoadingIdentities] = useState(true);
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 () => {
@@ -146,6 +143,35 @@ 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();
@@ -156,23 +182,33 @@ export function SecurityTab() {
setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession });
};
const confirmRevokeSession = () => {
const confirmRevokeSession = async () => {
if (!sessionToRevoke) return;
revokeSession.mutate(
{ sessionId: sessionToRevoke.id, isCurrent: sessionToRevoke.isCurrent },
{
onSuccess: () => {
if (!sessionToRevoke.isCurrent) {
refetchSessions();
}
setSessionToRevoke(null);
},
onError: () => {
setSessionToRevoke(null);
}
const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionToRevoke.id });
if (error) {
logger.error('Failed to revoke session', {
userId: user?.id,
action: 'revoke_session',
sessionId: sessionToRevoke.id,
error: error.message
});
handleError(error, { action: 'Revoke session', userId: user?.id });
} else {
handleSuccess('Success', 'Session revoked successfully');
if (sessionToRevoke.isCurrent) {
// Redirect to login after revoking current session
setTimeout(() => {
window.location.href = '/auth';
}, 1000);
} else {
fetchSessions();
}
);
}
setSessionToRevoke(null);
};
const getDeviceIcon = (userAgent: string | null) => {
@@ -261,77 +297,77 @@ export function SecurityTab() {
</CardContent>
</Card>
{/* Identity Management Section */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5" />
<CardTitle>Connected Accounts</CardTitle>
</div>
<CardDescription>
Manage your social login connections for easier access to your account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadingIdentities ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
connectedAccounts.map(account => {
const isConnected = !!account.identity;
const isDisconnecting = disconnectingProvider === account.provider;
const email = account.identity?.identity_data?.email;
{/* Connected Accounts */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5" />
<CardTitle>Connected Accounts</CardTitle>
</div>
<CardDescription>
Manage your social login connections for easier access to your account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadingIdentities ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
connectedAccounts.map(account => {
const isConnected = !!account.identity;
const isDisconnecting = disconnectingProvider === account.provider;
const email = account.identity?.identity_data?.email;
return (
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
{account.icon}
return (
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
{account.icon}
</div>
<div>
<p className="font-medium capitalize">{account.provider}</p>
{isConnected && email && (
<p className="text-sm text-muted-foreground">{email}</p>
)}
</div>
</div>
<div>
<p className="font-medium capitalize">{account.provider}</p>
{isConnected && email && (
<p className="text-sm text-muted-foreground">{email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Badge variant="secondary">Connected</Badge>
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Badge variant="secondary">Connected</Badge>
<Button
variant="outline"
size="sm"
onClick={() => handleUnlinkSocial(account.provider)}
disabled={isDisconnecting}
>
{isDisconnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Disconnecting...
</>
) : (
'Disconnect'
)}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleUnlinkSocial(account.provider)}
disabled={isDisconnecting}
onClick={() => handleSocialLogin(account.provider)}
>
{isDisconnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Disconnecting...
</>
) : (
'Disconnect'
)}
Connect
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleSocialLogin(account.provider)}
>
Connect
</Button>
)}
)}
</div>
</div>
</div>
);
})
)}
</CardContent>
</Card>
);
})
)}
</CardContent>
</Card>
</div>
{/* Two-Factor Authentication - Full Width */}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -14,10 +14,11 @@ import { useNavigate } from 'react-router-dom';
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
import { PhotoModal } from '@/components/moderation/PhotoModal';
import { supabase } from '@/integrations/supabase/client';
import { EntityPhotoGalleryProps } from '@/types/submissions';
import { useUserRole } from '@/hooks/useUserRole';
import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
import { getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
interface Photo {
id: string;
@@ -37,25 +38,47 @@ export function EntityPhotoGallery({
const { user } = useAuth();
const navigate = useNavigate();
const { isModerator } = useUserRole();
const [photos, setPhotos] = useState<Photo[]>([]);
const [showUpload, setShowUpload] = useState(false);
const [showManagement, setShowManagement] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest');
// Use optimized photos hook with caching
const { data: photos = [], isLoading: loading, refetch } = useEntityPhotos(
entityType,
entityId,
sortBy
);
useEffect(() => {
fetchPhotos();
}, [entityId, entityType, sortBy]);
// Query invalidation for cross-component cache updates
const {
invalidateEntityPhotos,
invalidatePhotoCount,
invalidateHomepageData
} = useQueryInvalidation();
const fetchPhotos = async () => {
try {
// Fetch photos directly from the photos table
const { data: photoData, error } = await supabase
.from('photos')
.select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('created_at', { ascending: sortBy === 'oldest' });
if (error) throw error;
// Map to Photo interface
const mappedPhotos: Photo[] = photoData?.map((photo) => ({
id: photo.id,
url: photo.cloudflare_image_url,
caption: photo.caption || undefined,
title: photo.title || undefined,
user_id: photo.submitted_by,
created_at: photo.created_at,
})) || [];
setPhotos(mappedPhotos);
} catch (error: unknown) {
logger.error('Failed to fetch photos', { error: getErrorMessage(error), entityId, entityType });
} finally {
setLoading(false);
}
};
const handleUploadClick = () => {
if (!user) {
@@ -67,14 +90,7 @@ export function EntityPhotoGallery({
const handleSubmissionComplete = () => {
setShowUpload(false);
// Invalidate all related caches
invalidateEntityPhotos(entityType, entityId);
invalidatePhotoCount(entityType, entityId);
invalidateHomepageData(); // Photos affect homepage stats
// Also refetch local component (immediate UI update)
refetch();
fetchPhotos(); // Refresh photos after submission
};
const handlePhotoClick = (index: number) => {
@@ -174,7 +190,7 @@ export function EntityPhotoGallery({
entityType={entityType}
open={showManagement}
onOpenChange={setShowManagement}
onUpdate={() => refetch()}
onUpdate={fetchPhotos}
/>
{/* Photo Grid */}

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useEntityName } from '@/hooks/entities/useEntityName';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -61,9 +60,6 @@ export function PhotoManagementDialog({
const [photoToDelete, setPhotoToDelete] = useState<Photo | null>(null);
const [deleteReason, setDeleteReason] = useState('');
const { toast } = useToast();
// Fetch entity name once using cached hook (replaces 4 sequential direct queries)
const { data: entityName = 'Unknown' } = useEntityName(entityType, entityId);
useEffect(() => {
if (open) {
@@ -110,6 +106,27 @@ export function PhotoManagementDialog({
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Fetch entity name from database based on entity type
let entityName = 'Unknown';
try {
if (entityType === 'park') {
const { data } = await supabase.from('parks').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (entityType === 'ride') {
const { data } = await supabase.from('rides').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (entityType === 'ride_model') {
const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
const { data } = await supabase.from('companies').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
}
} catch (err) {
logger.error('Failed to fetch entity name', { error: getErrorMessage(err), entityType, entityId });
}
// Create content submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')

View File

@@ -1,35 +0,0 @@
/**
* Auth0 Provider Wrapper
*
* Wraps the Auth0Provider from @auth0/auth0-react with our configuration
*/
import { Auth0Provider as Auth0ProviderBase } from '@auth0/auth0-react';
import { auth0Config, isAuth0Configured } from '@/lib/auth0Config';
import { ReactNode } from 'react';
interface Auth0ProviderWrapperProps {
children: ReactNode;
}
export function Auth0Provider({ children }: Auth0ProviderWrapperProps) {
// If Auth0 is not configured, render children without Auth0 provider
// This allows gradual migration from Supabase auth
if (!isAuth0Configured()) {
console.warn('[Auth0] Auth0 not configured, skipping Auth0 provider');
return <>{children}</>;
}
return (
<Auth0ProviderBase
domain={auth0Config.domain}
clientId={auth0Config.clientId}
authorizationParams={auth0Config.authorizationParams}
cacheLocation={auth0Config.cacheLocation}
useRefreshTokens={auth0Config.useRefreshTokens}
useRefreshTokensFallback={auth0Config.useRefreshTokensFallback}
>
{children}
</Auth0ProviderBase>
);
}

View File

@@ -1,371 +0,0 @@
# API and Cache Patterns
## Mutation Pattern (PREFERRED)
Always use `useMutation` hooks for data modifications instead of direct Supabase calls.
### ✅ CORRECT Pattern
```typescript
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';
export function useMyMutation() {
const queryClient = useQueryClient();
const { invalidateRelatedCache } = useQueryInvalidation();
return useMutation({
mutationFn: async (params) => {
const { error } = await supabase
.from('table')
.insert(params);
if (error) throw error;
},
onMutate: async (params) => {
// Optional: Optimistic updates
await queryClient.cancelQueries({ queryKey: ['my-data'] });
const previous = queryClient.getQueryData(['my-data']);
queryClient.setQueryData(['my-data'], (old) => {
// Update optimistically
});
return { previous };
},
onError: (error, variables, context) => {
// Rollback optimistic updates
if (context?.previous) {
queryClient.setQueryData(['my-data'], context.previous);
}
toast.error("Error", {
description: getErrorMessage(error),
});
},
onSuccess: () => {
invalidateRelatedCache();
toast.success("Success", {
description: "Operation completed successfully.",
});
},
});
}
```
### ❌ INCORRECT Pattern (Direct Supabase)
```typescript
// DON'T DO THIS
const handleSubmit = async () => {
try {
const { error } = await supabase.from('table').insert(data);
if (error) throw error;
toast.success('Success');
} catch (error) {
toast.error(error.message);
}
};
```
## Error Handling Pattern
### ✅ CORRECT: Use onError callback
```typescript
const mutation = useMutation({
mutationFn: async (data) => {
const { error } = await supabase.from('table').insert(data);
if (error) throw error;
},
onError: (error: unknown) => {
toast.error("Error", {
description: getErrorMessage(error),
});
},
});
```
### ❌ INCORRECT: try/catch in component
```typescript
// Avoid this pattern
const handleSubmit = async () => {
try {
await mutation.mutateAsync(data);
} catch (error) {
// Error already handled in mutation
}
};
```
## Query Keys Pattern
### ✅ CORRECT: Use centralized queryKeys
```typescript
import { queryKeys } from '@/lib/queryKeys';
const { data } = useQuery({
queryKey: queryKeys.parks.detail(slug),
queryFn: fetchParkDetail,
});
```
### ❌ INCORRECT: Inline query keys
```typescript
// Don't do this
const { data } = useQuery({
queryKey: ['parks', 'detail', slug],
queryFn: fetchParkDetail,
});
```
## Cache Invalidation Pattern
### ✅ CORRECT: Use invalidation helpers
```typescript
import { useQueryInvalidation } from '@/lib/queryInvalidation';
const { invalidateParks, invalidateHomepageData } = useQueryInvalidation();
// In mutation onSuccess:
onSuccess: () => {
invalidateParks();
invalidateHomepageData('parks');
}
```
### ❌ INCORRECT: Manual invalidation
```typescript
// Avoid this
queryClient.invalidateQueries({ queryKey: ['parks'] });
```
## Benefits of This Pattern
1. **Automatic retry logic**: Failed mutations can be retried automatically
2. **Loading states**: `isPending` flag for UI feedback
3. **Optimistic updates**: Update UI before server confirms
4. **Consistent error handling**: Centralized error handling
5. **Cache coordination**: Proper invalidation timing
6. **Testing**: Easier to mock and test
7. **Type safety**: Better TypeScript support
## Migration Checklist
When migrating a component to use mutation hooks:
- [ ] **Identify direct Supabase calls** - Find all `.from()`, `.update()`, `.insert()`, `.delete()` calls
- [ ] **Create or use existing mutation hook** - Check if hook exists in `src/hooks/` first
- [ ] **Import the hook** - `import { useMutationName } from '@/hooks/.../useMutationName'`
- [ ] **Replace async function** - Change from `async () => { await supabase... }` to `mutation.mutate()`
- [ ] **Remove manual error handling** - Delete `try/catch` blocks, use `onError` callback instead
- [ ] **Remove loading states** - Replace with `mutation.isPending`
- [ ] **Remove success toasts** - Handled by mutation's `onSuccess` callback
- [ ] **Verify cache invalidation** - Ensure mutation calls correct `invalidate*` helpers
- [ ] **Test optimistic updates** - Verify UI updates immediately and rolls back on error
- [ ] **Remove manual audit logs** - Most mutations handle this automatically
- [ ] **Test error scenarios** - Ensure proper error messages and rollback behavior
### Example Migration
**Before:**
```tsx
const [loading, setLoading] = useState(false);
const handleUpdate = async () => {
setLoading(true);
try {
const { error } = await supabase.from('table').update(data);
if (error) throw error;
toast.success('Updated!');
queryClient.invalidateQueries(['data']);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setLoading(false);
}
};
```
**After:**
```tsx
const { updateData, isUpdating } = useDataMutation();
const handleUpdate = () => {
updateData.mutate(data);
};
```
## 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()` and `useBlockedUsers()`
- `PasswordUpdateDialog.tsx` - Using `usePasswordUpdateMutation()`
- `EmailChangeDialog.tsx` - Using `useEmailChangeMutation()`
### 📊 Impact
- **100%** of settings mutations now use mutation hooks
- **100%** consistent error handling across all mutations
- **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
- [ ] Implement `onSuccess` callback with cache invalidation
- [ ] Use centralized `queryKeys` for query identification
- [ ] Use `useQueryInvalidation` helpers for cache management
- [ ] 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
- [ ] 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, 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, 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, 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
- **`useReportActionMutation`** - Resolve/dismiss reports
- Invalidates: moderation queue, moderation stats, audit logs
- Features: Automatic audit logging
### 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
## 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
- **`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
Always invalidate related caches after mutations:
```typescript
// After profile update
invalidateUserProfile(userId);
invalidateProfileStats(userId);
invalidateProfileActivity(userId);
invalidateUserSearch(); // If username/display name changed
// After privacy update
invalidateUserProfile(userId);
invalidateAuditLogs(userId);
invalidateUserSearch(); // If privacy level changed
// After report action
invalidateModerationQueue();
invalidateModerationStats();
invalidateAuditLogs();
// After security action
invalidateSessions();
invalidateAuditLogs();
invalidateEmailChangeStatus(); // For email changes
```

View File

@@ -1,506 +0,0 @@
# Cache Debugging Guide
## Quick Diagnosis Checklist
### Symptom: Stale Data Showing in UI
**Quick Checks:**
1. ✅ Is React Query DevTools showing the query?
2. ✅ What's the `dataUpdatedAt` timestamp?
3. ✅ Are there any failed network requests in browser DevTools?
4. ✅ Is the cache key correct for the data you're expecting?
**Common Causes:**
- Cache not being invalidated after mutation
- Wrong cache key being used
- Realtime subscription not connected
- Optimistic update not rolled back on error
### Symptom: Data Loads Slowly
**Quick Checks:**
1. ✅ Check cache hit/miss in React Query DevTools
2. ✅ Look for duplicate queries with same key
3. ✅ Check if `staleTime` is too short
4. ✅ Verify network tab for slow API calls
**Common Causes:**
- Cache misses due to incorrect keys
- Too aggressive invalidation
- Missing cache-first strategies
- No prefetching for predictable navigation
### Symptom: Data Not Updating After Mutation
**Quick Checks:**
1. ✅ Check if mutation `onSuccess` is firing
2. ✅ Verify correct invalidation helper called
3. ✅ Check React Query DevTools for query state
4. ✅ Look for JavaScript errors in console
**Common Causes:**
- Missing cache invalidation in mutation
- Wrong query key in invalidation
- Error in mutation preventing `onSuccess`
- Realtime subscription overwriting changes
## Using React Query DevTools
### Installation Check
```typescript
// Already installed in src/App.tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// In development, you'll see floating React Query icon in bottom-right
```
### Key Features
#### 1. Query Explorer
- See all active queries and their states
- Check `status`: `'success'`, `'error'`, `'pending'`
- View `dataUpdatedAt` to see when data was last fetched
- Inspect `staleTime` and `cacheTime` settings
#### 2. Query Details
Click on any query to see:
- **Data**: The actual cached data
- **Query Key**: The exact key used (useful for debugging invalidation)
- **Observers**: How many components are using this query
- **Last Updated**: Timestamp of last successful fetch
#### 3. Mutations Tab
- See all recent mutations
- Check success/error states
- View mutation variables
- Inspect error messages
### Debugging Workflow
```mermaid
graph TD
A[Issue Reported] --> B{Data Stale?}
B -->|Yes| C[Open DevTools]
C --> D[Find Query by Key]
D --> E{Query Exists?}
E -->|No| F[Check Component Using Correct Key]
E -->|Yes| G{Data Correct?}
G -->|Yes| H[Check dataUpdatedAt Timestamp]
G -->|No| I[Check Last Mutation]
I --> J{Mutation Succeeded?}
J -->|Yes| K[Check Invalidation Called]
J -->|No| L[Check Error in Mutations Tab]
K --> M{Correct Keys Invalidated?}
M -->|No| N[Fix Invalidation Keys]
M -->|Yes| O[Check Realtime Subscription]
```
## Common Issues & Solutions
### Issue 1: Profile Data Not Updating
**Symptoms:**
- Changed profile but old data still showing
- Avatar or display name stuck on old value
**Debug Steps:**
```typescript
// 1. Open DevTools, find query
['profile', userId]
// 2. Check mutation hook
useProfileUpdateMutation()
// 3. Verify invalidation in onSuccess
invalidateUserProfile(userId);
invalidateProfileStats(userId);
invalidateProfileActivity(userId);
```
**Solution:**
```typescript
// Make sure all profile queries invalidated
onSuccess: (_data, { userId }) => {
invalidateUserProfile(userId);
invalidateProfileStats(userId);
invalidateProfileActivity(userId);
// If username changed, also invalidate search
if (updates.username) {
invalidateUserSearch();
}
}
```
### Issue 2: List Not Refreshing After Create/Update
**Symptoms:**
- Created new park/ride but not in list
- Edited entity but changes not showing in list
**Debug Steps:**
```typescript
// 1. Check what queries are active for list
['parks'] // All parks
['parks', 'owner', ownerSlug] // Owner's parks
['parks', parkSlug, 'rides'] // Park's rides
// 2. Verify mutation invalidates list
onSuccess: () => {
invalidateParks(); // Global lists
invalidateParkDetail(parkSlug); // Specific park
invalidateHomepage(); // Recent changes
}
```
**Solution:**
```typescript
// Invalidate ALL affected lists
onSuccess: () => {
invalidateParks(); // Global list
invalidateParkRides(parkSlug); // Park's rides list
invalidateRideDetail(rideSlug); // Detail page
invalidateHomepage(); // Homepage feed
}
```
### Issue 3: Realtime Not Working
**Symptoms:**
- Changes by other users not appearing
- Manual refresh required to see updates
**Debug Steps:**
```typescript
// 1. Check browser console for subscription errors
// Look for: "Realtime subscription error"
// 2. Check useRealtimeSubscriptions hook
// Verify subscription is active
// 3. Check Supabase dashboard for realtime status
```
**Solution:**
```typescript
// Check table has realtime enabled in Supabase
// Verify RLS policies allow SELECT
// Ensure subscription filters match your queries
// In useRealtimeSubscriptions.ts:
channel
.on('postgres_changes',
{
event: '*',
schema: 'public',
table: 'parks' // Correct table name
},
(payload) => {
// Invalidation happens here
}
)
```
### Issue 4: Optimistic Update Not Rolling Back
**Symptoms:**
- UI shows change but it failed
- Error toast shown but UI not reverted
**Debug Steps:**
```typescript
// 1. Check mutation has onError handler
onError: (error, variables, context) => {
// Should rollback here
}
// 2. Check context has previousData
onMutate: async (variables) => {
const previousData = queryClient.getQueryData(queryKey);
return { previousData }; // Must return this
}
// 3. Check rollback logic
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
```
**Solution:**
```typescript
export function useMyMutation() {
return useMutation({
mutationFn: async (data) => { /* ... */ },
onMutate: async (variables) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey });
// Get previous data
const previousData = queryClient.getQueryData(queryKey);
// Optimistically update
queryClient.setQueryData(queryKey, newData);
// MUST return previous data
return { previousData };
},
onError: (error, variables, context) => {
// Rollback using context
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
toast.error("Failed", { description: getErrorMessage(error) });
}
});
}
```
### Issue 5: Queries Firing Too Often
**Symptoms:**
- Too many network requests
- Poor performance
- Rate limiting errors
**Debug Steps:**
```typescript
// 1. Check staleTime setting
useQuery({
queryKey,
queryFn,
staleTime: 1000 * 60 * 5 // 5 minutes - good
// staleTime: 0 // BAD - refetches constantly
})
// 2. Check for duplicate queries
// Open DevTools, look for same key multiple times
// 3. Check refetchOnWindowFocus
useQuery({
queryKey,
queryFn,
refetchOnWindowFocus: false // Disable if not needed
})
```
**Solution:**
```typescript
// Set appropriate staleTime
Profile data: 5 minutes
List data: 2 minutes
Static data: 10 minutes
Real-time data: 30 seconds
// Example:
useQuery({
queryKey: ['profile', userId],
queryFn: fetchProfile,
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: true, // Good for user data
})
```
## Cache Monitoring Tools
### Built-in Monitoring
**File**: `src/lib/cacheMonitoring.ts`
```typescript
import { cacheMonitor } from '@/lib/cacheMonitoring';
// Start monitoring
cacheMonitor.start();
// Get metrics
const metrics = cacheMonitor.getMetrics();
console.log('Cache hit rate:', metrics.hitRate);
console.log('Avg query time:', metrics.avgQueryTime);
// Log slow queries
cacheMonitor.onSlowQuery((queryKey, duration) => {
console.warn('Slow query:', queryKey, duration);
});
```
### Manual Cache Inspection
```typescript
import { useQueryClient } from '@tanstack/react-query';
function DebugComponent() {
const queryClient = useQueryClient();
// Get all queries
const queries = queryClient.getQueryCache().getAll();
console.log('Active queries:', queries.length);
// Get specific query data
const profileData = queryClient.getQueryData(['profile', userId]);
console.log('Profile data:', profileData);
// Check query state
const query = queryClient.getQueryState(['profile', userId]);
console.log('Query state:', query?.status);
console.log('Data updated at:', query?.dataUpdatedAt);
return null;
}
```
## Advanced Debugging Techniques
### 1. Network Tab Analysis
**Chrome DevTools → Network Tab**
Look for:
- Duplicate requests to same endpoint
- Slow API responses (>500ms)
- Failed requests (4xx, 5xx errors)
- Request timing (waiting, download time)
Filter by:
- `supabase` - See all Supabase calls
- `rpc` - See database function calls
- `rest` - See table queries
### 2. Console Logging
```typescript
// Add temporary logging to mutation
onSuccess: (data, variables) => {
console.group('Mutation Success');
console.log('Data:', data);
console.log('Variables:', variables);
console.log('Invalidating:', ['profile', variables.userId]);
console.groupEnd();
// Your invalidation code
}
```
### 3. React DevTools Profiler
1. Open React DevTools
2. Go to Profiler tab
3. Click record
4. Perform action
5. Stop recording
6. Analyze which components re-rendered
Look for:
- Unnecessary re-renders
- Slow components (>16ms)
- Deep component trees
### 4. Performance Monitoring
```typescript
// Add performance marks
performance.mark('query-start');
const data = await queryFn();
performance.mark('query-end');
performance.measure('query', 'query-start', 'query-end');
// Log slow queries
const entries = performance.getEntriesByName('query');
entries.forEach(entry => {
if (entry.duration > 500) {
console.warn('Slow query:', entry.duration);
}
});
```
## Preventive Measures
### Code Review Checklist
When adding new mutations:
- [ ] Has proper `onSuccess` with invalidation
- [ ] Has `onError` with rollback
- [ ] Uses `onMutate` for optimistic updates (if applicable)
- [ ] Invalidates all affected queries
- [ ] Uses centralized invalidation helpers
- [ ] Has proper TypeScript types
- [ ] Includes error handling with toast
When adding new queries:
- [ ] Uses proper query key from `queryKeys.ts`
- [ ] Has appropriate `staleTime`
- [ ] Has `enabled` flag if conditional
- [ ] Returns typed data
- [ ] Handles loading and error states
### Testing Strategy
```typescript
// Test cache invalidation
it('should invalidate cache after mutation', async () => {
const { result } = renderHook(() => useProfileUpdateMutation());
const queryClient = new QueryClient();
// Spy on invalidation
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
await result.current.mutateAsync({ userId, updates });
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ['profile', userId]
});
});
```
## When to Ask for Help
If you've tried the above and still have issues:
1. **Gather information:**
- React Query DevTools screenshots
- Network tab screenshots
- Console error messages
- Steps to reproduce
2. **Check documentation:**
- [API_PATTERNS.md](./API_PATTERNS.md)
- [CACHE_INVALIDATION_GUIDE.md](./CACHE_INVALIDATION_GUIDE.md)
- [PRODUCTION_READY.md](./PRODUCTION_READY.md)
3. **Create issue with:**
- Clear description
- Expected vs actual behavior
- Debugging steps already tried
- Relevant code snippets
---
## Quick Reference
### Essential Tools
- React Query DevTools: Visual query inspector
- Browser Network Tab: API call monitoring
- Console Logging: Quick debugging
- `cacheMonitoring.ts`: Performance metrics
### Common Commands
```typescript
// Manually invalidate
queryClient.invalidateQueries({ queryKey: ['profile'] });
// Manually refetch
queryClient.refetchQueries({ queryKey: ['profile'] });
// Clear cache
queryClient.clear();
// Get cache data
queryClient.getQueryData(['profile', userId]);
```
### Debug Mode
```typescript
// Enable React Query debugging
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Always show (not just in dev)
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
```

View File

@@ -1,519 +0,0 @@
# Cache Invalidation Quick Reference Guide
## Decision Tree
```mermaid
graph TD
A[Data Changed] --> B{What Changed?}
B -->|Profile| C[Profile Invalidation]
B -->|Park| D[Park Invalidation]
B -->|Ride| E[Ride Invalidation]
B -->|Company| F[Company Invalidation]
B -->|User Action| G[Security Invalidation]
C --> C1[invalidateUserProfile]
C --> C2[invalidateProfileStats]
C --> C3[invalidateProfileActivity]
C --> C4{Name Changed?}
C4 -->|Yes| C5[invalidateUserSearch]
D --> D1[invalidateParks]
D --> D2[invalidateParkDetail]
D --> D3[invalidateParkRides]
D --> D4[invalidateHomepage]
E --> E1[invalidateRides]
E --> E2[invalidateRideDetail]
E --> E3[invalidateParkRides]
E --> E4[invalidateHomepage]
F --> F1[invalidateCompanies]
F --> F2[invalidateCompanyDetail]
G --> G1[invalidateUserProfile]
G --> G2[invalidateAuditLogs]
```
## Quick Lookup Table
| Action | Invalidate These | Helper Function |
|--------|------------------|-----------------|
| **Update Profile** | User profile, stats, activity | `invalidateUserProfile()`, `invalidateProfileStats()`, `invalidateProfileActivity()` |
| **Change Username** | + User search | `invalidateUserSearch()` |
| **Change Avatar** | User profile only | `invalidateUserProfile()` |
| **Update Privacy** | Profile, search | `invalidateUserProfile()`, `invalidateUserSearch()` |
| **Create Park** | All parks, homepage | `invalidateParks()`, `invalidateHomepage()` |
| **Update Park** | Parks, detail, homepage | `invalidateParks()`, `invalidateParkDetail()`, `invalidateHomepage()` |
| **Delete Park** | Parks, rides, homepage | `invalidateParks()`, `invalidateParkRides()`, `invalidateHomepage()` |
| **Create Ride** | Rides, park rides, homepage | `invalidateRides()`, `invalidateParkRides()`, `invalidateHomepage()` |
| **Update Ride** | Rides, detail, park rides, homepage | `invalidateRides()`, `invalidateRideDetail()`, `invalidateParkRides()`, `invalidateHomepage()` |
| **Delete Ride** | Rides, park rides, homepage | `invalidateRides()`, `invalidateParkRides()`, `invalidateHomepage()` |
| **Create Company** | All companies | `invalidateCompanies()` |
| **Update Company** | Companies, detail | `invalidateCompanies()`, `invalidateCompanyDetail()` |
| **Block User** | User profile, blocklist, search | `invalidateUserProfile()`, `invalidateBlockedUsers()`, `invalidateUserSearch()` |
| **Unblock User** | Same as block | Same as block |
| **Change Password** | Sessions (optional) | `invalidateSessions()` |
| **Change Email** | Profile, email status | `invalidateUserProfile()`, `invalidateEmailStatus()` |
| **Update Role** | Profile, permissions | `invalidateUserProfile()` |
| **Submit Moderation** | Queue, entity | `invalidateModerationQueue()`, `invalidate[Entity]()` |
| **Approve/Reject** | Queue, entity, homepage | `invalidateModerationQueue()`, `invalidate[Entity]()`, `invalidateHomepage()` |
| **Add Ride Credit** | Ride credits, profile stats | `invalidateRideCredits()`, `invalidateProfileStats()` |
| **Reorder Credits** | Ride credits only | `invalidateRideCredits()` |
## Common Patterns
### Pattern 1: Simple Entity Update
**Use Case**: Updating a single entity with no relations
```typescript
export function useSimpleUpdateMutation() {
const { invalidateEntity } = useQueryInvalidation();
return useMutation({
mutationFn: updateEntity,
onSuccess: (_data, { slug }) => {
invalidateEntity(slug);
toast.success("Updated");
}
});
}
```
**Examples**: Update park name, update ride description
### Pattern 2: Entity Update with Relations
**Use Case**: Updating entity that affects parent/child relations
```typescript
export function useRelatedUpdateMutation() {
const {
invalidateRideDetail,
invalidateParkRides,
invalidateHomepage
} = useQueryInvalidation();
return useMutation({
mutationFn: updateRide,
onSuccess: (_data, { rideSlug, parkSlug }) => {
invalidateRideDetail(rideSlug); // The ride itself
invalidateParkRides(parkSlug); // Parent park's rides
invalidateHomepage(); // Recent changes feed
toast.success("Updated");
}
});
}
```
**Examples**: Update ride (affects park), update park (affects rides)
### Pattern 3: User Action with Audit Trail
**Use Case**: Actions that affect user and create audit logs
```typescript
export function useAuditedActionMutation() {
const {
invalidateUserProfile,
invalidateAuditLogs,
invalidateUserSearch
} = useQueryInvalidation();
return useMutation({
mutationFn: performAction,
onSuccess: (_data, { userId }) => {
invalidateUserProfile(userId); // User's profile
invalidateAuditLogs(userId); // Audit trail
invalidateUserSearch(); // Search results
toast.success("Action completed");
}
});
}
```
**Examples**: Block user, change role, update privacy
### Pattern 4: Create with List Update
**Use Case**: Creating new entity that appears in lists
```typescript
export function useCreateMutation() {
const {
invalidateParks,
invalidateHomepage
} = useQueryInvalidation();
return useMutation({
mutationFn: createPark,
onSuccess: () => {
invalidateParks(); // All park lists
invalidateHomepage(); // Recent changes
toast.success("Created");
}
});
}
```
**Examples**: Create park, create ride, create company
### Pattern 5: Conditional Invalidation
**Use Case**: Invalidation depends on what changed
```typescript
export function useConditionalMutation() {
const {
invalidateUserProfile,
invalidateProfileStats,
invalidateUserSearch
} = useQueryInvalidation();
return useMutation({
mutationFn: updateProfile,
onSuccess: (_data, { userId, updates }) => {
// Always invalidate profile
invalidateUserProfile(userId);
// Conditional invalidations
if (updates.display_name || updates.username) {
invalidateUserSearch(); // Name changed
}
if (updates.avatar_url) {
invalidateProfileStats(userId); // Avatar in stats
}
toast.success("Updated");
}
});
}
```
**Examples**: Profile update, privacy update
## Entity-Specific Guides
### Parks
#### Create Park
```typescript
invalidateParks(); // Global list + owner lists
invalidateHomepage(); // Recent changes
```
#### Update Park
```typescript
invalidateParks(); // All lists
invalidateParkDetail(slug); // Detail page
invalidateHomepage(); // Recent changes
```
#### Delete Park
```typescript
invalidateParks(); // All lists
invalidateParkRides(slug); // Park's rides (cleanup)
invalidateHomepage(); // Recent changes
```
### Rides
#### Create Ride
```typescript
invalidateRides(); // Global list + manufacturer lists
invalidateParkRides(parkSlug); // Parent park
invalidateHomepage(); // Recent changes
```
#### Update Ride
```typescript
invalidateRides(); // All lists
invalidateRideDetail(slug); // Detail page
invalidateParkRides(parkSlug); // Parent park
invalidateHomepage(); // Recent changes
```
#### Delete Ride
```typescript
invalidateRides(); // All lists
invalidateParkRides(parkSlug); // Parent park
invalidateHomepage(); // Recent changes
```
### Companies
#### Create Company
```typescript
invalidateCompanies(); // Global list
```
#### Update Company
```typescript
invalidateCompanies(); // All lists
invalidateCompanyDetail(slug); // Detail page
```
### Users & Profiles
#### Update Profile
```typescript
invalidateUserProfile(userId);
invalidateProfileStats(userId);
invalidateProfileActivity(userId);
// If name changed:
if (nameChanged) {
invalidateUserSearch();
}
```
#### Update Privacy
```typescript
invalidateUserProfile(userId);
invalidateUserSearch(); // Visibility changed
```
#### Block/Unblock User
```typescript
invalidateUserProfile(targetUserId);
invalidateUserProfile(actorUserId); // Your blocklist
invalidateBlockedUsers(actorUserId);
invalidateUserSearch();
```
### Security Actions
#### Change Password
```typescript
// Optional: invalidate sessions to force re-login elsewhere
invalidateSessions(userId);
```
#### Change Email
```typescript
invalidateUserProfile(userId);
invalidateEmailStatus(); // Email verification status
```
#### Update Role
```typescript
invalidateUserProfile(userId);
// Note: App should re-fetch user permissions
```
### Moderation
#### Submit for Moderation
```typescript
invalidateModerationQueue();
// Don't invalidate entity yet - not approved
```
#### Approve Submission
```typescript
invalidateModerationQueue();
invalidate[Entity](); // Park/Ride/Company
invalidateHomepage(); // Recent changes
invalidateUserProfile(submitterId); // Stats
```
#### Reject Submission
```typescript
invalidateModerationQueue();
// Don't invalidate entity - rejected
```
### Ride Credits
#### Add Credit
```typescript
invalidateRideCredits(userId);
invalidateProfileStats(userId);
```
#### Reorder Credits
```typescript
invalidateRideCredits(userId);
// Stats unaffected - just order
```
#### Remove Credit
```typescript
invalidateRideCredits(userId);
invalidateProfileStats(userId);
```
## Anti-Patterns (Don't Do This!)
### ❌ Over-Invalidation
```typescript
// BAD: Invalidating everything
onSuccess: () => {
queryClient.invalidateQueries(); // Nukes entire cache!
}
// GOOD: Specific invalidation
onSuccess: () => {
invalidateUserProfile(userId);
}
```
### ❌ Under-Invalidation
```typescript
// BAD: Forgetting related data
onSuccess: () => {
invalidateRideDetail(slug); // Only detail page
// Missing: invalidateRides() for lists
// Missing: invalidateParkRides() for parent
}
// GOOD: All affected queries
onSuccess: () => {
invalidateRideDetail(slug);
invalidateRides();
invalidateParkRides(parkSlug);
invalidateHomepage();
}
```
### ❌ Manual Cache Manipulation
```typescript
// BAD: Manually updating cache
onSuccess: (newData) => {
queryClient.setQueryData(['park', slug], newData);
}
// GOOD: Let React Query refetch
onSuccess: () => {
invalidateParkDetail(slug); // Triggers refetch
}
```
### ❌ Wrong Query Keys
```typescript
// BAD: Hardcoded strings
queryClient.invalidateQueries({ queryKey: ['profile'] });
// GOOD: Using centralized keys
import { queryKeys } from '@/lib/queryKeys';
queryClient.invalidateQueries({ queryKey: queryKeys.profile.detail(userId) });
```
### ❌ Missing Optimistic Rollback
```typescript
// BAD: Optimistic update without rollback
onMutate: (newData) => {
queryClient.setQueryData(key, newData);
// Missing: return previous data
},
onError: () => {
toast.error("Failed");
// Missing: rollback
}
// GOOD: Proper optimistic pattern
onMutate: (newData) => {
const previous = queryClient.getQueryData(key);
queryClient.setQueryData(key, newData);
return { previous }; // Save for rollback
},
onError: (err, vars, context) => {
queryClient.setQueryData(key, context.previous); // Rollback
toast.error("Failed");
}
```
## Testing Invalidation
### Unit Test Example
```typescript
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
it('should invalidate correct queries on success', async () => {
const queryClient = new QueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(
() => useProfileUpdateMutation(),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
);
await result.current.mutateAsync({
userId: 'test-id',
updates: { display_name: 'New Name' }
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ['profile', 'test-id']
});
});
});
```
### Manual Test Checklist
1. Open React Query DevTools
2. Perform mutation
3. Check that affected queries show as "invalidated"
4. Verify queries refetch automatically
5. Confirm UI updates with new data
## Quick Reference: Import Statements
```typescript
// Invalidation helpers
import { useQueryInvalidation } from '@/lib/queryInvalidation';
// In your hook:
const {
invalidateUserProfile,
invalidateParks,
invalidateRides,
invalidateHomepage
} = useQueryInvalidation();
// Query keys (for manual invalidation)
import { queryKeys } from '@/lib/queryKeys';
import { useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
queryClient.invalidateQueries({
queryKey: queryKeys.profile.detail(userId)
});
```
## When in Doubt
**General Rule**: If data X changes, invalidate:
1. Data X itself (detail view)
2. All lists containing X (list views)
3. Any parent/child relations (related entities)
4. Homepage/feeds if it might appear there
5. Search results if searchable fields changed
**Example Thinking Process**:
- "I'm updating a ride"
- "The ride detail page shows this" → invalidate ride detail
- "The all-rides list shows this" → invalidate rides list
- "The park's rides list shows this" → invalidate park rides
- "The homepage shows recent changes" → invalidate homepage
- "Done!"
---
For more details, see:
- [API_PATTERNS.md](./API_PATTERNS.md) - How to structure mutations
- [CACHE_DEBUGGING.md](./CACHE_DEBUGGING.md) - Troubleshooting issues
- [PRODUCTION_READY.md](./PRODUCTION_READY.md) - Architecture overview

View File

@@ -1,365 +0,0 @@
# Production Readiness Report
## System Overview
**Grade**: A+ (100/100) - Production Ready
**Last Updated**: 2025-10-31
ThrillWiki's API and cache system is production-ready with enterprise-grade architecture, comprehensive error handling, and intelligent cache management.
## Architecture Summary
### Core Technologies
- **React Query (TanStack Query v5)**: Handles all server state management
- **Supabase**: Backend database and authentication
- **TypeScript**: Full type safety across the stack
- **Realtime Subscriptions**: Automatic cache synchronization
### Key Metrics
- **Mutation Hook Coverage**: 100% (10/10 hooks)
- **Query Hook Coverage**: 100% (15+ hooks)
- **Type Safety**: 100% (zero `any` types in critical paths)
- **Cache Invalidation**: 35+ specialized helpers
- **Error Handling**: Centralized with proper rollback
## Performance Characteristics
### Cache Hit Rates
```
Profile Data: 85-95% hit rate (5min stale time)
List Data: 70-80% hit rate (2min stale time)
Static Data: 95%+ hit rate (10min stale time)
Realtime Updates: <100ms propagation
```
### Network Optimization
- **Reduced API Calls**: 60% reduction through intelligent caching
- **Optimistic Updates**: Instant UI feedback on mutations
- **Smart Invalidation**: Only invalidates affected queries
- **Debounced Realtime**: Prevents cascade invalidation storms
### User Experience Impact
- **Perceived Load Time**: 80% faster with cache hits
- **Offline Resilience**: Cached data available during network issues
- **Instant Feedback**: Optimistic updates for all mutations
- **No Stale Data**: Realtime sync ensures consistency
## Cache Invalidation Strategy
### Invalidation Patterns
#### 1. Profile Changes
```typescript
// When profile updates
invalidateUserProfile(userId); // User's profile data
invalidateProfileStats(userId); // Stats and counts
invalidateProfileActivity(userId); // Activity feed
invalidateUserSearch(); // Search results (if name changed)
```
#### 2. Park Changes
```typescript
// When park updates
invalidateParks(); // All park listings
invalidateParkDetail(slug); // Specific park
invalidateParkRides(slug); // Park's rides list
invalidateHomepage(); // Homepage recent changes
```
#### 3. Ride Changes
```typescript
// When ride updates
invalidateRides(); // All ride listings
invalidateRideDetail(slug); // Specific ride
invalidateParkRides(parkSlug); // Parent park's rides
invalidateHomepage(); // Homepage recent changes
```
#### 4. Moderation Actions
```typescript
// When content moderated
invalidateModerationQueue(); // Queue listings
invalidateEntity(); // The entity itself
invalidateUserProfile(); // Submitter's profile
invalidateAuditLogs(); // Audit trail
```
### Realtime Synchronization
**File**: `src/hooks/useRealtimeSubscriptions.ts`
Features:
- Automatic cache updates on database changes
- Debounced invalidation (300ms) to prevent cascades
- Optimistic update protection (waits 1s before invalidating)
- Filter-aware invalidation based on table and event type
```typescript
// Example: Park update via realtime
Database Change Debounce (300ms) Check Optimistic Lock
Invalidate Affected Queries UI Auto-Updates
```
## Error Handling Architecture
### Centralized Error System
**File**: `src/lib/errorHandler.ts`
```typescript
getErrorMessage(error: unknown): string
// - Handles PostgrestError
// - Handles AuthError
// - Handles standard Error
// - Returns user-friendly messages
```
### Mutation Error Pattern
All mutations follow this pattern:
```typescript
onError: (error, variables, context) => {
// 1. Rollback optimistic update
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
// 2. Show user-friendly error
toast.error("Operation Failed", {
description: getErrorMessage(error),
});
// 3. Log error for monitoring
logger.error('operation_failed', { error, variables });
}
```
### Error Boundaries
- Query errors caught by error boundaries
- Fallback UI displayed for failed queries
- Retry logic built into React Query
- Network errors automatically retried (3x exponential backoff)
## Monitoring Recommendations
### Key Metrics to Track
#### 1. Cache Performance
```typescript
// Monitor these with cacheMonitoring.ts
- Cache hit rate (target: >80%)
- Average query duration (target: <100ms)
- Invalidation frequency (target: <10/min per user)
- Stale query count (target: <5% of total)
```
#### 2. Error Rates
```typescript
// Track mutation failures
- Failed mutations by type (target: <1%)
- Network timeouts (target: <0.5%)
- Auth errors (target: <0.1%)
- Database errors (target: <0.1%)
```
#### 3. API Performance
```typescript
// Supabase metrics
- Average response time (target: <200ms)
- P95 response time (target: <500ms)
- RPC call duration (target: <150ms)
- Realtime message latency (target: <100ms)
```
### Logging Strategy
**Production Logging**:
```typescript
import { logger } from '@/lib/logger';
// Log important mutations
logger.info('profile_updated', { userId, changes });
// Log errors with context
logger.error('mutation_failed', {
operation: 'update_profile',
userId,
error: error.message
});
// Log performance issues
logger.warn('slow_query', {
queryKey,
duration: queryDuration
});
```
**Debug Tools**:
- React Query DevTools (development only)
- Cache monitoring utilities (`src/lib/cacheMonitoring.ts`)
- Browser performance profiling
- Network tab for API call inspection
## Scaling Considerations
### Current Capacity
- **Concurrent Users**: Tested up to 10,000
- **Queries Per Second**: 1,000+ (with 80% cache hits)
- **Realtime Connections**: 5,000+ concurrent
- **Database Connections**: Auto-scaling via Supabase
### Bottleneck Analysis
#### Low Risk Areas ✅
- Cache invalidation (O(1) operations)
- Optimistic updates (client-side only)
- Error handling (lightweight)
- Type checking (compile-time only)
#### Monitor These 🟡
- Realtime subscriptions at scale (>10k concurrent users)
- Homepage query with large datasets (>100k records)
- Search queries with complex filters
- Cascade invalidations (rare but possible)
### Scaling Strategies
#### For 10k-100k Users
- ✅ Current architecture sufficient
- Consider: CDN for static assets
- Consider: Geographic database replicas
#### For 100k-1M Users
- Implement: Redis cache layer for hot data
- Implement: Database read replicas
- Implement: Rate limiting per user
- Implement: Query result pagination everywhere
#### For 1M+ Users
- Implement: Microservices for heavy operations
- Implement: Event-driven architecture
- Implement: Dedicated realtime server cluster
- Implement: Multi-region deployment
## Deployment Checklist
### Pre-Deployment
- [ ] All tests passing
- [ ] No TypeScript errors
- [ ] Database migrations applied
- [ ] RLS policies verified with linter
- [ ] Environment variables configured
- [ ] Error tracking service configured (e.g., Sentry)
- [ ] Performance monitoring enabled
### Post-Deployment
- [ ] Monitor error rates (first 24 hours)
- [ ] Check cache hit rates
- [ ] Verify realtime subscriptions working
- [ ] Test authentication flows
- [ ] Review query performance metrics
- [ ] Check database connection pool
### Rollback Plan
```bash
# If issues detected:
1. Revert to previous deployment
2. Check error logs for root cause
3. Review recent database migrations
4. Verify environment variables
5. Test in staging before re-deploying
```
## Security Considerations
### RLS Policies
- All tables have Row Level Security enabled
- Policies verified with Supabase linter
- Regular security audits recommended
### Authentication
- JWT tokens with automatic refresh
- Session management via Supabase
- Email verification required
- Password reset flows secure
### API Security
- All mutations require authentication
- Rate limiting on sensitive endpoints
- Input validation via Zod schemas
- SQL injection prevented by Supabase client
## Maintenance Guidelines
### Daily
- Monitor error rates in logging service
- Check realtime subscription health
- Review slow query logs
### Weekly
- Review cache hit rates
- Analyze query performance
- Check for stale data reports
- Review security logs
### Monthly
- Performance audit
- Database query optimization review
- Cache invalidation pattern review
- Update dependencies
### Quarterly
- Comprehensive security audit
- Load testing at scale
- Architecture review
- Disaster recovery test
## Known Limitations
### Minor Areas for Future Enhancement
1. **Entity Cache Types** - Currently uses `any` for flexibility (9 instances)
2. **Legacy Components** - 3 components use manual loading states
3. **Moderation Queue** - Old hook still exists alongside new one (being phased out)
**Impact**: None of these affect production stability or performance.
## Success Metrics
### Code Quality
- ✅ Zero `any` types in critical paths
- ✅ 100% mutation hook coverage
- ✅ Comprehensive error handling
- ✅ Proper TypeScript types throughout
### Performance
- ✅ 60% reduction in API calls
- ✅ <100ms realtime propagation
- ✅ 80%+ cache hit rates
- ✅ Instant optimistic updates
### User Experience
- ✅ No stale data issues
- ✅ Instant feedback on actions
- ✅ Graceful error handling
- ✅ Offline resilience
### Maintainability
- ✅ Centralized patterns
- ✅ Comprehensive documentation
- ✅ Clear code organization
- ✅ Type-safe throughout
## Conclusion
The ThrillWiki API and cache system is **production-ready** and enterprise-grade. The architecture is solid, performance is excellent, and the codebase is maintainable. The system can handle current load and scale to 100k+ users with minimal changes.
**Confidence Level**: Very High
**Risk Level**: Very Low
**Recommendation**: Deploy with confidence
---
For debugging issues, see: [CACHE_DEBUGGING.md](./CACHE_DEBUGGING.md)
For invalidation patterns, see: [CACHE_INVALIDATION_GUIDE.md](./CACHE_INVALIDATION_GUIDE.md)
For API patterns, see: [API_PATTERNS.md](./API_PATTERNS.md)

View File

@@ -1,55 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
interface AuditLogFilters {
userId?: string;
action?: string;
page?: number;
pageSize?: number;
}
/**
* Hook for querying audit logs with proper caching
* Provides: paginated audit log queries with filtering
*/
export function useAuditLogs(filters: AuditLogFilters = {}) {
const { userId, action, page = 1, pageSize = 50 } = filters;
return useQuery({
queryKey: queryKeys.admin.auditLogs(userId),
queryFn: async () => {
let query = supabase
.from('profile_audit_log')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false });
if (userId) {
query = query.eq('user_id', userId);
}
if (action) {
query = query.eq('action', action);
}
// Apply pagination
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize - 1;
query = query.range(startIndex, endIndex);
const { data, error, count } = await query;
if (error) throw error;
return {
logs: data || [],
total: count || 0,
page,
pageSize,
totalPages: Math.ceil((count || 0) / pageSize),
};
},
staleTime: 2 * 60 * 1000, // 2 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -1,98 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
/**
* useVersionAudit Hook
*
* Detects suspicious entity versions without user attribution for security monitoring.
*
* Features:
* - Combines 4 count queries with Promise.all() for parallel execution
* - Caches for 5 minutes (security alert, should be relatively fresh)
* - Returns total count + breakdown by entity type
* - Only runs for moderators/admins
* - Performance monitoring with slow query warnings
*
* @returns TanStack Query result with audit data
*
* @example
* ```tsx
* const { data: auditResult, isLoading } = useVersionAudit();
*
* if (auditResult && auditResult.totalCount > 0) {
* console.warn(`Found ${auditResult.totalCount} suspicious versions`);
* }
* ```
*/
interface VersionAuditResult {
totalCount: number;
parkVersions: number;
rideVersions: number;
companyVersions: number;
modelVersions: number;
}
export function useVersionAudit() {
const { user } = useAuth();
const { isModerator } = useUserRole();
return useQuery<VersionAuditResult>({
queryKey: queryKeys.admin.versionAudit,
queryFn: async () => {
const startTime = performance.now();
const [parksResult, ridesResult, companiesResult, modelsResult] = await Promise.all([
supabase
.from('park_versions')
.select('*', { count: 'exact', head: true })
.is('created_by', null),
supabase
.from('ride_versions')
.select('*', { count: 'exact', head: true })
.is('created_by', null),
supabase
.from('company_versions')
.select('*', { count: 'exact', head: true })
.is('created_by', null),
supabase
.from('ride_model_versions')
.select('*', { count: 'exact', head: true })
.is('created_by', null),
]);
// Check for errors
if (parksResult.error) throw parksResult.error;
if (ridesResult.error) throw ridesResult.error;
if (companiesResult.error) throw companiesResult.error;
if (modelsResult.error) throw modelsResult.error;
const parkCount = parksResult.count || 0;
const rideCount = ridesResult.count || 0;
const companyCount = companiesResult.count || 0;
const modelCount = modelsResult.count || 0;
const duration = performance.now() - startTime;
// Log slow queries in development
if (import.meta.env.DEV && duration > 1000) {
console.warn(`Slow query: useVersionAudit took ${duration}ms`);
}
return {
totalCount: parkCount + rideCount + companyCount + modelCount,
parkVersions: parkCount,
rideVersions: rideCount,
companyVersions: companyCount,
modelVersions: modelCount,
};
},
enabled: !!user && isModerator(),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -1,45 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* useBlogPost Hook
*
* Fetches a published blog post by slug with author profile information.
* Extracted from BlogPost.tsx for reusability and better caching.
*
* Features:
* - Caches blog posts for 5 minutes
* - Includes author profile data (username, display_name, avatar)
* - Only returns published posts
*
* @param slug - URL slug of the blog post
* @returns TanStack Query result with blog post data
*
* @example
* ```tsx
* const { data: post, isLoading } = useBlogPost(slug);
* ```
*/
export function useBlogPost(slug: string | undefined) {
return useQuery({
queryKey: queryKeys.blog.post(slug || ''),
queryFn: async () => {
if (!slug) return null;
const { data, error } = await supabase
.from('blog_posts')
.select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)')
.eq('slug', slug)
.eq('status', 'published')
.single();
if (error) throw error;
return data;
},
enabled: !!slug,
staleTime: 5 * 60 * 1000, // 5 minutes (blog content)
gcTime: 15 * 60 * 1000, // 15 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -1,32 +0,0 @@
/**
* Company Detail Hook
*
* Fetches company details with caching for efficient data access.
*/
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useCompanyDetail(slug: string | undefined, companyType: string) {
return useQuery({
queryKey: queryKeys.companies.detail(slug || '', companyType),
queryFn: async () => {
if (!slug) return null;
const { data, error } = await supabase
.from('companies')
.select('*')
.eq('slug', slug)
.eq('company_type', companyType)
.maybeSingle();
if (error) throw error;
return data;
},
enabled: !!slug,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,38 +0,0 @@
/**
* Company Parks Hook
*
* Fetches parks operated/owned by a company with caching.
*/
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useCompanyParks(
companyId: string | undefined,
companyType: 'operator' | 'property_owner',
limit = 6
) {
const field = companyType === 'operator' ? 'operator_id' : 'property_owner_id';
return useQuery({
queryKey: queryKeys.companies.parks(companyId || '', companyType, limit),
queryFn: async () => {
if (!companyId) return [];
const { data, error } = await supabase
.from('parks')
.select('*, location:locations(*)')
.eq(field, companyId)
.order('name')
.limit(limit);
if (error) throw error;
return data || [];
},
enabled: !!companyId,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,124 +0,0 @@
/**
* Company Statistics Hook
*
* Fetches company-specific statistics with optimized parallel queries.
* Adapts query strategy based on company type (manufacturer/designer/operator/property_owner).
*
* Features:
* - Parallel stat queries for performance
* - Type-specific optimizations
* - Long cache times (10 min) for rarely-changing stats
* - Performance monitoring in dev mode
*
* @param companyId - UUID of the company
* @param companyType - Type of company (manufacturer, designer, operator, property_owner)
* @returns Statistics object with counts
*
* @example
* ```tsx
* const { data: stats } = useCompanyStatistics(companyId, 'manufacturer');
* console.log(stats?.ridesCount); // Number of rides
* ```
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
interface CompanyStatistics {
ridesCount?: number;
modelsCount?: number;
photosCount?: number;
parksCount?: number;
operatingRidesCount?: number;
}
export function useCompanyStatistics(
companyId: string | undefined,
companyType: string
): UseQueryResult<CompanyStatistics | null> {
return useQuery({
queryKey: queryKeys.companies.statistics(companyId || '', companyType),
queryFn: async () => {
const startTime = performance.now();
if (!companyId) return null;
if (companyType === 'manufacturer') {
const [ridesRes, modelsRes, photosRes] = await Promise.all([
supabase.from('rides').select('id', { count: 'exact', head: true }).eq('manufacturer_id', companyId),
supabase.from('ride_models').select('id', { count: 'exact', head: true }).eq('manufacturer_id', companyId),
supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'manufacturer').eq('entity_id', companyId)
]);
const result = {
ridesCount: ridesRes.count || 0,
modelsCount: modelsRes.count || 0,
photosCount: photosRes.count || 0,
};
// Performance monitoring (dev only)
if (import.meta.env.DEV) {
const duration = performance.now() - startTime;
if (duration > 1000) {
console.warn(`⚠️ Slow query: useCompanyStatistics took ${duration.toFixed(0)}ms`, { companyId, companyType });
}
}
return result;
} else if (companyType === 'designer') {
const [ridesRes, photosRes] = await Promise.all([
supabase.from('rides').select('id', { count: 'exact', head: true }).eq('designer_id', companyId),
supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'designer').eq('entity_id', companyId)
]);
const result = {
ridesCount: ridesRes.count || 0,
photosCount: photosRes.count || 0,
};
// Performance monitoring (dev only)
if (import.meta.env.DEV) {
const duration = performance.now() - startTime;
if (duration > 1000) {
console.warn(`⚠️ Slow query: useCompanyStatistics took ${duration.toFixed(0)}ms`, { companyId, companyType });
}
}
return result;
} else {
// operator or property_owner - optimized single query
const parkField = companyType === 'operator' ? 'operator_id' : 'property_owner_id';
const [parksRes, ridesRes, photosRes] = await Promise.all([
supabase.from('parks').select('id', { count: 'exact', head: true }).eq(parkField, companyId),
supabase.from('rides')
.select('id, parks!inner(operator_id, property_owner_id)', { count: 'exact', head: true })
.eq('status', 'operating')
.eq(`parks.${parkField}`, companyId),
supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', companyType).eq('entity_id', companyId)
]);
const result = {
parksCount: parksRes.count || 0,
operatingRidesCount: ridesRes.count || 0,
photosCount: photosRes.count || 0,
};
// Performance monitoring (dev only)
if (import.meta.env.DEV) {
const duration = performance.now() - startTime;
if (duration > 1000) {
console.warn(`⚠️ Slow query: useCompanyStatistics took ${duration.toFixed(0)}ms`, { companyId, companyType });
}
}
return result;
}
},
enabled: !!companyId,
staleTime: 10 * 60 * 1000, // 10 minutes - stats change rarely
gcTime: 20 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,82 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* useEntityName Hook
*
* Fetches the name of an entity for display purposes.
* Replaces multiple sequential direct queries with a single cached hook.
*
* Features:
* - Caches entity names for 10 minutes (rarely change)
* - Supports parks, rides, ride_models, and all company types
* - Returns 'Unknown' for invalid types or missing data
*
* @param entityType - Type of entity ('park', 'ride', 'ride_model', 'manufacturer', etc.)
* @param entityId - UUID of the entity
* @returns TanStack Query result with entity name string
*
* @example
* ```tsx
* const { data: entityName = 'Unknown' } = useEntityName('park', parkId);
* ```
*/
export function useEntityName(entityType: string, entityId: string) {
return useQuery({
queryKey: queryKeys.entities.name(entityType, entityId),
queryFn: async () => {
// Type-safe approach: separate queries for each table type
try {
if (entityType === 'park') {
const { data, error } = await supabase
.from('parks')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
if (entityType === 'ride') {
const { data, error } = await supabase
.from('rides')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
if (entityType === 'ride_model') {
const { data, error } = await supabase
.from('ride_models')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
const { data, error } = await supabase
.from('companies')
.select('name')
.eq('id', entityId)
.single();
if (error) throw error;
return data?.name || 'Unknown';
}
return 'Unknown';
} catch (error) {
console.error('Failed to fetch entity name:', error);
return 'Unknown';
}
},
enabled: !!entityType && !!entityId,
staleTime: 10 * 60 * 1000, // 10 minutes (entity names rarely change)
gcTime: 30 * 60 * 1000, // 30 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { toDateOnly } from '@/lib/dateUtils';
export function useHomepageRecentlyClosedParks(enabled = true) {
return useQuery({
@@ -14,9 +13,9 @@ export function useHomepageRecentlyClosedParks(enabled = true) {
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('closing_date', toDateOnly(oneYearAgo))
.lte('closing_date', toDateOnly(today))
.order('closing_date', { ascending: false })
.gte('closed_date', oneYearAgo.toISOString())
.lte('closed_date', today.toISOString())
.order('closed_date', { ascending: false })
.limit(12);
if (error) throw error;
@@ -39,10 +38,10 @@ export function useHomepageRecentlyClosedRides(enabled = true) {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*, location:locations(*))`)
.gte('closing_date', toDateOnly(oneYearAgo))
.lte('closing_date', toDateOnly(today))
.order('closing_date', { ascending: false })
.select(`*, park:parks(*), location:locations(*)`)
.gte('closed_date', oneYearAgo.toISOString())
.lte('closed_date', today.toISOString())
.order('closed_date', { ascending: false })
.limit(12);
if (error) throw error;

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { toDateOnly } from '@/lib/dateUtils';
export function useHomepageClosingSoonParks(enabled = true) {
return useQuery({
@@ -14,9 +13,9 @@ export function useHomepageClosingSoonParks(enabled = true) {
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('closing_date', toDateOnly(today))
.lte('closing_date', toDateOnly(sixMonthsFromNow))
.order('closing_date', { ascending: true })
.gte('closed_date', today.toISOString())
.lte('closed_date', sixMonthsFromNow.toISOString())
.order('closed_date', { ascending: true })
.limit(12);
if (error) throw error;
@@ -39,10 +38,10 @@ export function useHomepageClosingSoonRides(enabled = true) {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*, location:locations(*))`)
.gte('closing_date', toDateOnly(today))
.lte('closing_date', toDateOnly(sixMonthsFromNow))
.order('closing_date', { ascending: true })
.select(`*, park:parks(*), location:locations(*)`)
.gte('closed_date', today.toISOString())
.lte('closed_date', sixMonthsFromNow.toISOString())
.order('closed_date', { ascending: true })
.limit(12);
if (error) throw error;

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { toDateOnly } from '@/lib/dateUtils';
export function useHomepageRecentlyOpenedParks(enabled = true) {
return useQuery({
@@ -13,8 +12,8 @@ export function useHomepageRecentlyOpenedParks(enabled = true) {
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('opening_date', toDateOnly(oneYearAgo))
.order('opening_date', { ascending: false })
.gte('opened_date', oneYearAgo.toISOString())
.order('opened_date', { ascending: false })
.limit(12);
if (error) throw error;
@@ -36,9 +35,9 @@ export function useHomepageRecentlyOpenedRides(enabled = true) {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*, location:locations(*))`)
.gte('opening_date', toDateOnly(oneYearAgo))
.order('opening_date', { ascending: false })
.select(`*, park:parks(*), location:locations(*)`)
.gte('opened_date', oneYearAgo.toISOString())
.order('opened_date', { ascending: false })
.limit(12);
if (error) throw error;

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { toDateOnly } from '@/lib/dateUtils';
export function useHomepageOpeningSoonParks(enabled = true) {
return useQuery({
@@ -14,9 +13,9 @@ export function useHomepageOpeningSoonParks(enabled = true) {
const { data, error } = await supabase
.from('parks')
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
.gte('opening_date', toDateOnly(today))
.lte('opening_date', toDateOnly(sixMonthsFromNow))
.order('opening_date', { ascending: true })
.gte('opened_date', today.toISOString())
.lte('opened_date', sixMonthsFromNow.toISOString())
.order('opened_date', { ascending: true })
.limit(12);
if (error) throw error;
@@ -39,10 +38,10 @@ export function useHomepageOpeningSoonRides(enabled = true) {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*, location:locations(*))`)
.gte('opening_date', toDateOnly(today))
.lte('opening_date', toDateOnly(sixMonthsFromNow))
.order('opening_date', { ascending: true })
.select(`*, park:parks(*), location:locations(*)`)
.gte('opened_date', today.toISOString())
.lte('opened_date', sixMonthsFromNow.toISOString())
.order('opened_date', { ascending: true })
.limit(12);
if (error) throw error;

View File

@@ -29,7 +29,7 @@ export function useHomepageHighestRatedRides(enabled = true) {
queryFn: async () => {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*, location:locations(*))`)
.select(`*, park:parks(*), location:locations(*)`)
.not('average_rating', 'is', null)
.order('average_rating', { ascending: false })
.limit(12);

View File

@@ -28,7 +28,7 @@ export function useHomepageRecentRides(enabled = true) {
queryFn: async () => {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*, location:locations(*))`)
.select(`*, park:parks(*), location:locations(*)`)
.order('created_at', { ascending: false })
.limit(12);

View File

@@ -1,30 +1,4 @@
/**
* Homepage Recent Changes Hook
*
* Fetches recent entity changes (parks, rides, companies) for homepage display.
* Uses optimized RPC function for single-query fetch of all data.
*
* Features:
* - Fetches up to 24 recent changes
* - Includes entity details, change metadata, and user info
* - Single database query via RPC
* - 5 minute cache for homepage performance
* - Performance monitoring
*
* @param enabled - Whether the query should run (default: true)
* @returns Array of recent changes with full entity context
*
* @example
* ```tsx
* const { data: changes, isLoading } = useHomepageRecentChanges();
*
* changes?.forEach(change => {
* console.log(`${change.name} was ${change.changeType} by ${change.changedBy?.username}`);
* });
* ```
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
@@ -44,35 +18,17 @@ interface RecentChange {
changeReason?: string;
}
export function useHomepageRecentChanges(
enabled = true
): UseQueryResult<RecentChange[]> {
export function useHomepageRecentChanges(enabled = true) {
return useQuery({
queryKey: queryKeys.homepage.recentChanges(),
queryFn: async () => {
const startTime = performance.now();
// Use the new database function to get all changes in a single query
const { data, error } = await supabase.rpc('get_recent_changes', { limit_count: 24 });
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 as unknown as DatabaseRecentChange[] || []).map((item) => ({
return (data || []).map((item: any) => ({
id: item.entity_id,
name: item.entity_name,
type: item.entity_type as 'park' | 'ride' | 'company',
@@ -86,17 +42,7 @@ export function useHomepageRecentChanges(
avatarUrl: item.changed_by_avatar || undefined
} : undefined,
changeReason: item.change_reason || undefined
}));
// Performance monitoring (dev only)
if (import.meta.env.DEV) {
const duration = performance.now() - startTime;
if (duration > 500) {
console.warn(`⚠️ Slow query: useHomepageRecentChanges took ${duration.toFixed(0)}ms`, { changeCount: result.length });
}
}
return result;
})) as RecentChange[];
},
enabled,
staleTime: 5 * 60 * 1000,

View File

@@ -28,7 +28,7 @@ export function useHomepageTrendingRides(enabled = true) {
queryFn: async () => {
const { data, error } = await supabase
.from('rides')
.select(`*, park:parks(*, location:locations(*))`)
.select(`*, park:parks(*), location:locations(*)`)
.order('view_count_30d', { ascending: false })
.limit(12);

View File

@@ -1,66 +1,14 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* List Item with Entity Data
* Hook to fetch list items with entities (batch fetching to avoid N+1)
*/
interface ListItemWithEntity {
id: string;
list_id: string;
entity_type: string; // Allow any string from DB
entity_id: string;
position: number;
notes: string;
created_at: string;
updated_at: string;
entity?: {
id: string;
name: string;
slug: string;
park_type?: string;
category?: string;
company_type?: string;
location_id?: string;
park_id?: string;
};
}
/**
* Fetch List Items Hook
*
* Fetches list items with their associated entities using optimized batch fetching.
* Prevents N+1 queries by grouping entity requests by type.
*
* Features:
* - Batch fetches parks, rides, and companies in parallel
* - Caches results for 5 minutes (staleTime)
* - Background refetch every 15 minutes (gcTime)
* - Type-safe entity data
* - Performance monitoring in dev mode
*
* @param listId - UUID of the list to fetch items for
* @param enabled - Whether the query should run (default: true)
* @returns TanStack Query result with array of list items
*
* @example
* ```tsx
* const { data: items, isLoading } = useListItems(listId);
*
* items?.forEach(item => {
* console.log(item.entity?.name); // Entity data is pre-loaded
* });
* ```
*/
export function useListItems(
listId: string | undefined,
enabled = true
): UseQueryResult<ListItemWithEntity[]> {
export function useListItems(listId: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.lists.items(listId || ''),
queryFn: async () => {
const startTime = performance.now();
if (!listId) return [];
// Get items
@@ -78,47 +26,30 @@ export function useListItems(
const rideIds = items.filter(i => i.entity_type === 'ride').map(i => i.entity_id);
const companyIds = items.filter(i => i.entity_type === 'company').map(i => i.entity_id);
// Batch fetch all entities in parallel with error handling
// Batch fetch all entities in parallel
const [parksResult, ridesResult, companiesResult] = await Promise.all([
parkIds.length > 0
? supabase.from('parks').select('id, name, slug, park_type, location_id').in('id', parkIds)
: Promise.resolve({ data: [], error: null }),
: Promise.resolve({ data: [] }),
rideIds.length > 0
? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds)
: Promise.resolve({ data: [], error: null }),
: Promise.resolve({ data: [] }),
companyIds.length > 0
? supabase.from('companies').select('id, name, slug, company_type').in('id', companyIds)
: Promise.resolve({ data: [], error: null }),
: Promise.resolve({ data: [] }),
]);
// Check for errors in batch fetches
if (parksResult.error) throw parksResult.error;
if (ridesResult.error) throw ridesResult.error;
if (companiesResult.error) throw companiesResult.error;
// Create entities map for quick lookup (properly typed)
type EntityData = NonNullable<ListItemWithEntity['entity']>;
const entitiesMap = new Map<string, EntityData>();
(parksResult.data || []).forEach(p => entitiesMap.set(p.id, p as EntityData));
(ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r as EntityData));
(companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c as EntityData));
// Create entities map for quick lookup
const entitiesMap = new Map<string, any>();
(parksResult.data || []).forEach(p => entitiesMap.set(p.id, p));
(ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r));
(companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c));
// Map entities to items
const result = items.map(item => ({
return items.map(item => ({
...item,
entity: entitiesMap.get(item.entity_id),
}));
// Performance monitoring (dev only)
if (import.meta.env.DEV) {
const duration = performance.now() - startTime;
if (duration > 1000) {
console.warn(`⚠️ Slow query: useListItems took ${duration.toFixed(0)}ms`, { listId, itemCount: items.length });
}
}
return result;
},
enabled: enabled && !!listId,
staleTime: 5 * 60 * 1000, // 5 minutes

View File

@@ -1,84 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* useUserLists Hook
*
* Fetches user's top lists with items for list management.
*
* Features:
* - Single query with nested list_items SELECT
* - Caches for 3 minutes (user data, moderate volatility)
* - Performance monitoring with slow query warnings
* - Supports optimistic updates via refetch
*
* @param userId - User UUID
*
* @returns TanStack Query result with user lists array
*
* @example
* ```tsx
* const { data: lists, isLoading, refetch } = useUserLists(user?.id);
*
* // After creating/updating a list:
* await createList(newList);
* refetch(); // Refresh lists
* ```
*/
interface UserTopListItem {
id: string;
entity_type: string;
entity_id: string;
position: number;
notes?: string;
created_at: string;
}
interface UserTopList {
id: string;
user_id: string;
title: string;
description?: string;
list_type: string; // Database returns any string
is_public: boolean;
created_at: string;
updated_at: string;
list_items: UserTopListItem[];
}
export function useUserLists(userId?: string) {
return useQuery<UserTopList[]>({
queryKey: queryKeys.lists.user(userId),
queryFn: async () => {
if (!userId) return [];
const startTime = performance.now();
const { data, error } = await supabase
.from('user_top_lists')
.select(`
*,
list_items:user_top_list_items(*)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
const duration = performance.now() - startTime;
// Log slow queries in development
if (import.meta.env.DEV && duration > 1000) {
console.warn(`Slow query: useUserLists took ${duration}ms`, { userId });
}
return data || [];
},
enabled: !!userId,
staleTime: 3 * 60 * 1000, // 3 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -7,7 +7,6 @@ import { validateMultipleItems } from '@/lib/entityValidationSchemas';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import type { User } from '@supabase/supabase-js';
import type { ModerationItem } from '@/types/moderation';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
/**
* Configuration for moderation actions
@@ -30,42 +29,15 @@ export interface ModerationActions {
}
/**
* Moderation Actions Hook
* Hook for moderation action handlers
* Extracted from useModerationQueueManager for better separation of concerns
*
* Provides functions for performing moderation actions on content submissions.
* Handles approval, rejection, deletion, and retry operations with proper
* cache invalidation and audit logging.
*
* Features:
* - Photo submission processing
* - Submission item validation
* - Selective approval via edge function
* - Comprehensive error handling
* - Cache invalidation for affected entities
* - Audit trail logging
* - Performance monitoring
*
* @param config - Configuration with user, callbacks, and lock state
* @param config - Configuration object with user, callbacks, and dependencies
* @returns Object with action handler functions
*
* @example
* ```tsx
* const actions = useModerationActions({
* user,
* onActionStart: (id) => console.log('Starting:', id),
* onActionComplete: () => refetch(),
* currentLockSubmissionId: lockedId
* });
*
* await actions.performAction(item, 'approved', 'Looks good!');
* ```
*/
export function useModerationActions(config: ModerationActionsConfig): ModerationActions {
const { user, onActionStart, onActionComplete } = config;
const { toast } = useToast();
// Cache invalidation for moderation and affected entities
const invalidation = useQueryInvalidation();
/**
* Perform moderation action (approve/reject)
@@ -291,30 +263,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
description: `The ${item.type} has been ${action}`,
});
// Invalidate specific entity caches based on submission type
if (action === 'approved') {
if (item.submission_type === 'photo' && item.content) {
const entityType = item.content.entity_type as string;
const entityId = item.content.entity_id as string;
if (entityType && entityId) {
invalidation.invalidateEntityPhotos(entityType, entityId);
invalidation.invalidatePhotoCount(entityType, entityId);
}
} else if (item.submission_type === 'park') {
invalidation.invalidateParks();
invalidation.invalidateHomepageData('parks');
} else if (item.submission_type === 'ride') {
invalidation.invalidateRides();
invalidation.invalidateHomepageData('rides');
} else if (item.submission_type === 'company') {
invalidation.invalidateHomepageData('all');
}
}
// Always invalidate moderation queue
invalidation.invalidateModerationQueue();
invalidation.invalidateModerationStats();
logger.log(`✅ Action ${action} completed for ${item.id}`);
} catch (error: unknown) {
logger.error('❌ Error performing action:', { error: getErrorMessage(error) });

View File

@@ -1,80 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import type { PhotoItem } from '@/types/photos';
/**
* usePhotoSubmission Hook
*
* Fetches photo submission with items using optimized JOIN query.
*
* Features:
* - Single query with JOIN instead of 2 sequential queries (75% reduction)
* - Caches for 3 minutes (moderation content, moderate volatility)
* - Transforms to PhotoItem[] for PhotoGrid compatibility
* - Performance monitoring with slow query warnings
*
* @param submissionId - Content submission UUID
*
* @returns TanStack Query result with PhotoItem array
*
* @example
* ```tsx
* const { data: photos, isLoading, error } = usePhotoSubmission(submissionId);
*
* if (photos && photos.length > 0) {
* return <PhotoGrid photos={photos} />;
* }
* ```
*/
export function usePhotoSubmission(submissionId?: string) {
return useQuery<PhotoItem[]>({
queryKey: queryKeys.moderation.photoSubmission(submissionId),
queryFn: async () => {
if (!submissionId) return [];
const startTime = performance.now();
// Step 1: Get photo_submission_id from submission_id
const { data: photoSubmission, error: photoSubmissionError } = await supabase
.from('photo_submissions')
.select('id, entity_type, title')
.eq('submission_id', submissionId)
.maybeSingle();
if (photoSubmissionError) throw photoSubmissionError;
if (!photoSubmission) return [];
// Step 2: Get photo items using photo_submission_id
const { data: items, error: itemsError } = await supabase
.from('photo_submission_items')
.select('*')
.eq('photo_submission_id', photoSubmission.id)
.order('order_index');
if (itemsError) throw itemsError;
const duration = performance.now() - startTime;
// Log slow queries in development
if (import.meta.env.DEV && duration > 1000) {
console.warn(`Slow query: usePhotoSubmission took ${duration}ms`, { submissionId });
}
// Transform to PhotoItem[] for PhotoGrid compatibility
return (items || []).map((item) => ({
id: item.id,
url: item.cloudflare_image_url,
filename: item.filename || `Photo ${item.order_index + 1}`,
caption: item.caption,
title: item.title,
date_taken: item.date_taken,
}));
},
enabled: !!submissionId,
staleTime: 3 * 60 * 1000, // 3 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -1,154 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { useAuth } from '@/hooks/useAuth';
/**
* useRecentActivity Hook
*
* Fetches recent moderation activity across all types for activity feed.
*
* Features:
* - 3 parallel queries (submissions, reports, reviews) + 1 batch profile fetch
* - Caches for 2 minutes (activity feed, should be relatively fresh)
* - Smart merging for background refetches (preserves scroll position)
* - Performance monitoring with slow query warnings
*
* @returns TanStack Query result with activity items array
*
* @example
* ```tsx
* const { data: activities, isLoading, refetch } = useRecentActivity();
*
* // Manual refresh trigger:
* <Button onClick={() => refetch()}>Refresh Activity</Button>
* ```
*/
interface ActivityItem {
id: string;
type: 'submission' | 'report' | 'review';
action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged';
entity_type?: string;
entity_name?: string;
timestamp: string;
moderator_id?: string;
moderator?: {
username: string;
display_name?: string;
avatar_url?: string;
};
}
export function useRecentActivity() {
const { user } = useAuth();
return useQuery<ActivityItem[]>({
queryKey: queryKeys.moderation.recentActivity,
queryFn: async () => {
const startTime = performance.now();
// Fetch all activity types in parallel
const [submissionsResult, reportsResult, reviewsResult] = await Promise.all([
supabase
.from('content_submissions')
.select('id, submission_type, status, updated_at, reviewer_id')
.in('status', ['approved', 'rejected'])
.order('updated_at', { ascending: false })
.limit(10),
supabase
.from('reports')
.select('id, reported_entity_type, status, updated_at, reviewed_by')
.in('status', ['resolved', 'dismissed'])
.order('updated_at', { ascending: false })
.limit(10),
supabase
.from('reviews')
.select('id, ride_id, park_id, moderation_status, moderated_at, moderated_by')
.eq('moderation_status', 'flagged')
.not('moderated_at', 'is', null)
.order('moderated_at', { ascending: false })
.limit(10),
]);
// Check for errors
if (submissionsResult.error) throw submissionsResult.error;
if (reportsResult.error) throw reportsResult.error;
if (reviewsResult.error) throw reviewsResult.error;
const submissions = submissionsResult.data || [];
const reports = reportsResult.data || [];
const reviews = reviewsResult.data || [];
// Collect all unique moderator IDs
const moderatorIds = new Set<string>();
submissions.forEach((s) => s.reviewer_id && moderatorIds.add(s.reviewer_id));
reports.forEach((r) => r.reviewed_by && moderatorIds.add(r.reviewed_by));
reviews.forEach((r) => r.moderated_by && moderatorIds.add(r.moderated_by));
// Batch fetch moderator profiles
let moderatorMap = new Map<string, any>();
if (moderatorIds.size > 0) {
const { data: profiles, error: profilesError } = await supabase
.from('profiles')
.select('user_id, username, display_name, avatar_url')
.in('user_id', Array.from(moderatorIds));
if (profilesError) throw profilesError;
moderatorMap = new Map(
(profiles || []).map((p) => [p.user_id, p])
);
}
// Transform to ActivityItem[]
const activities: ActivityItem[] = [
...submissions.map((s) => ({
id: s.id,
type: 'submission' as const,
action: s.status as 'approved' | 'rejected',
entity_type: s.submission_type,
timestamp: s.updated_at,
moderator_id: s.reviewer_id || undefined,
moderator: s.reviewer_id ? moderatorMap.get(s.reviewer_id) : undefined,
})),
...reports.map((r) => ({
id: r.id,
type: 'report' as const,
action: (r.status === 'resolved' ? 'reviewed' : 'dismissed') as 'reviewed' | 'dismissed',
entity_type: r.reported_entity_type,
timestamp: r.updated_at,
moderator_id: r.reviewed_by || undefined,
moderator: r.reviewed_by ? moderatorMap.get(r.reviewed_by) : undefined,
})),
...reviews.map((r) => ({
id: r.id,
type: 'review' as const,
action: 'flagged' as const,
entity_type: r.ride_id ? 'ride' : 'park',
timestamp: r.moderated_at!,
moderator_id: r.moderated_by || undefined,
moderator: r.moderated_by ? moderatorMap.get(r.moderated_by) : undefined,
})),
];
// Sort by timestamp descending and limit to 20
activities.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
const duration = performance.now() - startTime;
// Log slow queries in development
if (import.meta.env.DEV && duration > 1000) {
console.warn(`Slow query: useRecentActivity took ${duration}ms`);
}
return activities.slice(0, 20);
},
enabled: !!user,
staleTime: 2 * 60 * 1000, // 2 minutes
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -1,47 +0,0 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
/**
* usePrefetchNextPage Hook
*
* Prefetches the next page of paginated data for instant navigation.
*
* Features:
* - Automatically prefetches when user is on a page with more data
* - Short staleTime (2 minutes) prevents over-caching
* - Generic implementation works with any paginated data
*
* @param baseQueryKey - Base query key array for the paginated query
* @param currentPage - Current page number
* @param hasNextPage - Boolean indicating if there is a next page
* @param queryFn - Function to fetch the next page
*
* @example
* ```tsx
* usePrefetchNextPage(
* queryKeys.parks.all(),
* currentPage,
* hasNextPage,
* (page) => fetchParks(page)
* );
* ```
*/
export function usePrefetchNextPage<T>(
baseQueryKey: readonly unknown[],
currentPage: number,
hasNextPage: boolean,
queryFn: (page: number) => Promise<T>
) {
const queryClient = useQueryClient();
useEffect(() => {
if (!hasNextPage) return;
// Prefetch next page
queryClient.prefetchQuery({
queryKey: [...baseQueryKey, currentPage + 1],
queryFn: () => queryFn(currentPage + 1),
staleTime: 2 * 60 * 1000, // 2 minutes
});
}, [currentPage, hasNextPage, baseQueryKey, queryFn, queryClient]);
}

View File

@@ -1,119 +0,0 @@
/**
* Entity Photos Hook
*
* Fetches photos for a specific entity with intelligent caching and sort support.
*
* Features:
* - Caches photos for 5 minutes (staleTime)
* - Background refetch every 15 minutes (gcTime)
* - Supports 'newest' and 'oldest' sorting without refetching
* - Performance monitoring in dev mode
*
* @param entityType - Type of entity ('park', 'ride', 'company', etc.)
* @param entityId - UUID of the entity
* @param sortBy - Sort order: 'newest' (default) or 'oldest'
*
* @returns TanStack Query result with photo array
*
* @example
* ```tsx
* const { data: photos, isLoading, refetch } = useEntityPhotos('park', parkId, 'newest');
*
* // After uploading new photos:
* await uploadPhotos();
* refetch(); // Refresh this component
* invalidateEntityPhotos('park', parkId); // Refresh all components
* ```
*/
import { useQuery, UseQueryResult, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
interface EntityPhoto {
id: string;
url: string;
caption?: string;
title?: string;
user_id: string;
created_at: string;
}
export function useEntityPhotos(
entityType: string,
entityId: string,
sortBy: 'newest' | 'oldest' = 'newest',
enableRealtime = false // New parameter for opt-in real-time updates
): UseQueryResult<EntityPhoto[]> {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.photos.entity(entityType, entityId, sortBy),
queryFn: async () => {
const startTime = performance.now();
const { data, error } = await supabase
.from('photos')
.select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('created_at', { ascending: sortBy === 'oldest' });
if (error) throw error;
const result = data?.map((photo) => ({
id: photo.id,
url: photo.cloudflare_image_url,
caption: photo.caption || undefined,
title: photo.title || undefined,
user_id: photo.submitted_by,
created_at: photo.created_at,
})) || [];
// Performance monitoring (dev only)
if (import.meta.env.DEV) {
const duration = performance.now() - startTime;
if (duration > 1000) {
console.warn(`⚠️ Slow query: useEntityPhotos took ${duration.toFixed(0)}ms`, { entityType, entityId, photoCount: result.length });
}
}
return result;
},
enabled: !!entityType && !!entityId,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
// Real-time subscription for photo uploads (opt-in)
useEffect(() => {
if (!enableRealtime || !entityType || !entityId) return;
const channel = supabase
.channel(`photos-${entityType}-${entityId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'photos',
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`,
},
(payload) => {
console.log('📸 New photo uploaded:', payload.new);
queryClient.invalidateQueries({
queryKey: queryKeys.photos.entity(entityType, entityId)
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [enableRealtime, entityType, entityId, queryClient, sortBy]);
return query;
}

View File

@@ -1,68 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { getErrorMessage } from '@/lib/errorHandler';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
import { useAuth } from '@/hooks/useAuth';
interface UnblockUserParams {
blockId: string;
blockedUserId: string;
username: string;
}
/**
* Hook for user blocking/unblocking mutations
* Provides: unblock user with automatic audit logging and cache invalidation
*/
export function useBlockUserMutation() {
const { user } = useAuth();
const queryClient = useQueryClient();
const { invalidateAuditLogs } = useQueryInvalidation();
const unblockUser = useMutation({
mutationFn: async ({ blockId, blockedUserId, username }: UnblockUserParams) => {
if (!user) throw new Error('Authentication required');
const { error } = await supabase
.from('user_blocks')
.delete()
.eq('id', blockId);
if (error) throw error;
// Log to audit trail
await supabase.from('profile_audit_log').insert([{
user_id: user.id,
changed_by: user.id,
action: 'user_unblocked',
changes: JSON.parse(JSON.stringify({
blocked_user_id: blockedUserId,
username,
timestamp: new Date().toISOString()
}))
}]);
return { blockedUserId, username };
},
onError: (error: unknown) => {
toast.error("Error", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, { username }) => {
// Invalidate blocked users cache
queryClient.invalidateQueries({ queryKey: ['blocked-users'] });
invalidateAuditLogs();
toast.success("User Unblocked", {
description: `You have unblocked @${username}`,
});
},
});
return {
unblockUser,
isUnblocking: unblockUser.isPending,
};
}

View File

@@ -1,72 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/lib/logger';
import type { UserBlock } from '@/types/privacy';
/**
* Hook to fetch blocked users for the authenticated user
* Provides: automatic caching, refetch on window focus, and loading states
*/
export function useBlockedUsers(userId?: string) {
return useQuery({
queryKey: ['blocked-users', userId],
queryFn: async () => {
if (!userId) throw new Error('User ID required');
// Fetch blocked user IDs
const { data: blocks, error: blocksError } = await supabase
.from('user_blocks')
.select('id, blocked_id, reason, created_at')
.eq('blocker_id', userId)
.order('created_at', { ascending: false });
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 [];
}
// 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) {
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: 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: !!userId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}

View File

@@ -1,146 +0,0 @@
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
*
* Features:
* - Update privacy level and visibility settings
* - Optimistic updates with rollback on error
* - Automatic audit trail logging
* - Smart cache invalidation affecting search visibility
* - Updates both profile and user_preferences tables
*
* Modifies:
* - `profiles` table (privacy_level, show_pronouns)
* - `user_preferences` table (privacy_settings)
* - `profile_audit_log` table (audit trail)
*
* Cache Invalidation:
* - User profile data (`invalidateUserProfile`)
* - Audit logs (`invalidateAuditLogs`)
* - User search results (`invalidateUserSearch`) - privacy affects visibility
*
* @example
* ```tsx
* const { updatePrivacy, isUpdating } = usePrivacyMutations();
*
* updatePrivacy.mutate({
* privacy_level: 'private',
* show_pronouns: false,
* show_email: false,
* show_location: true
* });
* ```
*/
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 };
},
onMutate: async (newData) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
// Snapshot current value
interface Profile {
privacy_level?: string;
show_pronouns?: boolean;
}
const previousProfile = queryClient.getQueryData<Profile>(['profile', user?.id]);
// Optimistically update cache
if (previousProfile) {
queryClient.setQueryData<Profile>(['profile', user?.id], (old) =>
old ? {
...old,
privacy_level: newData.privacy_level,
show_pronouns: newData.show_pronouns,
} : old
);
}
return { previousProfile };
},
onError: (error: unknown, _variables, context) => {
// Rollback on error
if (context?.previousProfile && user) {
queryClient.setQueryData(['profile', user.id], context.previousProfile);
}
toast.error("Update Failed", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, variables) => {
// Invalidate all related caches
if (user) {
invalidateUserProfile(user.id);
invalidateAuditLogs(user.id);
invalidateUserSearch(); // Privacy affects search visibility
}
toast.success("Privacy Updated", {
description: "Your privacy preferences have been successfully saved.",
});
},
});
return {
updatePrivacy,
isUpdating: updatePrivacy.isPending,
};
}

View File

@@ -1,242 +0,0 @@
/**
* Profile Activity Hook
*
* Fetches user activity feed with privacy checks and optimized batch fetching.
* Prevents N+1 queries by batch fetching photo submission entities.
*
* Features:
* - Privacy-aware filtering based on user preferences
* - Batch fetches related entities (parks, rides) for photo submissions
* - Combines reviews, credits, submissions, and rankings
* - Returns top 15 most recent activities
* - 3 minute cache for frequently updated data
* - Performance monitoring in dev mode
*
* @param userId - UUID of the profile user
* @param isOwnProfile - Whether viewing user is the profile owner
* @param isModerator - Whether viewing user is a moderator
* @returns Combined activity feed sorted by date
*
* @example
* ```tsx
* const { data: activity } = useProfileActivity(userId, isOwnProfile, isModerator());
*
* activity?.forEach(item => {
* if (item.type === 'review') console.log('Review:', item.rating);
* if (item.type === 'submission') console.log('Submission:', item.submission_type);
* });
* ```
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
// Type-safe activity item types
type ActivityItem =
| { type: 'review'; [key: string]: any }
| { type: 'credit'; [key: string]: any }
| { type: 'submission'; [key: string]: any }
| { type: 'ranking'; [key: string]: any };
export function useProfileActivity(
userId: string | undefined,
isOwnProfile: boolean,
isModerator: boolean
): UseQueryResult<ActivityItem[]> {
return useQuery({
queryKey: queryKeys.profile.activity(userId || '', isOwnProfile, isModerator),
queryFn: async () => {
const startTime = performance.now();
if (!userId) return [];
// Check privacy settings first
const { data: preferences } = await supabase
.from('user_preferences')
.select('privacy_settings')
.eq('user_id', userId)
.single();
const privacySettings = preferences?.privacy_settings as { activity_visibility?: string } | null;
const activityVisibility = privacySettings?.activity_visibility || 'public';
if (activityVisibility !== 'public' && !isOwnProfile && !isModerator) {
return [];
}
// Build queries with conditional filters
const reviewsQuery = supabase.from('reviews')
.select('id, rating, title, created_at, moderation_status, park_id, ride_id, parks(name, slug), rides(name, slug, parks(name, slug))')
.eq('user_id', userId);
if (!isOwnProfile && !isModerator) {
reviewsQuery.eq('moderation_status', 'approved');
}
const submissionsQuery = supabase.from('content_submissions')
.select('id, submission_type, content, status, created_at')
.eq('user_id', userId);
if (!isOwnProfile && !isModerator) {
submissionsQuery.eq('status', 'approved');
}
const rankingsQuery = supabase.from('user_top_lists')
.select('id, title, description, list_type, created_at')
.eq('user_id', userId);
if (!isOwnProfile) {
rankingsQuery.eq('is_public', true);
}
// Fetch all activity types in parallel
const [reviews, credits, submissions, rankings] = await Promise.all([
reviewsQuery.order('created_at', { ascending: false }).limit(10).then(res => res.data || []),
supabase.from('user_ride_credits')
.select('id, ride_count, first_ride_date, created_at, rides(name, slug, parks(name, slug))')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10)
.then(res => res.data || []),
submissionsQuery.order('created_at', { ascending: false }).limit(10).then(res => res.data || []),
rankingsQuery.order('created_at', { ascending: false }).limit(10).then(res => res.data || [])
]);
// Enrich photo submissions in batch
const photoSubmissions = submissions.filter(s => s.submission_type === 'photo');
const photoSubmissionIds = photoSubmissions.map(s => s.id);
if (photoSubmissionIds.length > 0) {
// Batch fetch photo submission data
const { data: photoSubs } = await supabase
.from('photo_submissions')
.select('id, submission_id, entity_type, entity_id')
.in('submission_id', photoSubmissionIds);
if (photoSubs) {
// Batch fetch photo items
const photoSubIds = photoSubs.map(ps => ps.id);
const { data: photoItems } = await supabase
.from('photo_submission_items')
.select('photo_submission_id, cloudflare_image_url')
.in('photo_submission_id', photoSubIds)
.order('order_index', { ascending: true });
// Group entity IDs by type for batch fetching
const parkIds = photoSubs.filter(ps => ps.entity_type === 'park').map(ps => ps.entity_id);
const rideIds = photoSubs.filter(ps => ps.entity_type === 'ride').map(ps => ps.entity_id);
// Batch fetch entities
const [parks, rides] = await Promise.all([
parkIds.length ? supabase.from('parks').select('id, name, slug').in('id', parkIds).then(r => r.data || []) : [],
rideIds.length ? supabase.from('rides').select('id, name, slug, parks!inner(name, slug)').in('id', rideIds).then(r => r.data || []) : []
]);
// Create lookup maps with proper typing
interface PhotoSubmissionData {
id: string;
submission_id: string;
entity_type: string;
entity_id: string;
}
interface PhotoItem {
photo_submission_id: string;
cloudflare_image_url: string;
[key: string]: any;
}
interface EntityData {
id: string;
name: string;
slug: string;
parks?: {
name: string;
slug: string;
};
}
const photoSubMap = new Map<string, PhotoSubmissionData>(
photoSubs.map(ps => [ps.submission_id, ps as PhotoSubmissionData])
);
const photoItemsMap = new Map<string, PhotoItem[]>();
photoItems?.forEach((item: PhotoItem) => {
if (!photoItemsMap.has(item.photo_submission_id)) {
photoItemsMap.set(item.photo_submission_id, []);
}
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: 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: PhotoSubmissionWithAllFields) => {
const photoSub = photoSubMap.get(sub.id);
if (photoSub) {
const items = photoItemsMap.get(photoSub.id) || [];
sub.photo_count = items.length;
sub.photo_preview = items[0]?.cloudflare_image_url;
sub.entity_type = photoSub.entity_type;
sub.entity_id = photoSub.entity_id;
const entity = entityMap.get(photoSub.entity_id);
if (entity) {
sub.content = {
...(typeof sub.content === 'object' ? sub.content : {}),
entity_name: entity.name,
entity_slug: entity.slug,
...(entity.parks && { park_name: entity.parks.name, park_slug: entity.parks.slug })
};
}
}
});
}
}
// Combine and sort
const combined: ActivityItem[] = [
...reviews.map(r => ({ ...r, type: 'review' as const })),
...credits.map(c => ({ ...c, type: 'credit' as const })),
...submissions.map(s => ({ ...s, type: 'submission' as const })),
...rankings.map(r => ({ ...r, type: 'ranking' as const }))
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 15);
// Performance monitoring (dev only)
if (import.meta.env.DEV) {
const duration = performance.now() - startTime;
if (duration > 1500) {
console.warn(`⚠️ Slow query: useProfileActivity took ${duration.toFixed(0)}ms`, {
userId,
itemCount: combined.length,
reviewCount: reviews.length,
submissionCount: submissions.length
});
}
}
return combined;
},
enabled: !!userId,
staleTime: 3 * 60 * 1000, // 3 minutes - activity updates frequently
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,118 +0,0 @@
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,
invalidateProfileStats,
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;
},
onMutate: async (newData) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
// Snapshot current value
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>(['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 };
},
onError: (error: unknown, _variables, context) => {
// Rollback on error
if (context?.previousProfile && user) {
queryClient.setQueryData(['profile', user.id], context.previousProfile);
}
toast.error("Update Failed", {
description: getErrorMessage(error),
});
},
onSuccess: () => {
if (user) {
invalidateUserProfile(user.id);
invalidateProfileStats(user.id); // Location affects stats display
invalidateAuditLogs(user.id);
}
toast.success("Settings Saved", {
description: "Your location and personal information have been updated.",
});
},
});
return {
updateLocation,
isUpdating: updateLocation.isPending,
};
}

View File

@@ -1,37 +0,0 @@
/**
* Profile Stats Hook
*
* Fetches calculated user statistics (rides, coasters, parks).
*/
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useProfileStats(userId: string | undefined) {
return useQuery({
queryKey: queryKeys.profile.stats(userId || ''),
queryFn: async () => {
if (!userId) return { rideCount: 0, coasterCount: 0, parkCount: 0 };
const { data: ridesData } = await supabase
.from('user_ride_credits')
.select('ride_count, rides!inner(category, park_id)')
.eq('user_id', userId);
const totalRides = ridesData?.reduce((sum, credit) => sum + (credit.ride_count || 0), 0) || 0;
const coasterRides = ridesData?.filter(credit => credit.rides?.category === 'roller_coaster') || [];
const uniqueCoasters = new Set(coasterRides.map(credit => credit.rides));
const coasterCount = uniqueCoasters.size;
const parkRides = ridesData?.map(credit => credit.rides?.park_id).filter(Boolean) || [];
const uniqueParks = new Set(parkRides);
const parkCount = uniqueParks.size;
return { rideCount: totalRides, coasterCount, parkCount };
},
enabled: !!userId,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,116 +0,0 @@
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 ProfileUpdateParams {
userId: string;
updates: {
display_name?: string;
bio?: string;
location_id?: string | null;
website?: string | null;
[key: string]: any;
};
}
/**
* Hook for profile update mutations
*
* Features:
* - Optimistic updates for instant UI feedback
* - Automatic rollback on error
* - Smart cache invalidation (profile, stats, activity)
* - Conditional search invalidation when name changes
* - Comprehensive error handling with toast notifications
*
* Modifies:
* - `profiles` table
*
* Cache Invalidation:
* - User profile data (`invalidateUserProfile`)
* - Profile stats (`invalidateProfileStats`)
* - Profile activity feed (`invalidateProfileActivity`)
* - User search results if name changed (`invalidateUserSearch`)
*
* @example
* ```tsx
* const mutation = useProfileUpdateMutation();
*
* mutation.mutate({
* userId: user.id,
* updates: {
* display_name: 'New Name',
* bio: 'Updated bio',
* website: 'https://example.com'
* }
* });
* ```
*/
export function useProfileUpdateMutation() {
const queryClient = useQueryClient();
const {
invalidateUserProfile,
invalidateProfileStats,
invalidateProfileActivity,
invalidateUserSearch
} = useQueryInvalidation();
return useMutation({
mutationFn: async ({ userId, updates }: ProfileUpdateParams) => {
const { error } = await supabase
.from('profiles')
.update(updates)
.eq('user_id', userId);
if (error) throw error;
},
onMutate: async ({ userId, updates }) => {
// 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>(['profile', userId]);
// Optimistically update
queryClient.setQueryData<Profile>(['profile', userId], (old) =>
old ? { ...old, ...updates } : old
);
return { previousProfile, userId };
},
onError: (error: unknown, _variables, context) => {
// Rollback on error
if (context?.previousProfile) {
queryClient.setQueryData(['profile', context.userId], context.previousProfile);
}
toast.error("Update Failed", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, { userId, updates }) => {
// Invalidate all related caches
invalidateUserProfile(userId);
invalidateProfileStats(userId);
invalidateProfileActivity(userId);
// If display name or username changed, invalidate user search results
if (updates.display_name || updates.username) {
invalidateUserSearch();
}
toast.success("Profile Updated", {
description: "Your changes have been saved.",
});
},
});
}

View File

@@ -1,86 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { getErrorMessage } from '@/lib/errorHandler';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
import { useAuth } from '@/hooks/useAuth';
interface ReportActionParams {
reportId: string;
action: 'reviewed' | 'dismissed';
}
/**
* Hook for report action mutations
* Provides: report resolution/dismissal with automatic audit logging and cache invalidation
*/
export function useReportActionMutation() {
const { user } = useAuth();
const queryClient = useQueryClient();
const { invalidateModerationQueue, invalidateModerationStats, invalidateAuditLogs } = useQueryInvalidation();
const resolveReport = useMutation({
mutationFn: async ({ reportId, action }: ReportActionParams) => {
if (!user) throw new Error('Authentication required');
// Fetch full report details for audit log
const { data: reportData } = await supabase
.from('reports')
.select('reporter_id, reported_entity_type, reported_entity_id, reason')
.eq('id', reportId)
.single();
const { error } = await supabase
.from('reports')
.update({
status: action,
reviewed_by: user.id,
reviewed_at: new Date().toISOString(),
})
.eq('id', reportId);
if (error) throw error;
// Log audit trail for report resolution
if (reportData) {
try {
await supabase.rpc('log_admin_action', {
_admin_user_id: user.id,
_target_user_id: reportData.reporter_id,
_action: action === 'reviewed' ? 'report_resolved' : 'report_dismissed',
_details: {
report_id: reportId,
reported_entity_type: reportData.reported_entity_type,
reported_entity_id: reportData.reported_entity_id,
report_reason: reportData.reason,
action: action
}
});
} catch (auditError) {
console.error('Failed to log report action audit:', auditError);
}
}
return { action, reportData };
},
onError: (error: unknown) => {
toast.error("Error", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, { action }) => {
invalidateModerationQueue();
invalidateModerationStats();
invalidateAuditLogs();
toast.success(`Report ${action}`, {
description: `The report has been marked as ${action}`,
});
},
});
return {
resolveReport,
isResolving: resolveReport.isPending,
};
}

View File

@@ -1,76 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { getErrorMessage } from '@/lib/errorHandler';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
interface ReportParams {
entityType: 'review' | 'profile' | 'content_submission';
entityId: string;
reportType: string;
reason?: string;
}
/**
* Hook for content reporting mutations
*
* Features:
* - Submit reports for review/profile/submission abuse
* - Automatic moderation queue invalidation
* - Audit logging via database trigger
* - User-friendly success/error notifications
*
* Modifies:
* - `reports` table
*
* Cache Invalidation:
* - Moderation queue (`invalidateModerationQueue`)
* - Moderation stats (`invalidateModerationStats`)
*
* @example
* ```tsx
* const mutation = useReportMutation();
*
* mutation.mutate({
* entityType: 'review',
* entityId: 'review-123',
* reportType: 'spam',
* reason: 'This is clearly spam content'
* });
* ```
*/
export function useReportMutation() {
const { user } = useAuth();
const queryClient = useQueryClient();
const { invalidateModerationQueue, invalidateModerationStats } = useQueryInvalidation();
return useMutation({
mutationFn: async ({ entityType, entityId, reportType, reason }: ReportParams) => {
if (!user) throw new Error('Authentication required');
const { error } = await supabase.from('reports').insert({
reporter_id: user.id,
reported_entity_type: entityType,
reported_entity_id: entityId,
report_type: reportType,
reason: reason?.trim() || null,
});
if (error) throw error;
},
onSuccess: () => {
invalidateModerationQueue();
invalidateModerationStats();
toast.success("Report Submitted", {
description: "Thank you for your report. We'll review it shortly.",
});
},
onError: (error: unknown) => {
toast.error("Error", {
description: getErrorMessage(error),
});
},
});
}

View File

@@ -1,20 +1,12 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch reviews for a specific entity (park or ride)
*/
export function useEntityReviews(
entityType: 'park' | 'ride',
entityId: string | undefined,
enabled = true,
enableRealtime = false // New parameter for opt-in real-time updates
) {
const queryClient = useQueryClient();
const query = useQuery({
export function useEntityReviews(entityType: 'park' | 'ride', entityId: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.reviews.entity(entityType, entityId || ''),
queryFn: async () => {
if (!entityId) return [];
@@ -43,34 +35,4 @@ export function useEntityReviews(
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
// Real-time subscription for new reviews (opt-in)
useEffect(() => {
if (!enableRealtime || !entityId || !enabled) return;
const channel = supabase
.channel(`reviews-${entityType}-${entityId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'reviews',
filter: `${entityType}_id=eq.${entityId},moderation_status=eq.approved`,
},
(payload) => {
console.log('⭐ New review posted:', payload.new);
queryClient.invalidateQueries({
queryKey: queryKeys.reviews.entity(entityType, entityId)
});
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [enableRealtime, entityType, entityId, enabled, queryClient]);
return query;
}

View File

@@ -1,39 +0,0 @@
/**
* Model Rides Hook
*
* Fetches rides using a specific ride model with caching.
*/
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useModelRides(modelId: string | undefined, limit?: number) {
return useQuery({
queryKey: queryKeys.rideModels.rides(modelId || '', limit),
queryFn: async () => {
if (!modelId) return [];
let query = supabase
.from('rides')
.select(`
*,
park:parks!inner(name, slug, location:locations(*)),
manufacturer:companies!rides_manufacturer_id_fkey(*),
ride_model:ride_models(id, name, slug, manufacturer_id, category)
`)
.eq('ride_model_id', modelId)
.order('name');
if (limit) query = query.limit(limit);
const { data, error } = await query;
if (error) throw error;
return data || [];
},
enabled: !!modelId,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,32 +0,0 @@
/**
* Model Statistics Hook
*
* Fetches ride model statistics (ride count, photo count) with parallel queries.
*/
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useModelStatistics(modelId: string | undefined) {
return useQuery({
queryKey: queryKeys.rideModels.statistics(modelId || ''),
queryFn: async () => {
if (!modelId) return { rideCount: 0, photoCount: 0 };
const [ridesResult, photosResult] = await Promise.all([
supabase.from('rides').select('id', { count: 'exact', head: true }).eq('ride_model_id', modelId),
supabase.from('photos').select('id', { count: 'exact', head: true }).eq('entity_type', 'ride_model').eq('entity_id', modelId)
]);
return {
rideCount: ridesResult.count || 0,
photoCount: photosResult.count || 0
};
},
enabled: !!modelId,
staleTime: 10 * 60 * 1000,
gcTime: 20 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,48 +0,0 @@
/**
* Ride Model Detail Hook
*
* Fetches ride model and manufacturer data with caching.
*/
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export function useRideModelDetail(
manufacturerSlug: string | undefined,
modelSlug: string | undefined
) {
return useQuery({
queryKey: queryKeys.rideModels.detail(manufacturerSlug || '', modelSlug || ''),
queryFn: async () => {
if (!manufacturerSlug || !modelSlug) return null;
// Fetch manufacturer first
const { data: manufacturer, error: mfgError } = await supabase
.from('companies')
.select('*')
.eq('slug', manufacturerSlug)
.eq('company_type', 'manufacturer')
.maybeSingle();
if (mfgError) throw mfgError;
if (!manufacturer) return null;
// Fetch ride model
const { data: model, error: modelError } = await supabase
.from('ride_models')
.select('*')
.eq('slug', modelSlug)
.eq('manufacturer_id', manufacturer.id)
.maybeSingle();
if (modelError) throw modelError;
return model ? { model, manufacturer } : null;
},
enabled: !!manufacturerSlug && !!modelSlug,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,50 +0,0 @@
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

@@ -1,83 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,87 +0,0 @@
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

@@ -1,54 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { getErrorMessage } from '@/lib/errorHandler';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
interface RevokeSessionParams {
sessionId: string;
isCurrent: boolean;
}
/**
* Hook for session management mutations
* Provides: session revocation with automatic cache invalidation
*/
export function useSecurityMutations() {
const queryClient = useQueryClient();
const { invalidateSessions, invalidateAuditLogs } = useQueryInvalidation();
const revokeSession = useMutation({
mutationFn: async ({ sessionId }: RevokeSessionParams) => {
const { error } = await supabase.rpc('revoke_my_session', {
session_id: sessionId
});
if (error) throw error;
},
onError: (error: unknown) => {
toast.error("Error", {
description: getErrorMessage(error),
});
},
onSuccess: (_data, { isCurrent }) => {
invalidateSessions();
invalidateAuditLogs();
toast.success("Success", {
description: "Session revoked successfully",
});
// Redirect to login if current session was revoked
if (isCurrent) {
setTimeout(() => {
window.location.href = '/auth';
}, 1000);
}
},
});
return {
revokeSession,
isRevoking: revokeSession.isPending,
};
}

View File

@@ -1,34 +0,0 @@
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

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

View File

@@ -15,7 +15,7 @@ interface AuthContextType {
loading: boolean;
pendingEmail: string | null;
sessionError: string | null;
signOut: (scope?: 'global' | 'local' | 'others') => Promise<void>;
signOut: () => Promise<void>;
verifySession: () => Promise<boolean>;
clearPendingEmail: () => void;
checkAalStepUp: () => Promise<CheckAalResult>;
@@ -123,24 +123,6 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
await supabase.auth.signOut();
return;
}
// Enhanced session monitoring: Proactively refresh tokens before expiry
const expiresAt = session.expires_at;
if (expiresAt) {
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = expiresAt - now;
// Refresh 5 minutes (300 seconds) before expiry
if (timeUntilExpiry < 300 && timeUntilExpiry > 0) {
authLog('[Auth] Token expiring soon, refreshing session...');
const { error } = await supabase.auth.refreshSession();
if (error) {
authError('[Auth] Session refresh failed:', error);
} else {
authLog('[Auth] Session refreshed successfully');
}
}
}
} else {
setAal(null);
}
@@ -236,23 +218,12 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
};
}, []);
const signOut = async (scope: 'global' | 'local' | 'others' = 'global') => {
authLog('[Auth] Signing out with scope:', scope);
try {
const { error } = await supabase.auth.signOut({ scope });
if (error) throw error;
// Clear all auth flags (only on global/local sign out)
if (scope !== 'others') {
clearAllAuthFlags();
}
authLog('[Auth] Sign out successful');
} catch (error) {
authError('[Auth] Error signing out:', error);
throw error;
const signOut = async () => {
authLog('[Auth] Signing out...');
const result = await signOutUser();
if (!result.success) {
authError('Error signing out:', result.error);
throw new Error(result.error);
}
};

View File

@@ -1,8 +1,6 @@
import { useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
import { useAuth } from '@/hooks/useAuth';
export type AvatarUploadState = {
url: string;
@@ -15,8 +13,6 @@ export const useAvatarUpload = (
initialImageId: string = '',
username: string
) => {
const { user } = useAuth();
const { invalidateUserProfile } = useQueryInvalidation();
const [state, setState] = useState<AvatarUploadState>({
url: initialUrl,
imageId: initialImageId,
@@ -52,11 +48,6 @@ export const useAvatarUpload = (
setState(prev => ({ ...prev, isUploading: false }));
handleSuccess('Avatar updated', 'Your avatar has been successfully updated.');
// Invalidate user profile cache for instant UI update
if (user?.id) {
invalidateUserProfile(user.id);
}
return { success: true };
} catch (error: unknown) {
// Rollback on error
@@ -73,7 +64,7 @@ export const useAvatarUpload = (
return { success: false, error };
}
}, [username, initialUrl, initialImageId, user?.id, invalidateUserProfile]);
}, [username, initialUrl, initialImageId]);
const resetAvatar = useCallback(() => {
setState({

View File

@@ -1,6 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
export interface CoasterStat {
id: string;
@@ -16,7 +15,7 @@ export interface CoasterStat {
export function useCoasterStats(rideId: string | undefined) {
return useQuery({
queryKey: queryKeys.stats.coaster(rideId || ''),
queryKey: ['coaster-stats', rideId],
queryFn: async () => {
if (!rideId) return [];

View File

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

View File

@@ -1,13 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch public Novu settings accessible to all authenticated users
*/
export function usePublicNovuSettings() {
const { data: settings, isLoading, error } = useQuery({
queryKey: queryKeys.settings.publicNovu(),
queryKey: ['public-novu-settings'],
queryFn: async () => {
const { data, error } = await supabase
.from('admin_settings')

View File

@@ -43,7 +43,6 @@ export function useRequireMFA() {
isEnrolled,
needsEnrollment: requiresMFA && !isEnrolled,
needsVerification,
isBlocked: requiresMFA && (!isEnrolled || (isEnrolled && aal === 'aal1')), // Convenience flag
aal,
loading: loading || roleLoading,
};

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: RideCreditFilters[typeof key]) => {
const updateFilter = useCallback((key: keyof RideCreditFilters, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
}, []);

View File

@@ -1,144 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { toast } from 'sonner';
type ValidRole = 'admin' | 'moderator' | 'user';
interface GrantRoleParams {
userId: string;
role: ValidRole;
}
interface RevokeRoleParams {
roleId: string;
}
interface UserWithRoles {
id: string;
user_id: string;
username: string;
email: string;
display_name: string | null;
avatar_url: string | null;
banned: boolean;
created_at: string;
}
/**
* useRoleMutations Hook
*
* Provides TanStack Query mutations for granting and revoking user roles
* with optimistic updates for instant UI feedback.
*
* Features:
* - Optimistic updates for immediate UI response
* - Automatic cache invalidation on success
* - Error handling with rollback
* - Toast notifications
*
* @example
* ```tsx
* const { grantRole, revokeRole } = useRoleMutations();
*
* grantRole.mutate({ userId: 'user-id', role: 'moderator' });
* revokeRole.mutate({ roleId: 'role-id' });
* ```
*/
export function useRoleMutations() {
const queryClient = useQueryClient();
const grantRole = useMutation({
mutationFn: async ({ userId, role }: GrantRoleParams) => {
const { data, error } = await supabase
.from('user_roles')
.insert([{ user_id: userId, role }])
.select()
.single();
if (error) throw error;
return data;
},
onMutate: async ({ userId, role }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.users.roles() });
// Snapshot previous value
const previousUsers = queryClient.getQueryData<UserWithRoles[]>(queryKeys.users.roles());
// Optimistically update cache - add role to user
queryClient.setQueryData<UserWithRoles[]>(queryKeys.users.roles(), (old) => {
if (!old) return old;
return old.map((user) =>
user.user_id === userId
? { ...user, role } // Optimistically assign role
: user
);
});
return { previousUsers };
},
onError: (error, variables, context) => {
// Rollback on error
if (context?.previousUsers) {
queryClient.setQueryData(queryKeys.users.roles(), context.previousUsers);
}
toast.error(`Failed to grant role: ${error.message}`);
},
onSuccess: (data, { role }) => {
toast.success(`Role ${role} granted successfully`);
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: queryKeys.users.roles() });
queryClient.invalidateQueries({ queryKey: ['user-roles'] });
},
});
const revokeRole = useMutation({
mutationFn: async ({ roleId }: RevokeRoleParams) => {
const { error } = await supabase
.from('user_roles')
.delete()
.eq('id', roleId);
if (error) throw error;
},
onMutate: async ({ roleId }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.users.roles() });
// Snapshot previous value
const previousUsers = queryClient.getQueryData<UserWithRoles[]>(queryKeys.users.roles());
// Optimistically remove role from cache
queryClient.setQueryData<UserWithRoles[]>(queryKeys.users.roles(), (old) => {
if (!old) return old;
// Remove the user from the list since they no longer have a role
return old.filter((user) => user.id !== roleId);
});
return { previousUsers };
},
onError: (error, variables, context) => {
// Rollback on error
if (context?.previousUsers) {
queryClient.setQueryData(queryKeys.users.roles(), context.previousUsers);
}
toast.error(`Failed to revoke role: ${error.message}`);
},
onSuccess: () => {
toast.success('Role revoked successfully');
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: queryKeys.users.roles() });
queryClient.invalidateQueries({ queryKey: ['user-roles'] });
},
});
return {
grantRole,
revokeRole,
};
}

View File

@@ -1,66 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { useAuth } from '@/hooks/useAuth';
/**
* useUserRoles Hook
*
* Fetches all user roles with profile information for admin/moderator management.
*
* Features:
* - Uses RPC get_users_with_emails for comprehensive user data
* - Caches for 3 minutes (roles don't change frequently)
* - Includes email addresses (admin-only RPC)
* - Performance monitoring with slow query warnings
*
* @returns TanStack Query result with user roles array
*
* @example
* ```tsx
* const { data: userRoles, isLoading, refetch } = useUserRoles();
*
* // After granting a role:
* await grantRoleToUser(userId, 'moderator');
* invalidateUserAuth(userId);
* ```
*/
interface UserWithRoles {
id: string;
user_id: string;
username: string;
email: string;
display_name: string | null;
avatar_url: string | null;
banned: boolean;
created_at: string;
}
export function useUserRoles() {
const { user } = useAuth();
return useQuery<UserWithRoles[]>({
queryKey: queryKeys.users.roles(),
queryFn: async () => {
const startTime = performance.now();
const { data, error } = await supabase.rpc('get_users_with_emails');
if (error) throw error;
const duration = performance.now() - startTime;
// Log slow queries in development
if (import.meta.env.DEV && duration > 1000) {
console.warn(`Slow query: useUserRoles took ${duration}ms`);
}
return data || [];
},
enabled: !!user,
staleTime: 3 * 60 * 1000, // 3 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -1,80 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { useAuth } from '@/hooks/useAuth';
/**
* useUserSearch Hook
*
* Searches users with caching support for admin/moderator user management.
*
* Features:
* - Uses RPC get_users_with_emails for comprehensive data
* - Client-side filtering for flexible search
* - Caches each search term for 2 minutes
* - Only runs when search term is at least 2 characters
* - Performance monitoring with slow query warnings
*
* @param searchTerm - Search query (username or email)
*
* @returns TanStack Query result with filtered user array
*
* @example
* ```tsx
* const [search, setSearch] = useState('');
* const { data: users, isLoading } = useUserSearch(search);
*
* // Search updates automatically with caching
* <Input value={search} onChange={(e) => setSearch(e.target.value)} />
* ```
*/
interface UserSearchResult {
id: string;
user_id: string;
username: string;
email: string;
display_name: string | null;
avatar_url: string | null;
banned: boolean;
created_at: string;
}
export function useUserSearch(searchTerm: string) {
const { user } = useAuth();
return useQuery<UserSearchResult[]>({
queryKey: queryKeys.users.search(searchTerm),
queryFn: async () => {
const startTime = performance.now();
const { data, error } = await supabase.rpc('get_users_with_emails');
if (error) throw error;
const allUsers = data || [];
const searchLower = searchTerm.toLowerCase();
// Client-side filtering for flexible search
const filtered = allUsers.filter(
(u) =>
u.username.toLowerCase().includes(searchLower) ||
u.email.toLowerCase().includes(searchLower) ||
(u.display_name && u.display_name.toLowerCase().includes(searchLower))
);
const duration = performance.now() - startTime;
// Log slow queries in development
if (import.meta.env.DEV && duration > 1000) {
console.warn(`Slow query: useUserSearch took ${duration}ms`, { searchTerm });
}
return filtered;
},
enabled: !!user && searchTerm.length >= 2,
staleTime: 2 * 60 * 1000, // 2 minutes
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
}

View File

@@ -63,7 +63,6 @@ export type Database = {
Row: {
action: string
admin_user_id: string
auth0_event_type: string | null
created_at: string
details: Json | null
id: string
@@ -72,7 +71,6 @@ export type Database = {
Insert: {
action: string
admin_user_id: string
auth0_event_type?: string | null
created_at?: string
details?: Json | null
id?: string
@@ -81,7 +79,6 @@ export type Database = {
Update: {
action?: string
admin_user_id?: string
auth0_event_type?: string | null
created_at?: string
details?: Json | null
id?: string
@@ -122,57 +119,6 @@ export type Database = {
}
Relationships: []
}
auth0_sync_log: {
Row: {
auth0_sub: string
completed_at: string | null
created_at: string
error_message: string | null
id: string
metadata: Json | null
sync_status: string
sync_type: string
user_id: string | null
}
Insert: {
auth0_sub: string
completed_at?: string | null
created_at?: string
error_message?: string | null
id?: string
metadata?: Json | null
sync_status?: string
sync_type: string
user_id?: string | null
}
Update: {
auth0_sub?: string
completed_at?: string | null
created_at?: string
error_message?: string | null
id?: string
metadata?: Json | null
sync_status?: string
sync_type?: string
user_id?: string | null
}
Relationships: [
{
foreignKeyName: "auth0_sync_log_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "filtered_profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "auth0_sync_log_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
]
}
blog_posts: {
Row: {
author_id: string
@@ -2022,7 +1968,6 @@ export type Database = {
}
profiles: {
Row: {
auth0_sub: string | null
avatar_image_id: string | null
avatar_url: string | null
ban_expires_at: string | null
@@ -2056,7 +2001,6 @@ export type Database = {
username: string
}
Insert: {
auth0_sub?: string | null
avatar_image_id?: string | null
avatar_url?: string | null
ban_expires_at?: string | null
@@ -2090,7 +2034,6 @@ export type Database = {
username: string
}
Update: {
auth0_sub?: string | null
avatar_image_id?: string | null
avatar_url?: string | null
ban_expires_at?: string | null
@@ -4587,7 +4530,6 @@ export type Database = {
Returns: undefined
}
backfill_sort_orders: { Args: never; Returns: undefined }
block_aal1_with_mfa: { Args: never; Returns: boolean }
can_approve_submission_item: {
Args: { item_id: string }
Returns: boolean
@@ -4659,8 +4601,6 @@ export type Database = {
extract_cf_image_id: { Args: { url: string }; Returns: string }
generate_deletion_confirmation_code: { Args: never; Returns: string }
generate_ticket_number: { Args: never; Returns: string }
get_auth0_sub_from_jwt: { Args: never; Returns: string }
get_current_user_id: { Args: never; Returns: string }
get_email_change_status: { Args: never; Returns: Json }
get_filtered_profile: {
Args: { _profile_user_id: string; _viewer_id?: string }
@@ -4729,7 +4669,6 @@ export type Database = {
Returns: Json
}
has_aal2: { Args: never; Returns: boolean }
has_auth0_mfa: { Args: never; Returns: boolean }
has_mfa_enabled: { Args: { _user_id: string }; Returns: boolean }
has_pending_dependents: { Args: { item_id: string }; Returns: boolean }
has_role: {
@@ -4745,7 +4684,6 @@ export type Database = {
Args: { post_slug: string }
Returns: undefined
}
is_auth0_user: { Args: never; Returns: boolean }
is_moderator: { Args: { _user_id: string }; Returns: boolean }
is_superuser: { Args: { _user_id: string }; Returns: boolean }
is_user_banned: { Args: { _user_id: string }; Returns: boolean }

View File

@@ -1,32 +0,0 @@
/**
* Auth0 Configuration
*
* Centralized configuration for Auth0 authentication
*/
export const auth0Config = {
domain: import.meta.env.VITE_AUTH0_DOMAIN || '',
clientId: import.meta.env.VITE_AUTH0_CLIENT_ID || '',
authorizationParams: {
redirect_uri: typeof window !== 'undefined' ? `${window.location.origin}/auth/callback` : '',
audience: import.meta.env.VITE_AUTH0_DOMAIN ? `https://${import.meta.env.VITE_AUTH0_DOMAIN}/api/v2/` : '',
scope: 'openid profile email'
},
cacheLocation: 'localstorage' as const,
useRefreshTokens: true,
useRefreshTokensFallback: true,
};
/**
* Check if Auth0 is properly configured
*/
export function isAuth0Configured(): boolean {
return !!(auth0Config.domain && auth0Config.clientId);
}
/**
* Get Auth0 Management API audience
*/
export function getManagementAudience(): string {
return `https://${auth0Config.domain}/api/v2/`;
}

View File

@@ -1,138 +0,0 @@
/**
* Auth0 Management API Helper
*
* Provides helper functions to interact with Auth0 Management API
*/
import { supabase } from '@/integrations/supabase/client';
import type { Auth0MFAStatus, Auth0RoleInfo, ManagementTokenResponse } from '@/types/auth0';
/**
* Get Auth0 Management API access token via edge function
*/
export async function getManagementToken(): Promise<string> {
const { data, error } = await supabase.functions.invoke<ManagementTokenResponse>(
'auth0-get-management-token',
{ method: 'POST' }
);
if (error || !data) {
throw new Error('Failed to get management token: ' + (error?.message || 'Unknown error'));
}
return data.access_token;
}
/**
* Get user's MFA enrollment status
*/
export async function getMFAStatus(userId: string): Promise<Auth0MFAStatus> {
try {
const token = await getManagementToken();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const response = await fetch(`https://${domain}/api/v2/users/${userId}/enrollments`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch MFA status');
}
const enrollments = await response.json();
return {
enrolled: enrollments.length > 0,
methods: enrollments.map((e: any) => ({
id: e.id,
type: e.type,
name: e.name,
confirmed: e.status === 'confirmed',
})),
};
} catch (error) {
console.error('Error fetching MFA status:', error);
return { enrolled: false, methods: [] };
}
}
/**
* Get user's roles from Auth0
*/
export async function getUserRoles(userId: string): Promise<Auth0RoleInfo[]> {
try {
const token = await getManagementToken();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const response = await fetch(`https://${domain}/api/v2/users/${userId}/roles`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch user roles');
}
const roles = await response.json();
return roles.map((role: any) => ({
id: role.id,
name: role.name,
description: role.description,
}));
} catch (error) {
console.error('Error fetching user roles:', error);
return [];
}
}
/**
* Update user metadata
*/
export async function updateUserMetadata(
userId: string,
metadata: Record<string, any>
): Promise<boolean> {
try {
const token = await getManagementToken();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const response = await fetch(`https://${domain}/api/v2/users/${userId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_metadata: metadata,
}),
});
return response.ok;
} catch (error) {
console.error('Error updating user metadata:', error);
return false;
}
}
/**
* Trigger MFA enrollment for user
*/
export async function triggerMFAEnrollment(redirectUri?: string): Promise<void> {
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const redirect = redirectUri || `${window.location.origin}/settings`;
// Redirect to Auth0 MFA enrollment page
window.location.href = `https://${domain}/authorize?` +
`client_id=${clientId}&` +
`response_type=code&` +
`redirect_uri=${encodeURIComponent(redirect)}&` +
`scope=openid profile email&` +
`prompt=enroll`;
}

View File

@@ -1,387 +0,0 @@
/**
* Cache Performance Monitoring Utilities
*
* Provides tools to monitor React Query cache performance in production.
* Use sparingly - only enable when debugging performance issues.
*
* Features:
* - Cache hit/miss tracking
* - Query duration monitoring
* - Slow query detection
* - Invalidation frequency tracking
*
* @example
* import { cacheMonitor } from '@/lib/cacheMonitoring';
*
* // Start monitoring (development only)
* if (process.env.NODE_ENV === 'development') {
* cacheMonitor.start();
* }
*
* // Get metrics
* const metrics = cacheMonitor.getMetrics();
* console.log('Cache hit rate:', metrics.hitRate);
*/
import { QueryClient } from '@tanstack/react-query';
import { logger } from '@/lib/logger';
interface CacheMetrics {
hits: number;
misses: number;
hitRate: number;
totalQueries: number;
avgQueryTime: number;
slowQueries: number;
invalidations: number;
lastReset: Date;
}
interface QueryTiming {
queryKey: string;
startTime: number;
endTime?: number;
duration?: number;
status: 'pending' | 'success' | 'error';
}
class CacheMonitor {
private metrics: CacheMetrics;
private queryTimings: Map<string, QueryTiming>;
private slowQueryThreshold: number = 500; // ms
private enabled: boolean = false;
private listeners: {
onSlowQuery?: (queryKey: string, duration: number) => void;
onCacheMiss?: (queryKey: string) => void;
onInvalidation?: (queryKey: string) => void;
} = {};
constructor() {
this.metrics = this.resetMetrics();
this.queryTimings = new Map();
}
/**
* Start monitoring cache performance
* Should only be used in development or for debugging
*/
start(queryClient?: QueryClient) {
if (this.enabled) {
logger.warn('Cache monitor already started');
return;
}
this.enabled = true;
this.metrics = this.resetMetrics();
logger.info('Cache monitor started', {
slowQueryThreshold: this.slowQueryThreshold
});
// If queryClient provided, set up automatic tracking
if (queryClient) {
this.setupQueryClientTracking(queryClient);
}
}
/**
* Stop monitoring
*/
stop() {
this.enabled = false;
this.queryTimings.clear();
logger.info('Cache monitor stopped');
}
/**
* Reset all metrics
*/
reset() {
this.metrics = this.resetMetrics();
this.queryTimings.clear();
logger.info('Cache metrics reset');
}
/**
* Record a cache hit (data served from cache)
*/
recordHit(queryKey: string) {
if (!this.enabled) return;
this.metrics.hits++;
this.metrics.totalQueries++;
this.updateHitRate();
logger.debug('Cache hit', { queryKey });
}
/**
* Record a cache miss (data fetched from server)
*/
recordMiss(queryKey: string) {
if (!this.enabled) return;
this.metrics.misses++;
this.metrics.totalQueries++;
this.updateHitRate();
logger.debug('Cache miss', { queryKey });
if (this.listeners.onCacheMiss) {
this.listeners.onCacheMiss(queryKey);
}
}
/**
* Start timing a query
*/
startQuery(queryKey: string) {
if (!this.enabled) return;
const key = this.normalizeQueryKey(queryKey);
this.queryTimings.set(key, {
queryKey: key,
startTime: performance.now(),
status: 'pending'
});
}
/**
* End timing a query
*/
endQuery(queryKey: string, status: 'success' | 'error') {
if (!this.enabled) return;
const key = this.normalizeQueryKey(queryKey);
const timing = this.queryTimings.get(key);
if (!timing) {
logger.warn('Query timing not found', { queryKey: key });
return;
}
const endTime = performance.now();
const duration = endTime - timing.startTime;
timing.endTime = endTime;
timing.duration = duration;
timing.status = status;
// Update average query time
const totalTime = this.metrics.avgQueryTime * (this.metrics.totalQueries - 1) + duration;
this.metrics.avgQueryTime = totalTime / this.metrics.totalQueries;
// Check for slow query
if (duration > this.slowQueryThreshold) {
this.metrics.slowQueries++;
logger.warn('Slow query detected', {
queryKey: key,
duration: Math.round(duration),
threshold: this.slowQueryThreshold
});
if (this.listeners.onSlowQuery) {
this.listeners.onSlowQuery(key, duration);
}
}
// Clean up
this.queryTimings.delete(key);
}
/**
* Record a cache invalidation
*/
recordInvalidation(queryKey: string) {
if (!this.enabled) return;
this.metrics.invalidations++;
logger.debug('Cache invalidated', { queryKey });
if (this.listeners.onInvalidation) {
this.listeners.onInvalidation(queryKey);
}
}
/**
* Get current metrics
*/
getMetrics(): Readonly<CacheMetrics> {
return { ...this.metrics };
}
/**
* Get metrics as formatted string
*/
getMetricsReport(): string {
const m = this.metrics;
const uptimeMinutes = Math.round((Date.now() - m.lastReset.getTime()) / 1000 / 60);
return `
Cache Performance Report
========================
Uptime: ${uptimeMinutes} minutes
Total Queries: ${m.totalQueries}
Cache Hits: ${m.hits} (${(m.hitRate * 100).toFixed(1)}%)
Cache Misses: ${m.misses}
Avg Query Time: ${Math.round(m.avgQueryTime)}ms
Slow Queries: ${m.slowQueries}
Invalidations: ${m.invalidations}
`.trim();
}
/**
* Log current metrics to console
*/
logMetrics() {
console.log(this.getMetricsReport());
}
/**
* Set slow query threshold (in milliseconds)
*/
setSlowQueryThreshold(ms: number) {
this.slowQueryThreshold = ms;
logger.info('Slow query threshold updated', { threshold: ms });
}
/**
* Register event listeners
*/
on(event: 'slowQuery', callback: (queryKey: string, duration: number) => void): void;
on(event: 'cacheMiss', callback: (queryKey: string) => void): void;
on(event: 'invalidation', callback: (queryKey: string) => void): void;
on(event: string, callback: (...args: any[]) => void): void {
if (event === 'slowQuery') {
this.listeners.onSlowQuery = callback;
} else if (event === 'cacheMiss') {
this.listeners.onCacheMiss = callback;
} else if (event === 'invalidation') {
this.listeners.onInvalidation = callback;
}
}
/**
* Setup automatic tracking with QueryClient
* @private
*/
private setupQueryClientTracking(queryClient: QueryClient) {
const cache = queryClient.getQueryCache();
// Subscribe to cache updates
const unsubscribe = cache.subscribe((event) => {
if (!this.enabled) return;
const queryKey = this.normalizeQueryKey(event.query.queryKey);
if (event.type === 'updated') {
const query = event.query;
// Check if this is a cache hit or miss
if (query.state.dataUpdatedAt > 0) {
const isCacheHit = query.state.fetchStatus !== 'fetching';
if (isCacheHit) {
this.recordHit(queryKey);
} else {
this.recordMiss(queryKey);
this.startQuery(queryKey);
}
}
// Record when fetch completes
if (query.state.status === 'success' || query.state.status === 'error') {
this.endQuery(queryKey, query.state.status);
}
}
});
// Store unsubscribe function
(this as any)._unsubscribe = unsubscribe;
}
/**
* Normalize query key to string for tracking
* @private
*/
private normalizeQueryKey(queryKey: string | readonly unknown[]): string {
if (typeof queryKey === 'string') {
return queryKey;
}
return JSON.stringify(queryKey);
}
/**
* Update hit rate percentage
* @private
*/
private updateHitRate() {
if (this.metrics.totalQueries === 0) {
this.metrics.hitRate = 0;
} else {
this.metrics.hitRate = this.metrics.hits / this.metrics.totalQueries;
}
}
/**
* Reset metrics to initial state
* @private
*/
private resetMetrics(): CacheMetrics {
return {
hits: 0,
misses: 0,
hitRate: 0,
totalQueries: 0,
avgQueryTime: 0,
slowQueries: 0,
invalidations: 0,
lastReset: new Date()
};
}
}
// Singleton instance
export const cacheMonitor = new CacheMonitor();
/**
* Hook to use cache monitoring in React components
* Only use for debugging - do not leave in production code
*
* @example
* function DebugPanel() {
* const metrics = useCacheMonitoring();
*
* return (
* <div>
* <h3>Cache Stats</h3>
* <p>Hit Rate: {(metrics.hitRate * 100).toFixed(1)}%</p>
* <p>Avg Query Time: {Math.round(metrics.avgQueryTime)}ms</p>
* </div>
* );
* }
*/
export function useCacheMonitoring() {
// Re-render when metrics change (simple polling)
const [metrics, setMetrics] = React.useState(cacheMonitor.getMetrics());
React.useEffect(() => {
const interval = setInterval(() => {
setMetrics(cacheMonitor.getMetrics());
}, 1000);
return () => clearInterval(interval);
}, []);
return metrics;
}
// Only import React if using the hook
let React: any;
try {
React = require('react');
} catch {
// React not available, hook won't work but main exports still functional
}

View File

@@ -223,76 +223,6 @@ export async function connectIdentity(
}
}
/**
* Link an OAuth identity to the logged-in user's account (Manual Linking)
* Requires user to be authenticated
*/
export async function linkOAuthIdentity(
provider: OAuthProvider
): Promise<IdentityOperationResult> {
try {
const { data, error } = await supabase.auth.linkIdentity({
provider
});
if (error) throw error;
// Log audit event
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await logIdentityChange(user.id, 'identity_linked', {
provider,
method: 'manual',
timestamp: new Date().toISOString()
});
}
return { success: true };
} catch (error) {
const errorMsg = getErrorMessage(error);
logger.error('Failed to link identity', {
action: 'identity_link',
provider,
error: errorMsg
});
return {
success: false,
error: errorMsg
};
}
}
/**
* Log when automatic identity linking occurs
* Called internally when Supabase automatically links identities
*/
export async function logAutomaticIdentityLinking(
userId: string,
provider: OAuthProvider,
email: string
): Promise<void> {
try {
await logIdentityChange(userId, 'identity_auto_linked', {
provider,
email,
method: 'automatic',
timestamp: new Date().toISOString()
});
logger.info('Automatic identity linking logged', {
userId,
provider,
action: 'identity_auto_linked'
});
} catch (error) {
logger.error('Failed to log automatic identity linking', {
userId,
provider,
error: getErrorMessage(error)
});
}
}
/**
* Add password authentication to an OAuth-only account

View File

@@ -95,70 +95,6 @@ export function useQueryInvalidation() {
}
},
/**
* Invalidate user profile cache
* Call this after profile updates
*/
invalidateUserProfile: (userId: string) => {
queryClient.invalidateQueries({ queryKey: queryKeys.profile.detail(userId) });
},
/**
* Invalidate profile stats cache
* Call this after profile-related changes
*/
invalidateProfileStats: (userId: string) => {
queryClient.invalidateQueries({ queryKey: queryKeys.profile.stats(userId) });
},
/**
* Invalidate profile activity cache
* Call this after activity changes
*/
invalidateProfileActivity: (userId: string) => {
queryClient.invalidateQueries({ queryKey: ['profile', 'activity', userId] });
},
/**
* Invalidate user search results
* Call this when display names change
*/
invalidateUserSearch: () => {
queryClient.invalidateQueries({ queryKey: ['users', 'search'] });
},
/**
* Invalidate admin settings cache
* Call this after updating admin settings
*/
invalidateAdminSettings: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.admin.settings() });
},
/**
* Invalidate audit logs cache
* Call this after inserting audit log entries
*/
invalidateAuditLogs: (userId?: string) => {
queryClient.invalidateQueries({ queryKey: queryKeys.admin.auditLogs(userId) });
},
/**
* Invalidate contact submissions cache
* Call this after updating contact submissions
*/
invalidateContactSubmissions: () => {
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
},
/**
* Invalidate blog posts cache
* Call this after creating/updating/deleting blog posts
*/
invalidateBlogPosts: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.admin.blogPosts() });
},
/**
* Invalidate parks listing cache
* Call this after creating/updating/deleting parks
@@ -250,147 +186,5 @@ export function useQueryInvalidation() {
queryKey: ['homepage', 'featured-parks']
});
},
/**
* Invalidate company detail cache
* Call this after updating a company
*/
invalidateCompanyDetail: (slug: string, type: string) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.detail(slug, type) });
},
/**
* Invalidate company statistics cache
* Call this after changes affecting company stats
*/
invalidateCompanyStatistics: (id: string, type: string) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.statistics(id, type) });
},
/**
* Invalidate company parks cache
* Call this after park changes
*/
invalidateCompanyParks: (id: string, type: 'operator' | 'property_owner') => {
queryClient.invalidateQueries({ queryKey: ['companies', 'parks', id, type] });
},
/**
* Invalidate ride model detail cache
* Call this after updating a ride model
*/
invalidateRideModelDetail: (manufacturerSlug: string, modelSlug: string) => {
queryClient.invalidateQueries({ queryKey: queryKeys.rideModels.detail(manufacturerSlug, modelSlug) });
},
/**
* Invalidate ride model statistics cache
* Call this after changes affecting model stats
*/
invalidateRideModelStatistics: (modelId: string) => {
queryClient.invalidateQueries({ queryKey: queryKeys.rideModels.statistics(modelId) });
},
/**
* Invalidate model rides cache
* Call this after ride changes
*/
invalidateModelRides: (modelId: string, limit?: number) => {
queryClient.invalidateQueries({
queryKey: queryKeys.rideModels.rides(modelId, limit),
});
},
/**
* Invalidate entity name cache
* Call this after updating an entity's name
*/
invalidateEntityName: (entityType: string, entityId: string) => {
queryClient.invalidateQueries({
queryKey: queryKeys.entities.name(entityType, entityId)
});
},
/**
* Invalidate blog post cache
* Call this after updating a blog post
*/
invalidateBlogPost: (slug: string) => {
queryClient.invalidateQueries({
queryKey: queryKeys.blog.post(slug)
});
},
/**
* Invalidate coaster stats cache
* Call this after updating ride statistics
*/
invalidateCoasterStats: (rideId: string) => {
queryClient.invalidateQueries({
queryKey: queryKeys.stats.coaster(rideId)
});
},
/**
* 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)
*/
invalidateSecurityQueries: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.security.emailChangeStatus()
});
queryClient.invalidateQueries({
queryKey: queryKeys.security.sessions()
});
},
/**
* Smart invalidation for related entities
* Invalidates entity detail, photos, reviews, and name cache
* Call this after any entity update
*/
invalidateRelatedEntities: (entityType: string, entityId: string) => {
// Invalidate the entity itself
if (entityType === 'park') {
queryClient.invalidateQueries({
queryKey: queryKeys.parks.detail(entityId)
});
} else if (entityType === 'ride') {
queryClient.invalidateQueries({
queryKey: queryKeys.rides.detail('', entityId)
});
}
// Invalidate photos, reviews, and entity name
queryClient.invalidateQueries({
queryKey: queryKeys.photos.entity(entityType, entityId)
});
queryClient.invalidateQueries({
queryKey: queryKeys.reviews.entity(entityType as 'park' | 'ride', entityId)
});
queryClient.invalidateQueries({
queryKey: queryKeys.entities.name(entityType, entityId)
});
},
};
}

View File

@@ -62,8 +62,8 @@ export const queryKeys = {
// Photos queries
photos: {
entity: (entityType: string, entityId: string, sortBy?: string) =>
['photos', entityType, entityId, sortBy] as const,
entity: (entityType: string, entityId: string) =>
['photos', entityType, entityId] as const,
count: (entityType: string, entityId: string) =>
['photos', 'count', entityType, entityId] as const,
},
@@ -76,81 +76,5 @@ export const queryKeys = {
// Lists queries
lists: {
items: (listId: string) => ['list-items', listId] as const,
user: (userId?: string) => ['lists', 'user', userId] as const,
},
// Users queries
users: {
roles: (userId?: string) => ['users', 'roles', userId] as const,
search: (searchTerm: string) => ['users', 'search', searchTerm] as const,
},
// Admin queries
admin: {
versionAudit: ['admin', 'version-audit'] as const,
settings: () => ['admin-settings'] as const,
blogPosts: () => ['admin-blog-posts'] as const,
contactSubmissions: (statusFilter?: string, categoryFilter?: string, searchQuery?: string, showArchived?: boolean) =>
['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived] as const,
auditLogs: (userId?: string) => ['admin', 'audit-logs', userId] as const,
},
// Moderation queries
moderation: {
photoSubmission: (submissionId?: string) => ['moderation', 'photo-submission', submissionId] as const,
recentActivity: ['moderation', 'recent-activity'] as const,
},
// Company queries
companies: {
all: (type: string) => ['companies', 'all', type] as const,
detail: (slug: string, type: string) => ['companies', 'detail', slug, type] as const,
statistics: (id: string, type: string) => ['companies', 'statistics', id, type] as const,
parks: (id: string, type: string, limit: number) => ['companies', 'parks', id, type, limit] as const,
},
// Profile queries
profile: {
detail: (userId: string) => ['profile', userId] as const,
activity: (userId: string, isOwn: boolean, isMod: boolean) =>
['profile', 'activity', userId, isOwn, isMod] as const,
stats: (userId: string) => ['profile', 'stats', userId] as const,
},
// Ride Models queries
rideModels: {
all: (manufacturerId: string) => ['ride-models', 'all', manufacturerId] as const,
detail: (manufacturerSlug: string, modelSlug: string) =>
['ride-models', 'detail', manufacturerSlug, modelSlug] as const,
rides: (modelId: string, limit?: number) =>
['ride-models', 'rides', modelId, limit] as const,
statistics: (modelId: string) => ['ride-models', 'statistics', modelId] as const,
},
// Settings queries
settings: {
publicNovu: () => ['public-novu-settings'] as const,
},
// Stats queries
stats: {
coaster: (rideId: string) => ['coaster-stats', rideId] as const,
},
// Blog queries
blog: {
post: (slug: string) => ['blog-post', slug] as const,
viewIncrement: (slug: string) => ['blog-view-increment', slug] as const,
},
// Entity name queries (for PhotoManagementDialog)
entities: {
name: (entityType: string, entityId: string) => ['entity-name', entityType, entityId] as const,
},
// Security queries
security: {
emailChangeStatus: () => ['email-change-status'] as const,
sessions: () => ['my-sessions'] as const,
},
} as const;

Some files were not shown because too many files have changed in this diff Show More