mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 02:51:12 -05:00
Reverted to commit 0091584677
This commit is contained in:
@@ -30,9 +30,4 @@ VITE_ALLOW_CAPTCHA_BYPASS=false
|
|||||||
# For self-hosted Novu, replace with your instance URLs
|
# For self-hosted Novu, replace with your instance URLs
|
||||||
VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier
|
VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier
|
||||||
VITE_NOVU_SOCKET_URL=wss://ws.novu.co
|
VITE_NOVU_SOCKET_URL=wss://ws.novu.co
|
||||||
VITE_NOVU_API_URL=https://api.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
|
|
||||||
@@ -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
61
package-lock.json
generated
@@ -8,8 +8,6 @@
|
|||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth0/auth0-react": "^2.8.0",
|
|
||||||
"@auth0/auth0-spa-js": "^2.8.0",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -65,7 +63,6 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jose": "^6.1.0",
|
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -116,30 +113,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
@@ -6034,16 +6007,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.27.0",
|
"version": "4.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
|
||||||
@@ -6783,15 +6746,6 @@
|
|||||||
"react": ">=16.12.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -6963,12 +6917,6 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -8538,15 +8486,6 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth0/auth0-react": "^2.8.0",
|
|
||||||
"@auth0/auth0-spa-js": "^2.8.0",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -68,7 +66,6 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jose": "^6.1.0",
|
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
58
src/App.tsx
58
src/App.tsx
@@ -3,12 +3,10 @@ import { lazy, Suspense } from "react";
|
|||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
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 { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { CacheMonitor } from "@/components/dev/CacheMonitor";
|
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { AuthProvider } from "@/hooks/useAuth";
|
import { AuthProvider } from "@/hooks/useAuth";
|
||||||
import { Auth0Provider } from "@/contexts/Auth0Provider";
|
|
||||||
import { AuthModalProvider } from "@/contexts/AuthModalContext";
|
import { AuthModalProvider } from "@/contexts/AuthModalContext";
|
||||||
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
|
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
|
||||||
import { Analytics } from "@vercel/analytics/react";
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
@@ -63,7 +61,6 @@ const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings")
|
|||||||
const Profile = lazy(() => import("./pages/Profile"));
|
const Profile = lazy(() => import("./pages/Profile"));
|
||||||
const UserSettings = lazy(() => import("./pages/UserSettings"));
|
const UserSettings = lazy(() => import("./pages/UserSettings"));
|
||||||
const AuthCallback = lazy(() => import("./pages/AuthCallback"));
|
const AuthCallback = lazy(() => import("./pages/AuthCallback"));
|
||||||
const Auth0Callback = lazy(() => import("./pages/Auth0Callback"));
|
|
||||||
|
|
||||||
// Utility routes (lazy-loaded)
|
// Utility routes (lazy-loaded)
|
||||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
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
|
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 {
|
function AppContent(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -161,7 +125,6 @@ function AppContent(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* User routes - lazy loaded */}
|
{/* User routes - lazy loaded */}
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
<Route path="/auth/auth0-callback" element={<Auth0Callback />} />
|
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/profile/:username" element={<Profile />} />
|
<Route path="/profile/:username" element={<Profile />} />
|
||||||
<Route path="/settings" element={<UserSettings />} />
|
<Route path="/settings" element={<UserSettings />} />
|
||||||
@@ -193,19 +156,12 @@ function AppContent(): React.JSX.Element {
|
|||||||
|
|
||||||
const App = (): React.JSX.Element => (
|
const App = (): React.JSX.Element => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Auth0Provider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<AuthModalProvider>
|
||||||
<AuthModalProvider>
|
<AppContent />
|
||||||
<AppContent />
|
</AuthModalProvider>
|
||||||
</AuthModalProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
|
||||||
</Auth0Provider>
|
|
||||||
{import.meta.env.DEV && (
|
|
||||||
<>
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} position="bottom" />
|
|
||||||
<CacheMonitor />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
|
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
|
||||||
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
||||||
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -72,57 +70,73 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
setSignInCaptchaToken(null);
|
setSignInCaptchaToken(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call server-side auth check with MFA detection
|
const signInOptions: any = {
|
||||||
const { data: authResult, error: authError } = await supabase.functions.invoke(
|
email: formData.email,
|
||||||
'auth-with-mfa-check',
|
password: formData.password,
|
||||||
{
|
};
|
||||||
body: {
|
|
||||||
email: formData.email,
|
if (tokenToUse) {
|
||||||
password: formData.password,
|
signInOptions.options = { captchaToken: tokenToUse };
|
||||||
captchaToken: tokenToUse,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (authError || authResult.error) {
|
|
||||||
throw new Error(authResult?.error || authError?.message || 'Authentication failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is banned
|
const { data, error } = await supabase.auth.signInWithPassword(signInOptions);
|
||||||
if (authResult.banned) {
|
if (error) throw error;
|
||||||
const reason = authResult.banReason
|
|
||||||
? `Reason: ${authResult.banReason}`
|
|
||||||
: 'Contact support for assistance.';
|
|
||||||
|
|
||||||
|
// 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({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Account Suspended",
|
title: "Account Suspended",
|
||||||
description: `Your account has been suspended. ${reason}`,
|
description: `Your account has been suspended. ${reason}`,
|
||||||
duration: 10000,
|
duration: 10000
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return; // Stop authentication flow
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if MFA is required
|
// Check if MFA is required (user exists but no session)
|
||||||
if (authResult.mfaRequired) {
|
if (data.user && !data.session) {
|
||||||
// NO SESSION EXISTS YET - show MFA challenge
|
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
|
||||||
console.log('[AuthModal] MFA required - no session created yet');
|
|
||||||
setMfaFactorId(authResult.factorId);
|
if (totpFactor) {
|
||||||
setMfaChallengeId(authResult.challengeId);
|
setMfaFactorId(totpFactor.id);
|
||||||
setMfaPendingUserId(authResult.userId);
|
setLoading(false);
|
||||||
setLoading(false);
|
return;
|
||||||
return; // User has NO session - MFA modal will show
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No MFA required - user has session
|
|
||||||
console.log('[AuthModal] No MFA required - user authenticated');
|
|
||||||
|
|
||||||
// Set the session in Supabase client
|
// Track auth method for audit logging
|
||||||
if (authResult.session) {
|
setAuthMethod('password');
|
||||||
await supabase.auth.setSession(authResult.session);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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({
|
toast({
|
||||||
title: "Welcome back!",
|
title: "Welcome back!",
|
||||||
description: "You've been signed in successfully."
|
description: "You've been signed in successfully."
|
||||||
@@ -148,34 +162,30 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMfaSuccess = async () => {
|
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);
|
setMfaFactorId(null);
|
||||||
setMfaChallengeId(null);
|
|
||||||
setMfaPendingUserId(null);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Authentication complete",
|
|
||||||
description: "You've been signed in successfully.",
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMfaCancel = async () => {
|
const handleMfaCancel = () => {
|
||||||
console.log('[AuthModal] User cancelled MFA verification');
|
|
||||||
|
|
||||||
// Clear state
|
|
||||||
setMfaFactorId(null);
|
setMfaFactorId(null);
|
||||||
setMfaChallengeId(null);
|
|
||||||
setMfaPendingUserId(null);
|
|
||||||
setSignInCaptchaKey(prev => prev + 1);
|
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) => {
|
const handleSignUp = async (e: React.FormEvent) => {
|
||||||
@@ -234,7 +244,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
options: {
|
options: {
|
||||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
|
||||||
data: {
|
data: {
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
display_name: formData.displayName
|
display_name: formData.displayName
|
||||||
@@ -369,8 +378,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
{mfaFactorId ? (
|
{mfaFactorId ? (
|
||||||
<MFAChallenge
|
<MFAChallenge
|
||||||
factorId={mfaFactorId}
|
factorId={mfaFactorId}
|
||||||
challengeId={mfaChallengeId}
|
|
||||||
userId={mfaPendingUserId}
|
|
||||||
onSuccess={handleMfaSuccess}
|
onSuccess={handleMfaSuccess}
|
||||||
onCancel={handleMfaCancel}
|
onCancel={handleMfaCancel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,18 +5,15 @@ import { getErrorMessage } from '@/lib/errorHandler';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Shield } from 'lucide-react';
|
||||||
import { Shield, AlertCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
interface MFAChallengeProps {
|
interface MFAChallengeProps {
|
||||||
factorId: string;
|
factorId: string;
|
||||||
challengeId?: string | null;
|
|
||||||
userId?: string | null;
|
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCancel }: MFAChallengeProps) {
|
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -26,38 +23,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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
|
// Create fresh challenge for each verification attempt
|
||||||
const { data: challengeData, error: challengeError } =
|
const { data: challengeData, error: challengeError } =
|
||||||
await supabase.auth.mfa.challenge({ factorId });
|
await supabase.auth.mfa.challenge({ factorId });
|
||||||
@@ -94,14 +59,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="flex items-center gap-2 text-primary">
|
||||||
<Shield className="w-5 h-5" />
|
<Shield className="w-5 h-5" />
|
||||||
<h3 className="font-semibold">Two-Factor Authentication</h3>
|
<h3 className="font-semibold">Two-Factor Authentication</h3>
|
||||||
@@ -139,7 +96,7 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancel & Sign Out
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleVerify}
|
onClick={handleVerify}
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ interface MFAStepUpModalProps {
|
|||||||
export function MFAStepUpModal({ open, factorId, onSuccess, onCancel }: MFAStepUpModalProps) {
|
export function MFAStepUpModal({ open, factorId, onSuccess, onCancel }: MFAStepUpModalProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
||||||
<DialogContent
|
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
|
||||||
className="sm:max-w-md"
|
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
|
||||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2 justify-center mb-2">
|
<div className="flex items-center gap-2 justify-center mb-2">
|
||||||
<Shield className="h-6 w-6 text-primary" />
|
<Shield className="h-6 w-6 text-primary" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,54 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Star, TrendingUp, Award, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
|
import { Star, TrendingUp, Award, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Park } from '@/types/database';
|
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() {
|
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 }) => (
|
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]">
|
<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>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (topRated.isLoading || mostRides.isLoading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
|
|||||||
@@ -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 { Link } from "react-router-dom";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useListItems } from "@/hooks/lists/useListItems";
|
|
||||||
|
|
||||||
interface ListDisplayProps {
|
interface ListDisplayProps {
|
||||||
list: UserTopList;
|
list: UserTopList;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListDisplay({ list }: ListDisplayProps) {
|
interface EnrichedListItem extends UserTopListItem {
|
||||||
const { data: items, isLoading } = useListItems(list.id);
|
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 "#";
|
if (!item.entity) return "#";
|
||||||
|
|
||||||
const entity = item.entity as { slug?: string };
|
const entity = item.entity as { slug?: string };
|
||||||
@@ -27,11 +89,11 @@ export function ListDisplay({ list }: ListDisplayProps) {
|
|||||||
return "#";
|
return "#";
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (loading) {
|
||||||
return <div className="text-center py-4 text-muted-foreground">Loading...</div>;
|
return <div className="text-center py-4 text-muted-foreground">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
This list is empty. Click "Edit" to add items.
|
This list is empty. Click "Edit" to add items.
|
||||||
|
|||||||
@@ -1,28 +1,78 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
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 {
|
interface PhotoSubmissionDisplayProps {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) {
|
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>;
|
return <div className="text-sm text-muted-foreground">Loading photos...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-destructive">
|
<div className="text-sm text-destructive">
|
||||||
Error loading photos: {error.message}
|
Error loading photos: {error}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-xs">Submission ID: {submissionId}</span>
|
<span className="text-xs">Submission ID: {submissionId}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!photos || photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
No photos found for this submission
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,171 @@
|
|||||||
import { forwardRef, useImperativeHandle } from 'react';
|
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { useRecentActivity } from '@/hooks/moderation/useRecentActivity';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import { ActivityCard } from './ActivityCard';
|
import { ActivityCard } from './ActivityCard';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Activity as ActivityIcon } from 'lucide-react';
|
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 {
|
export interface RecentActivityRef {
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RecentActivity = forwardRef<RecentActivityRef>((props, ref) => {
|
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, () => ({
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useReportMutation } from '@/hooks/reports/useReportMutation';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
|
||||||
interface ReportButtonProps {
|
interface ReportButtonProps {
|
||||||
entityType: 'review' | 'profile' | 'content_submission';
|
entityType: 'review' | 'profile' | 'content_submission';
|
||||||
@@ -40,23 +42,42 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [reportType, setReportType] = useState('');
|
const [reportType, setReportType] = useState('');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
const reportMutation = useReportMutation();
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
if (!user || !reportType) return;
|
if (!user || !reportType) return;
|
||||||
|
|
||||||
reportMutation.mutate(
|
setLoading(true);
|
||||||
{ entityType, entityId, reportType, reason },
|
try {
|
||||||
{
|
const { error } = await supabase.from('reports').insert({
|
||||||
onSuccess: () => {
|
reporter_id: user.id,
|
||||||
setOpen(false);
|
reported_entity_type: entityType,
|
||||||
setReportType('');
|
reported_entity_id: entityId,
|
||||||
setReason('');
|
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;
|
if (!user) return null;
|
||||||
@@ -115,10 +136,10 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!reportType || reportMutation.isPending}
|
disabled={!reportType || loading}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
{reportMutation.isPending ? 'Submitting...' : 'Submit Report'}
|
{loading ? 'Submitting...' : 'Submit Report'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { smartMergeArray } from '@/lib/smartStateUpdate';
|
import { smartMergeArray } from '@/lib/smartStateUpdate';
|
||||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||||
import { useReportActionMutation } from '@/hooks/reports/useReportActionMutation';
|
|
||||||
|
|
||||||
// Type-safe reported content interfaces
|
// Type-safe reported content interfaces
|
||||||
interface ReportedReview {
|
interface ReportedReview {
|
||||||
@@ -116,7 +115,6 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
|||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [newReportsCount, setNewReportsCount] = useState(0);
|
const [newReportsCount, setNewReportsCount] = useState(0);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { resolveReport, isResolving } = useReportActionMutation();
|
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -348,29 +346,67 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
|||||||
};
|
};
|
||||||
}, [user, refreshMode, pollInterval, isInitialLoad]);
|
}, [user, refreshMode, pollInterval, isInitialLoad]);
|
||||||
|
|
||||||
const handleReportAction = (reportId: string, action: 'reviewed' | 'dismissed') => {
|
const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => {
|
||||||
setActionLoading(reportId);
|
setActionLoading(reportId);
|
||||||
|
try {
|
||||||
resolveReport.mutate(
|
// Fetch full report details including reporter_id for audit log
|
||||||
{ reportId, action },
|
const { data: reportData } = await supabase
|
||||||
{
|
.from('reports')
|
||||||
onSuccess: () => {
|
.select('reporter_id, reported_entity_type, reported_entity_id, reason')
|
||||||
// Remove report from queue
|
.eq('id', reportId)
|
||||||
setReports(prev => {
|
.single();
|
||||||
const newReports = prev.filter(r => r.id !== reportId);
|
|
||||||
// If last item on page and not page 1, go to previous page
|
const { error } = await supabase
|
||||||
if (newReports.length === 0 && currentPage > 1) {
|
.from('reports')
|
||||||
setCurrentPage(prev => prev - 1);
|
.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);
|
} catch (auditError) {
|
||||||
},
|
console.error('Failed to log report action audit:', auditError);
|
||||||
onError: () => {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
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
|
// Sort reports function
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { logger } from '@/lib/logger';
|
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
|
// Type-safe role definitions
|
||||||
const VALID_ROLES = ['admin', 'moderator', 'user'] as const;
|
const VALID_ROLES = ['admin', 'moderator', 'user'] as const;
|
||||||
@@ -55,36 +52,175 @@ interface UserRole {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function UserRoleManager() {
|
export function UserRoleManager() {
|
||||||
|
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [newUserSearch, setNewUserSearch] = useState('');
|
const [newUserSearch, setNewUserSearch] = useState('');
|
||||||
const [newRole, setNewRole] = useState('');
|
const [newRole, setNewRole] = useState('');
|
||||||
const [selectedUsers, setSelectedUsers] = useState<ProfileSearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<ProfileSearchResult[]>([]);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const { user } = useAuth();
|
const {
|
||||||
const { isAdmin, isSuperuser } = useUserRole();
|
user
|
||||||
const { data: userRoles = [], isLoading: loading } = useUserRoles();
|
} = useAuth();
|
||||||
const { data: searchResults = [] } = useUserSearch(newUserSearch);
|
const {
|
||||||
const { grantRole, revokeRole } = useRoleMutations();
|
isAdmin,
|
||||||
const handleGrantRole = () => {
|
isSuperuser,
|
||||||
const selectedUser = selectedUsers[0];
|
permissions
|
||||||
if (!selectedUser || !newRole || !isValidRole(newRole)) return;
|
} = 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(
|
// Get unique user IDs
|
||||||
{ userId: selectedUser.user_id, role: newRole },
|
const userIds = [...new Set((data || []).map(r => r.user_id))];
|
||||||
{
|
|
||||||
onSuccess: () => {
|
// Fetch user profiles with emails (for admins)
|
||||||
setNewUserSearch('');
|
let profiles: Array<{ user_id: string; username: string; display_name?: string }> | null = null;
|
||||||
setNewRole('');
|
const { data: allProfiles, error: rpcError } = await supabase
|
||||||
setSelectedUsers([]);
|
.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) => {
|
// Combine data with profiles
|
||||||
revokeRole.mutate({ roleId });
|
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()) {
|
if (!isAdmin()) {
|
||||||
return <div className="text-center py-8">
|
return <div className="text-center py-8">
|
||||||
<Shield className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
<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 className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
// Filter existing user IDs for search results
|
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()));
|
||||||
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())
|
|
||||||
);
|
|
||||||
return <div className="space-y-6">
|
return <div className="space-y-6">
|
||||||
{/* Add new role */}
|
{/* Add new role */}
|
||||||
<Card>
|
<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" />
|
<Input id="user-search" placeholder="Search by username or display name..." value={newUserSearch} onChange={e => setNewUserSearch(e.target.value)} className="pl-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{availableSearchResults.length > 0 && <div className="mt-2 border rounded-lg bg-background">
|
{searchResults.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.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);
|
setNewUserSearch(profile.display_name || profile.username);
|
||||||
setSelectedUsers([profile]);
|
setSearchResults([profile]);
|
||||||
}}>
|
}}>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{profile.display_name || profile.username}
|
{profile.display_name || profile.username}
|
||||||
@@ -152,13 +278,23 @@ export function UserRoleManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button onClick={() => {
|
||||||
onClick={handleGrantRole}
|
const selectedUser = searchResults.find(p => (p.display_name || p.username) === newUserSearch);
|
||||||
disabled={!newRole || !isValidRole(newRole) || selectedUsers.length === 0 || grantRole.isPending}
|
|
||||||
className="w-full md:w-auto"
|
// Type-safe validation before calling grantRole
|
||||||
>
|
if (selectedUser && newRole && isValidRole(newRole)) {
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
grantRole(selectedUser.user_id, newRole);
|
||||||
{grantRole.isPending ? 'Granting...' : 'Grant Role'}
|
} 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>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -185,31 +321,21 @@ export function UserRoleManager() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{userRole.display_name || userRole.username}
|
{userRole.profiles?.display_name || userRole.profiles?.username}
|
||||||
</div>
|
</div>
|
||||||
{userRole.display_name && <div className="text-sm text-muted-foreground">
|
{userRole.profiles?.display_name && <div className="text-sm text-muted-foreground">
|
||||||
@{userRole.username}
|
@{userRole.profiles.username}
|
||||||
</div>}
|
|
||||||
{userRole.email && <div className="text-xs text-muted-foreground">
|
|
||||||
{userRole.email}
|
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={userRole.id === 'admin' ? 'default' : 'secondary'}>
|
<Badge variant={userRole.role === 'admin' ? 'default' : 'secondary'}>
|
||||||
{getRoleLabel(userRole.id)}
|
{userRole.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Only show revoke button if current user can manage this role */}
|
{/* Only show revoke button if current user can manage this role */}
|
||||||
{(isSuperuser() || (isAdmin() && !['admin', 'superuser'].includes(userRole.id))) && (
|
{(isSuperuser() || isAdmin() && !['admin', 'superuser'].includes(userRole.role)) && <Button variant="outline" size="sm" onClick={() => revokeRole(userRole.id)} disabled={actionLoading === userRole.id}>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRevokeRole(userRole.id)}
|
|
||||||
disabled={revokeRole.isPending}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</Button>
|
</Button>}
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>)}
|
</Card>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,61 +1,19 @@
|
|||||||
import { MapPin, Star, Users, Clock, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
|
import { MapPin, Star, Users, Clock, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Park } from '@/types/database';
|
import { Park } from '@/types/database';
|
||||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
|
|
||||||
interface ParkCardProps {
|
interface ParkCardProps {
|
||||||
park: Park;
|
park: Park;
|
||||||
}
|
}
|
||||||
export function ParkCard({ park }: ParkCardProps) {
|
export function ParkCard({ park }: ParkCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
navigate(`/parks/${park.slug}`);
|
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) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'operating':
|
case 'operating':
|
||||||
@@ -98,7 +56,7 @@ export function ParkCard({ park }: ParkCardProps) {
|
|||||||
const formatParkType = (type: string) => {
|
const formatParkType = (type: string) => {
|
||||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
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">
|
<div className="relative overflow-hidden">
|
||||||
{/* Image Placeholder with Gradient */}
|
{/* 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">
|
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 flex items-center justify-center relative">
|
||||||
|
|||||||
@@ -1,18 +1,153 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||||
import { UserX, Trash2 } from 'lucide-react';
|
import { UserX, Trash2 } from 'lucide-react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useBlockedUsers } from '@/hooks/privacy/useBlockedUsers';
|
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||||
import { useBlockUserMutation } from '@/hooks/privacy/useBlockUserMutation';
|
import { logger } from '@/lib/logger';
|
||||||
|
import type { UserBlock } from '@/types/privacy';
|
||||||
|
|
||||||
export function BlockedUsers() {
|
export function BlockedUsers() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: blockedUsers = [], isLoading: loading } = useBlockedUsers(user?.id);
|
const [blockedUsers, setBlockedUsers] = useState<UserBlock[]>([]);
|
||||||
const { unblockUser, isUnblocking } = useBlockUserMutation();
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const handleUnblock = (blockId: string, blockedUserId: string, username: string) => {
|
useEffect(() => {
|
||||||
unblockUser.mutate({ blockId, blockedUserId, username });
|
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) {
|
if (loading) {
|
||||||
@@ -76,7 +211,7 @@ export function BlockedUsers() {
|
|||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" disabled={isUnblocking}>
|
<Button variant="outline" size="sm">
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
Unblock
|
Unblock
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { RideCreditFilters } from './RideCreditFilters';
|
|||||||
import { UserRideCredit } from '@/types/database';
|
import { UserRideCredit } from '@/types/database';
|
||||||
import { useRideCreditFilters } from '@/hooks/useRideCreditFilters';
|
import { useRideCreditFilters } from '@/hooks/useRideCreditFilters';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { useRideCreditsMutation } from '@/hooks/rides/useRideCreditsMutation';
|
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -40,7 +39,6 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
|||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { reorderCredit, isReordering } = useRideCreditsMutation();
|
|
||||||
|
|
||||||
// Use the filter hook
|
// Use the filter hook
|
||||||
const {
|
const {
|
||||||
@@ -248,16 +246,24 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReorder = (creditId: string, newPosition: number) => {
|
const handleReorder = async (creditId: string, newPosition: number) => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
try {
|
||||||
reorderCredit.mutate(
|
const { error } = await supabase.rpc('reorder_ride_credit', {
|
||||||
{ creditId, newPosition },
|
p_credit_id: creditId,
|
||||||
{
|
p_new_position: newPosition
|
||||||
onSuccess: () => resolve(),
|
});
|
||||||
onError: (error) => reject(error)
|
|
||||||
}
|
if (error) throw error;
|
||||||
);
|
|
||||||
});
|
// No refetch - optimistic update is already applied
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleError(error, {
|
||||||
|
action: 'Reorder Ride Credit',
|
||||||
|
userId,
|
||||||
|
metadata: { creditId, newPosition }
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import { StarRating } from './StarRating';
|
|||||||
import { toDateOnly } from '@/lib/dateUtils';
|
import { toDateOnly } from '@/lib/dateUtils';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
|
||||||
|
|
||||||
const reviewSchema = z.object({
|
const reviewSchema = z.object({
|
||||||
rating: z.number().min(0.5).max(5).multipleOf(0.5),
|
rating: z.number().min(0.5).max(5).multipleOf(0.5),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
@@ -43,7 +41,6 @@ export function ReviewForm({
|
|||||||
const {
|
const {
|
||||||
user
|
user
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
const { invalidateEntityReviews } = useQueryInvalidation();
|
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [photos, setPhotos] = useState<string[]>([]);
|
const [photos, setPhotos] = useState<string[]>([]);
|
||||||
@@ -121,10 +118,6 @@ export function ReviewForm({
|
|||||||
title: "Review Submitted!",
|
title: "Review Submitted!",
|
||||||
description: "Thank you for your review. It will be published after moderation."
|
description: "Thank you for your review. It will be published after moderation."
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate review cache for instant UI update
|
|
||||||
invalidateEntityReviews(entityType, entityId);
|
|
||||||
|
|
||||||
reset();
|
reset();
|
||||||
setRating(0);
|
setRating(0);
|
||||||
setPhotos([]);
|
setPhotos([]);
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Star, MapPin, Clock, Zap, FerrisWheel, Waves, Theater, Train, ArrowUp, CheckCircle, Calendar, Hammer, XCircle } from 'lucide-react';
|
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 { MeasurementDisplay } from '@/components/ui/measurement-display';
|
||||||
import { Ride } from '@/types/database';
|
import { Ride } from '@/types/database';
|
||||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
|
|
||||||
interface RideCardProps {
|
interface RideCardProps {
|
||||||
ride: Ride;
|
ride: Ride;
|
||||||
@@ -18,47 +15,11 @@ interface RideCardProps {
|
|||||||
|
|
||||||
export function RideCard({ ride, showParkName = true, className, parkSlug }: RideCardProps) {
|
export function RideCard({ ride, showParkName = true, className, parkSlug }: RideCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handleRideClick = () => {
|
const handleRideClick = () => {
|
||||||
const slug = parkSlug || ride.park?.slug;
|
const slug = parkSlug || ride.park?.slug;
|
||||||
navigate(`/parks/${slug}/rides/${ride.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) => {
|
const getRideIcon = (category: string) => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
@@ -100,7 +61,6 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid
|
|||||||
<Card
|
<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}`}
|
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}
|
onClick={handleRideClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
{/* Image/Icon Section */}
|
{/* Image/Icon Section */}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { FerrisWheel } from 'lucide-react';
|
import { FerrisWheel } from 'lucide-react';
|
||||||
import { RideModel } from '@/types/database';
|
import { RideModel } from '@/types/database';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
|
|
||||||
interface RideModelCardProps {
|
interface RideModelCardProps {
|
||||||
model: RideModel;
|
model: RideModel;
|
||||||
@@ -15,23 +12,6 @@ interface RideModelCardProps {
|
|||||||
|
|
||||||
export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
|
export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
|
||||||
const navigate = useNavigate();
|
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) => {
|
const formatCategory = (category: string | null | undefined) => {
|
||||||
if (!category) return 'Unknown';
|
if (!category) return 'Unknown';
|
||||||
@@ -62,7 +42,6 @@ export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
|
|||||||
<Card
|
<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="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}`)}
|
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">
|
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 relative overflow-hidden">
|
||||||
{(cardImageUrl || cardImageId) ? (
|
{(cardImageUrl || cardImageId) ? (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RideCard } from '@/components/rides/RideCard';
|
import { RideCard } from '@/components/rides/RideCard';
|
||||||
import { useSimilarRides } from '@/hooks/rides/useSimilarRides';
|
|
||||||
|
|
||||||
interface SimilarRidesProps {
|
interface SimilarRidesProps {
|
||||||
currentRideId: string;
|
currentRideId: string;
|
||||||
@@ -31,9 +32,44 @@ interface SimilarRide {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SimilarRides({ currentRideId, parkId, parkSlug, category }: SimilarRidesProps) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
|||||||
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
|
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
|
||||||
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
||||||
import { useAutoSave } from '@/hooks/useAutoSave';
|
import { useAutoSave } from '@/hooks/useAutoSave';
|
||||||
import { useProfileUpdateMutation } from '@/hooks/profile/useProfileUpdateMutation';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ type ProfileFormData = z.infer<typeof profileSchema>;
|
|||||||
export function AccountProfileTab() {
|
export function AccountProfileTab() {
|
||||||
const { user, pendingEmail, clearPendingEmail } = useAuth();
|
const { user, pendingEmail, clearPendingEmail } = useAuth();
|
||||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||||
const updateProfileMutation = useProfileUpdateMutation();
|
const [loading, setLoading] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showEmailDialog, setShowEmailDialog] = useState(false);
|
const [showEmailDialog, setShowEmailDialog] = useState(false);
|
||||||
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
|
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
|
||||||
@@ -108,28 +107,47 @@ export function AccountProfileTab() {
|
|||||||
const handleFormSubmit = async (data: ProfileFormData) => {
|
const handleFormSubmit = async (data: ProfileFormData) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
// Update Novu subscriber if username changed (before mutation for optimistic update)
|
setLoading(true);
|
||||||
const usernameChanged = data.username !== profile?.username;
|
try {
|
||||||
|
// Use the update_profile RPC function with server-side validation
|
||||||
updateProfileMutation.mutate({
|
const { data: result, error } = await supabase.rpc('update_profile', {
|
||||||
userId: user.id,
|
p_username: data.username,
|
||||||
updates: {
|
p_display_name: data.display_name || null,
|
||||||
username: data.username,
|
p_bio: data.bio || null
|
||||||
display_name: data.display_name || null,
|
});
|
||||||
bio: data.bio || null
|
|
||||||
}
|
if (error) {
|
||||||
}, {
|
// Handle rate limiting error
|
||||||
onSuccess: async () => {
|
if (error.code === 'P0001') {
|
||||||
if (usernameChanged && notificationService.isEnabled()) {
|
const resetTime = error.message.match(/Try again at (.+)$/)?.[1];
|
||||||
await notificationService.updateSubscriber({
|
throw new AppError(
|
||||||
subscriberId: user.id,
|
error.message,
|
||||||
email: user.email,
|
'RATE_LIMIT',
|
||||||
firstName: data.username,
|
`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) => {
|
const onSubmit = async (data: ProfileFormData) => {
|
||||||
@@ -382,17 +400,17 @@ export function AccountProfileTab() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
updateProfileMutation.isPending ||
|
loading ||
|
||||||
isDeactivated ||
|
isDeactivated ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
usernameValidation.isChecking ||
|
usernameValidation.isChecking ||
|
||||||
usernameValidation.isAvailable === false
|
usernameValidation.isAvailable === false
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{updateProfileMutation.isPending || isSaving ? 'Saving...' : 'Save Changes'}
|
{loading || isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{lastSaved && !updateProfileMutation.isPending && !isSaving && (
|
{lastSaved && !loading && !isSaving && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
|
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useEmailChangeMutation } from '@/hooks/security/useEmailChangeMutation';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -53,7 +52,6 @@ interface EmailChangeDialogProps {
|
|||||||
|
|
||||||
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
|
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { changeEmail, isChanging } = useEmailChangeMutation();
|
|
||||||
const [step, setStep] = useState<Step>('verification');
|
const [step, setStep] = useState<Step>('verification');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [captchaToken, setCaptchaToken] = useState<string>('');
|
const [captchaToken, setCaptchaToken] = useState<string>('');
|
||||||
@@ -158,18 +156,63 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
|
|||||||
throw signInError;
|
throw signInError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Update email address using mutation hook
|
// Step 3: Update email address
|
||||||
changeEmail.mutate(
|
// Supabase will send verification emails to both old and new addresses
|
||||||
{ newEmail: data.newEmail, currentEmail, userId },
|
const { error: updateError } = await supabase.auth.updateUser({
|
||||||
{
|
email: data.newEmail
|
||||||
onSuccess: () => {
|
});
|
||||||
setStep('success');
|
|
||||||
},
|
if (updateError) throw updateError;
|
||||||
onError: (error) => {
|
|
||||||
throw error;
|
// 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) {
|
} catch (error: unknown) {
|
||||||
const errorMsg = getErrorMessage(error);
|
const errorMsg = getErrorMessage(error);
|
||||||
logger.error('Email change failed', {
|
logger.error('Email change failed', {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -8,7 +8,6 @@ import { Progress } from '@/components/ui/progress';
|
|||||||
import { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react';
|
import { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||||
import { useEmailChangeStatus } from '@/hooks/security/useEmailChangeStatus';
|
|
||||||
|
|
||||||
interface EmailChangeStatusProps {
|
interface EmailChangeStatusProps {
|
||||||
currentEmail: string;
|
currentEmail: string;
|
||||||
@@ -16,19 +15,55 @@ interface EmailChangeStatusProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailChangeData = {
|
||||||
|
has_pending_change: boolean;
|
||||||
|
current_email?: string;
|
||||||
|
new_email?: string;
|
||||||
|
current_email_verified?: boolean;
|
||||||
|
new_email_verified?: boolean;
|
||||||
|
change_sent_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function EmailChangeStatus({
|
export function EmailChangeStatus({
|
||||||
currentEmail,
|
currentEmail,
|
||||||
pendingEmail,
|
pendingEmail,
|
||||||
onCancel
|
onCancel
|
||||||
}: EmailChangeStatusProps) {
|
}: EmailChangeStatusProps) {
|
||||||
|
const [verificationStatus, setVerificationStatus] = useState({
|
||||||
|
oldEmailVerified: false,
|
||||||
|
newEmailVerified: false
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [resending, setResending] = useState(false);
|
const [resending, setResending] = useState(false);
|
||||||
const { data: emailStatus, isLoading } = useEmailChangeStatus();
|
|
||||||
|
|
||||||
const verificationStatus = {
|
const checkVerificationStatus = async () => {
|
||||||
oldEmailVerified: emailStatus?.current_email_verified || false,
|
try {
|
||||||
newEmailVerified: emailStatus?.new_email_verified || false
|
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 () => {
|
const handleResendVerification = async () => {
|
||||||
setResending(true);
|
setResending(true);
|
||||||
try {
|
try {
|
||||||
@@ -53,7 +88,7 @@ export function EmailChangeStatus({
|
|||||||
(verificationStatus.oldEmailVerified ? 50 : 0) +
|
(verificationStatus.oldEmailVerified ? 50 : 0) +
|
||||||
(verificationStatus.newEmailVerified ? 50 : 0);
|
(verificationStatus.newEmailVerified ? 50 : 0);
|
||||||
|
|
||||||
if (isLoading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-blue-500/30">
|
<Card className="border-blue-500/30">
|
||||||
<CardContent className="flex items-center justify-center py-8">
|
<CardContent className="flex items-center justify-center py-8">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useProfile } from '@/hooks/useProfile';
|
import { useProfile } from '@/hooks/useProfile';
|
||||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||||
import { useProfileLocationMutation } from '@/hooks/profile/useProfileLocationMutation';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
@@ -31,8 +30,8 @@ export function LocationTab() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||||
const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences();
|
const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences();
|
||||||
const { updateLocation, isUpdating } = useProfileLocationMutation();
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [parks, setParks] = useState<ParkOption[]>([]);
|
const [parks, setParks] = useState<ParkOption[]>([]);
|
||||||
const [accessibility, setAccessibility] = useState<AccessibilityOptions>(DEFAULT_ACCESSIBILITY_OPTIONS);
|
const [accessibility, setAccessibility] = useState<AccessibilityOptions>(DEFAULT_ACCESSIBILITY_OPTIONS);
|
||||||
|
|
||||||
@@ -172,11 +171,42 @@ export function LocationTab() {
|
|||||||
const onSubmit = async (data: LocationFormData) => {
|
const onSubmit = async (data: LocationFormData) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validatedData = locationFormSchema.parse(data);
|
const validatedData = locationFormSchema.parse(data);
|
||||||
const validatedAccessibility = accessibilityOptionsSchema.parse(accessibility);
|
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
|
const { error: accessibilityError } = await supabase
|
||||||
.from('user_preferences')
|
.from('user_preferences')
|
||||||
.update({
|
.update({
|
||||||
@@ -197,20 +227,34 @@ export function LocationTab() {
|
|||||||
|
|
||||||
await updateUnitPreferences(unitPreferences);
|
await updateUnitPreferences(unitPreferences);
|
||||||
|
|
||||||
// Update profile via mutation hook with complete validated data
|
await supabase.from('profile_audit_log').insert([{
|
||||||
const locationData: LocationFormData = {
|
user_id: user.id,
|
||||||
personal_location: validatedData.personal_location || null,
|
changed_by: user.id,
|
||||||
home_park_id: validatedData.home_park_id || null,
|
action: 'location_info_updated',
|
||||||
timezone: validatedData.timezone,
|
changes: JSON.parse(JSON.stringify({
|
||||||
preferred_language: validatedData.preferred_language,
|
previous: {
|
||||||
preferred_pronouns: validatedData.preferred_pronouns || null,
|
profile: previousProfile,
|
||||||
};
|
accessibility: DEFAULT_ACCESSIBILITY_OPTIONS
|
||||||
|
},
|
||||||
|
updated: {
|
||||||
|
profile: validatedData,
|
||||||
|
accessibility: validatedAccessibility
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}))
|
||||||
|
}]);
|
||||||
|
|
||||||
updateLocation.mutate(locationData, {
|
await refreshProfile();
|
||||||
onSuccess: () => {
|
|
||||||
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) {
|
} catch (error: unknown) {
|
||||||
logger.error('Error saving location settings', {
|
logger.error('Error saving location settings', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -233,6 +277,8 @@ export function LocationTab() {
|
|||||||
userId: user.id
|
userId: user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -512,8 +558,8 @@ export function LocationTab() {
|
|||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isUpdating}>
|
<Button type="submit" disabled={saving}>
|
||||||
{isUpdating ? 'Saving...' : 'Save Settings'}
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { usePasswordUpdateMutation } from '@/hooks/security/usePasswordUpdateMutation';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -45,7 +45,6 @@ function isErrorWithCode(error: unknown): error is Error & ErrorWithCode {
|
|||||||
|
|
||||||
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
|
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { updatePassword, isUpdating } = usePasswordUpdateMutation();
|
|
||||||
const [step, setStep] = useState<Step>('password');
|
const [step, setStep] = useState<Step>('password');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nonce, setNonce] = useState<string>('');
|
const [nonce, setNonce] = useState<string>('');
|
||||||
@@ -289,26 +288,62 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
|
|||||||
|
|
||||||
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
|
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
|
||||||
try {
|
try {
|
||||||
updatePassword.mutate(
|
// Step 2: Update password
|
||||||
{ password, hasMFA, userId },
|
const { error: updateError } = await supabase.auth.updateUser({
|
||||||
{
|
password
|
||||||
onSuccess: () => {
|
});
|
||||||
setStep('success');
|
|
||||||
form.reset();
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
// Auto-close after 2 seconds
|
// Step 3: Log audit trail
|
||||||
setTimeout(() => {
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
onOpenChange(false);
|
if (user) {
|
||||||
onSuccess();
|
await supabase.from('admin_audit_log').insert({
|
||||||
setStep('password');
|
admin_user_id: user.id,
|
||||||
setTotpCode('');
|
target_user_id: user.id,
|
||||||
}, 2000);
|
action: 'password_changed',
|
||||||
},
|
details: {
|
||||||
onError: (error) => {
|
timestamp: new Date().toISOString(),
|
||||||
throw error;
|
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) {
|
} catch (error: unknown) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useProfile } from '@/hooks/useProfile';
|
import { useProfile } from '@/hooks/useProfile';
|
||||||
import { usePrivacyMutations } from '@/hooks/privacy/usePrivacyMutations';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Eye, UserX, Shield, Search } from 'lucide-react';
|
import { Eye, UserX, Shield, Search } from 'lucide-react';
|
||||||
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
|
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
|
||||||
@@ -22,7 +21,7 @@ import { z } from 'zod';
|
|||||||
export function PrivacyTab() {
|
export function PrivacyTab() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||||
const { updatePrivacy, isUpdating } = usePrivacyMutations();
|
const [loading, setLoading] = useState(false);
|
||||||
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
|
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
|
||||||
|
|
||||||
const form = useForm<PrivacyFormData>({
|
const form = useForm<PrivacyFormData>({
|
||||||
@@ -135,17 +134,106 @@ export function PrivacyTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (data: PrivacyFormData) => {
|
const onSubmit = async (data: PrivacyFormData) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
updatePrivacy.mutate(data, {
|
setLoading(true);
|
||||||
onSuccess: () => {
|
|
||||||
refreshProfile();
|
try {
|
||||||
// Extract privacy settings (exclude profile fields)
|
// Validate the form data
|
||||||
const { privacy_level, show_pronouns, ...privacySettings } = data;
|
const validated = privacyFormSchema.parse(data);
|
||||||
setPreferences(privacySettings);
|
|
||||||
|
// 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 (
|
return (
|
||||||
@@ -362,8 +450,8 @@ export function PrivacyTab() {
|
|||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isUpdating}>
|
<Button type="submit" disabled={loading}>
|
||||||
{isUpdating ? 'Saving...' : 'Save Privacy Settings'}
|
{loading ? 'Saving...' : 'Save Privacy Settings'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useSecurityMutations } from '@/hooks/security/useSecurityMutations';
|
|
||||||
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
|
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -14,7 +13,6 @@ import { TOTPSetup } from '@/components/auth/TOTPSetup';
|
|||||||
import { GoogleIcon } from '@/components/icons/GoogleIcon';
|
import { GoogleIcon } from '@/components/icons/GoogleIcon';
|
||||||
import { DiscordIcon } from '@/components/icons/DiscordIcon';
|
import { DiscordIcon } from '@/components/icons/DiscordIcon';
|
||||||
import { PasswordUpdateDialog } from './PasswordUpdateDialog';
|
import { PasswordUpdateDialog } from './PasswordUpdateDialog';
|
||||||
import { useSessions } from '@/hooks/security/useSessions';
|
|
||||||
import {
|
import {
|
||||||
getUserIdentities,
|
getUserIdentities,
|
||||||
checkDisconnectSafety,
|
checkDisconnectSafety,
|
||||||
@@ -31,21 +29,20 @@ import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog';
|
|||||||
export function SecurityTab() {
|
export function SecurityTab() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { revokeSession, isRevoking } = useSecurityMutations();
|
|
||||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||||
const [loadingIdentities, setLoadingIdentities] = useState(true);
|
const [loadingIdentities, setLoadingIdentities] = useState(true);
|
||||||
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
|
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
|
||||||
const [hasPassword, setHasPassword] = useState(false);
|
const [hasPassword, setHasPassword] = useState(false);
|
||||||
const [addingPassword, setAddingPassword] = useState(false);
|
const [addingPassword, setAddingPassword] = useState(false);
|
||||||
|
const [sessions, setSessions] = useState<AuthSession[]>([]);
|
||||||
|
const [loadingSessions, setLoadingSessions] = useState(true);
|
||||||
const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null);
|
const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null);
|
||||||
|
|
||||||
// Fetch sessions using hook
|
|
||||||
const { data: sessions = [], isLoading: loadingSessions, refetch: refetchSessions } = useSessions(user?.id);
|
|
||||||
|
|
||||||
// Load user identities on mount
|
// Load user identities on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadIdentities();
|
loadIdentities();
|
||||||
|
fetchSessions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadIdentities = async () => {
|
const loadIdentities = async () => {
|
||||||
@@ -146,6 +143,35 @@ export function SecurityTab() {
|
|||||||
setAddingPassword(false);
|
setAddingPassword(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
setLoadingSessions(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('get_my_sessions');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessions((data as AuthSession[]) || []);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error('Failed to fetch sessions', {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'fetch_sessions',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
handleError(error, {
|
||||||
|
action: 'Load active sessions',
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
setSessions([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingSessions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const initiateSessionRevoke = async (sessionId: string) => {
|
const initiateSessionRevoke = async (sessionId: string) => {
|
||||||
// Get current session to check if revoking self
|
// Get current session to check if revoking self
|
||||||
const { data: { session: currentSession } } = await supabase.auth.getSession();
|
const { data: { session: currentSession } } = await supabase.auth.getSession();
|
||||||
@@ -156,23 +182,33 @@ export function SecurityTab() {
|
|||||||
setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession });
|
setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession });
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmRevokeSession = () => {
|
const confirmRevokeSession = async () => {
|
||||||
if (!sessionToRevoke) return;
|
if (!sessionToRevoke) return;
|
||||||
|
|
||||||
revokeSession.mutate(
|
const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionToRevoke.id });
|
||||||
{ sessionId: sessionToRevoke.id, isCurrent: sessionToRevoke.isCurrent },
|
|
||||||
{
|
if (error) {
|
||||||
onSuccess: () => {
|
logger.error('Failed to revoke session', {
|
||||||
if (!sessionToRevoke.isCurrent) {
|
userId: user?.id,
|
||||||
refetchSessions();
|
action: 'revoke_session',
|
||||||
}
|
sessionId: sessionToRevoke.id,
|
||||||
setSessionToRevoke(null);
|
error: error.message
|
||||||
},
|
});
|
||||||
onError: () => {
|
handleError(error, { action: 'Revoke session', userId: user?.id });
|
||||||
setSessionToRevoke(null);
|
} 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) => {
|
const getDeviceIcon = (userAgent: string | null) => {
|
||||||
@@ -261,77 +297,77 @@ export function SecurityTab() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Identity Management Section */}
|
{/* Connected Accounts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5" />
|
<Globe className="w-5 h-5" />
|
||||||
<CardTitle>Connected Accounts</CardTitle>
|
<CardTitle>Connected Accounts</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage your social login connections for easier access to your account.
|
Manage your social login connections for easier access to your account.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{loadingIdentities ? (
|
{loadingIdentities ? (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connectedAccounts.map(account => {
|
connectedAccounts.map(account => {
|
||||||
const isConnected = !!account.identity;
|
const isConnected = !!account.identity;
|
||||||
const isDisconnecting = disconnectingProvider === account.provider;
|
const isDisconnecting = disconnectingProvider === account.provider;
|
||||||
const email = account.identity?.identity_data?.email;
|
const email = account.identity?.identity_data?.email;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
|
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||||
{account.icon}
|
{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>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-medium capitalize">{account.provider}</p>
|
{isConnected ? (
|
||||||
{isConnected && email && (
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{email}</p>
|
<Badge variant="secondary">Connected</Badge>
|
||||||
)}
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => handleUnlinkSocial(account.provider)}
|
||||||
{isConnected ? (
|
disabled={isDisconnecting}
|
||||||
<>
|
>
|
||||||
<Badge variant="secondary">Connected</Badge>
|
{isDisconnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Disconnecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Disconnect'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleUnlinkSocial(account.provider)}
|
onClick={() => handleSocialLogin(account.provider)}
|
||||||
disabled={isDisconnecting}
|
|
||||||
>
|
>
|
||||||
{isDisconnecting ? (
|
Connect
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Disconnecting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Disconnect'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSocialLogin(account.provider)}
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})
|
||||||
})
|
)}
|
||||||
)}
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-Factor Authentication - Full Width */}
|
{/* Two-Factor Authentication - Full Width */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react';
|
import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -14,10 +14,11 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
||||||
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
|
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
|
||||||
import { PhotoModal } from '@/components/moderation/PhotoModal';
|
import { PhotoModal } from '@/components/moderation/PhotoModal';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,25 +38,47 @@ export function EntityPhotoGallery({
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isModerator } = useUserRole();
|
const { isModerator } = useUserRole();
|
||||||
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [showManagement, setShowManagement] = useState(false);
|
const [showManagement, setShowManagement] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest');
|
const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest');
|
||||||
|
|
||||||
// Use optimized photos hook with caching
|
useEffect(() => {
|
||||||
const { data: photos = [], isLoading: loading, refetch } = useEntityPhotos(
|
fetchPhotos();
|
||||||
entityType,
|
}, [entityId, entityType, sortBy]);
|
||||||
entityId,
|
|
||||||
sortBy
|
|
||||||
);
|
|
||||||
|
|
||||||
// Query invalidation for cross-component cache updates
|
const fetchPhotos = async () => {
|
||||||
const {
|
try {
|
||||||
invalidateEntityPhotos,
|
// Fetch photos directly from the photos table
|
||||||
invalidatePhotoCount,
|
const { data: photoData, error } = await supabase
|
||||||
invalidateHomepageData
|
.from('photos')
|
||||||
} = useQueryInvalidation();
|
.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 = () => {
|
const handleUploadClick = () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -67,14 +90,7 @@ export function EntityPhotoGallery({
|
|||||||
|
|
||||||
const handleSubmissionComplete = () => {
|
const handleSubmissionComplete = () => {
|
||||||
setShowUpload(false);
|
setShowUpload(false);
|
||||||
|
fetchPhotos(); // Refresh photos after submission
|
||||||
// Invalidate all related caches
|
|
||||||
invalidateEntityPhotos(entityType, entityId);
|
|
||||||
invalidatePhotoCount(entityType, entityId);
|
|
||||||
invalidateHomepageData(); // Photos affect homepage stats
|
|
||||||
|
|
||||||
// Also refetch local component (immediate UI update)
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePhotoClick = (index: number) => {
|
const handlePhotoClick = (index: number) => {
|
||||||
@@ -174,7 +190,7 @@ export function EntityPhotoGallery({
|
|||||||
entityType={entityType}
|
entityType={entityType}
|
||||||
open={showManagement}
|
open={showManagement}
|
||||||
onOpenChange={setShowManagement}
|
onOpenChange={setShowManagement}
|
||||||
onUpdate={() => refetch()}
|
onUpdate={fetchPhotos}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{/* Photo Grid */}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useEntityName } from '@/hooks/entities/useEntityName';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -61,9 +60,6 @@ export function PhotoManagementDialog({
|
|||||||
const [photoToDelete, setPhotoToDelete] = useState<Photo | null>(null);
|
const [photoToDelete, setPhotoToDelete] = useState<Photo | null>(null);
|
||||||
const [deleteReason, setDeleteReason] = useState('');
|
const [deleteReason, setDeleteReason] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Fetch entity name once using cached hook (replaces 4 sequential direct queries)
|
|
||||||
const { data: entityName = 'Unknown' } = useEntityName(entityType, entityId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -110,6 +106,27 @@ export function PhotoManagementDialog({
|
|||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!user) throw new Error('Not authenticated');
|
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
|
// Create content submission
|
||||||
const { data: submission, error: submissionError } = await supabase
|
const { data: submission, error: submissionError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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" />
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
import { toDateOnly } from '@/lib/dateUtils';
|
|
||||||
|
|
||||||
export function useHomepageRecentlyClosedParks(enabled = true) {
|
export function useHomepageRecentlyClosedParks(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -14,9 +13,9 @@ export function useHomepageRecentlyClosedParks(enabled = true) {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('parks')
|
.from('parks')
|
||||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||||
.gte('closing_date', toDateOnly(oneYearAgo))
|
.gte('closed_date', oneYearAgo.toISOString())
|
||||||
.lte('closing_date', toDateOnly(today))
|
.lte('closed_date', today.toISOString())
|
||||||
.order('closing_date', { ascending: false })
|
.order('closed_date', { ascending: false })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -39,10 +38,10 @@ export function useHomepageRecentlyClosedRides(enabled = true) {
|
|||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`*, park:parks(*, location:locations(*))`)
|
.select(`*, park:parks(*), location:locations(*)`)
|
||||||
.gte('closing_date', toDateOnly(oneYearAgo))
|
.gte('closed_date', oneYearAgo.toISOString())
|
||||||
.lte('closing_date', toDateOnly(today))
|
.lte('closed_date', today.toISOString())
|
||||||
.order('closing_date', { ascending: false })
|
.order('closed_date', { ascending: false })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
import { toDateOnly } from '@/lib/dateUtils';
|
|
||||||
|
|
||||||
export function useHomepageClosingSoonParks(enabled = true) {
|
export function useHomepageClosingSoonParks(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -14,9 +13,9 @@ export function useHomepageClosingSoonParks(enabled = true) {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('parks')
|
.from('parks')
|
||||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||||
.gte('closing_date', toDateOnly(today))
|
.gte('closed_date', today.toISOString())
|
||||||
.lte('closing_date', toDateOnly(sixMonthsFromNow))
|
.lte('closed_date', sixMonthsFromNow.toISOString())
|
||||||
.order('closing_date', { ascending: true })
|
.order('closed_date', { ascending: true })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -39,10 +38,10 @@ export function useHomepageClosingSoonRides(enabled = true) {
|
|||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`*, park:parks(*, location:locations(*))`)
|
.select(`*, park:parks(*), location:locations(*)`)
|
||||||
.gte('closing_date', toDateOnly(today))
|
.gte('closed_date', today.toISOString())
|
||||||
.lte('closing_date', toDateOnly(sixMonthsFromNow))
|
.lte('closed_date', sixMonthsFromNow.toISOString())
|
||||||
.order('closing_date', { ascending: true })
|
.order('closed_date', { ascending: true })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
import { toDateOnly } from '@/lib/dateUtils';
|
|
||||||
|
|
||||||
export function useHomepageRecentlyOpenedParks(enabled = true) {
|
export function useHomepageRecentlyOpenedParks(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -13,8 +12,8 @@ export function useHomepageRecentlyOpenedParks(enabled = true) {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('parks')
|
.from('parks')
|
||||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||||
.gte('opening_date', toDateOnly(oneYearAgo))
|
.gte('opened_date', oneYearAgo.toISOString())
|
||||||
.order('opening_date', { ascending: false })
|
.order('opened_date', { ascending: false })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -36,9 +35,9 @@ export function useHomepageRecentlyOpenedRides(enabled = true) {
|
|||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`*, park:parks(*, location:locations(*))`)
|
.select(`*, park:parks(*), location:locations(*)`)
|
||||||
.gte('opening_date', toDateOnly(oneYearAgo))
|
.gte('opened_date', oneYearAgo.toISOString())
|
||||||
.order('opening_date', { ascending: false })
|
.order('opened_date', { ascending: false })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
import { toDateOnly } from '@/lib/dateUtils';
|
|
||||||
|
|
||||||
export function useHomepageOpeningSoonParks(enabled = true) {
|
export function useHomepageOpeningSoonParks(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -14,9 +13,9 @@ export function useHomepageOpeningSoonParks(enabled = true) {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('parks')
|
.from('parks')
|
||||||
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
.select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`)
|
||||||
.gte('opening_date', toDateOnly(today))
|
.gte('opened_date', today.toISOString())
|
||||||
.lte('opening_date', toDateOnly(sixMonthsFromNow))
|
.lte('opened_date', sixMonthsFromNow.toISOString())
|
||||||
.order('opening_date', { ascending: true })
|
.order('opened_date', { ascending: true })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -39,10 +38,10 @@ export function useHomepageOpeningSoonRides(enabled = true) {
|
|||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`*, park:parks(*, location:locations(*))`)
|
.select(`*, park:parks(*), location:locations(*)`)
|
||||||
.gte('opening_date', toDateOnly(today))
|
.gte('opened_date', today.toISOString())
|
||||||
.lte('opening_date', toDateOnly(sixMonthsFromNow))
|
.lte('opened_date', sixMonthsFromNow.toISOString())
|
||||||
.order('opening_date', { ascending: true })
|
.order('opened_date', { ascending: true })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function useHomepageHighestRatedRides(enabled = true) {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`*, park:parks(*, location:locations(*))`)
|
.select(`*, park:parks(*), location:locations(*)`)
|
||||||
.not('average_rating', 'is', null)
|
.not('average_rating', 'is', null)
|
||||||
.order('average_rating', { ascending: false })
|
.order('average_rating', { ascending: false })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function useHomepageRecentRides(enabled = true) {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`*, park:parks(*, location:locations(*))`)
|
.select(`*, park:parks(*), location:locations(*)`)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,4 @@
|
|||||||
/**
|
import { useQuery } from '@tanstack/react-query';
|
||||||
* 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 { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
@@ -44,35 +18,17 @@ interface RecentChange {
|
|||||||
changeReason?: string;
|
changeReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHomepageRecentChanges(
|
export function useHomepageRecentChanges(enabled = true) {
|
||||||
enabled = true
|
|
||||||
): UseQueryResult<RecentChange[]> {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.homepage.recentChanges(),
|
queryKey: queryKeys.homepage.recentChanges(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
// Use the new database function to get all changes in a single query
|
// 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 });
|
const { data, error } = await supabase.rpc('get_recent_changes', { limit_count: 24 });
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
interface DatabaseRecentChange {
|
|
||||||
entity_id: string;
|
|
||||||
entity_name: string;
|
|
||||||
entity_type: string;
|
|
||||||
entity_slug: string;
|
|
||||||
park_slug?: string;
|
|
||||||
image_url?: string;
|
|
||||||
change_type: string;
|
|
||||||
changed_at: string;
|
|
||||||
changed_by_username?: string;
|
|
||||||
changed_by_avatar?: string;
|
|
||||||
change_reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform the database response to match our interface
|
// Transform the database response to match our interface
|
||||||
const result: RecentChange[] = (data as unknown as DatabaseRecentChange[] || []).map((item) => ({
|
return (data || []).map((item: any) => ({
|
||||||
id: item.entity_id,
|
id: item.entity_id,
|
||||||
name: item.entity_name,
|
name: item.entity_name,
|
||||||
type: item.entity_type as 'park' | 'ride' | 'company',
|
type: item.entity_type as 'park' | 'ride' | 'company',
|
||||||
@@ -86,17 +42,7 @@ export function useHomepageRecentChanges(
|
|||||||
avatarUrl: item.changed_by_avatar || undefined
|
avatarUrl: item.changed_by_avatar || undefined
|
||||||
} : undefined,
|
} : undefined,
|
||||||
changeReason: item.change_reason || undefined
|
changeReason: item.change_reason || undefined
|
||||||
}));
|
})) as RecentChange[];
|
||||||
|
|
||||||
// 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;
|
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function useHomepageTrendingRides(enabled = true) {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('rides')
|
.from('rides')
|
||||||
.select(`*, park:parks(*, location:locations(*))`)
|
.select(`*, park:parks(*), location:locations(*)`)
|
||||||
.order('view_count_30d', { ascending: false })
|
.order('view_count_30d', { ascending: false })
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,14 @@
|
|||||||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
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 {
|
export function useListItems(listId: string | undefined, enabled = true) {
|
||||||
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[]> {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.lists.items(listId || ''),
|
queryKey: queryKeys.lists.items(listId || ''),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
if (!listId) return [];
|
if (!listId) return [];
|
||||||
|
|
||||||
// Get items
|
// Get items
|
||||||
@@ -78,47 +26,30 @@ export function useListItems(
|
|||||||
const rideIds = items.filter(i => i.entity_type === 'ride').map(i => i.entity_id);
|
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);
|
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([
|
const [parksResult, ridesResult, companiesResult] = await Promise.all([
|
||||||
parkIds.length > 0
|
parkIds.length > 0
|
||||||
? supabase.from('parks').select('id, name, slug, park_type, location_id').in('id', parkIds)
|
? 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
|
rideIds.length > 0
|
||||||
? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds)
|
? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds)
|
||||||
: Promise.resolve({ data: [], error: null }),
|
: Promise.resolve({ data: [] }),
|
||||||
companyIds.length > 0
|
companyIds.length > 0
|
||||||
? supabase.from('companies').select('id, name, slug, company_type').in('id', companyIds)
|
? 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
|
// Create entities map for quick lookup
|
||||||
if (parksResult.error) throw parksResult.error;
|
const entitiesMap = new Map<string, any>();
|
||||||
if (ridesResult.error) throw ridesResult.error;
|
(parksResult.data || []).forEach(p => entitiesMap.set(p.id, p));
|
||||||
if (companiesResult.error) throw companiesResult.error;
|
(ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r));
|
||||||
|
(companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c));
|
||||||
// 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));
|
|
||||||
|
|
||||||
// Map entities to items
|
// Map entities to items
|
||||||
const result = items.map(item => ({
|
return items.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
entity: entitiesMap.get(item.entity_id),
|
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,
|
enabled: enabled && !!listId,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { validateMultipleItems } from '@/lib/entityValidationSchemas';
|
|||||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||||
import type { User } from '@supabase/supabase-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
import type { ModerationItem } from '@/types/moderation';
|
import type { ModerationItem } from '@/types/moderation';
|
||||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for moderation actions
|
* 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.
|
* @param config - Configuration object with user, callbacks, and dependencies
|
||||||
* 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
|
|
||||||
* @returns Object with action handler functions
|
* @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 {
|
export function useModerationActions(config: ModerationActionsConfig): ModerationActions {
|
||||||
const { user, onActionStart, onActionComplete } = config;
|
const { user, onActionStart, onActionComplete } = config;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Cache invalidation for moderation and affected entities
|
|
||||||
const invalidation = useQueryInvalidation();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform moderation action (approve/reject)
|
* Perform moderation action (approve/reject)
|
||||||
@@ -291,30 +263,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
description: `The ${item.type} has been ${action}`,
|
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}`);
|
logger.log(`✅ Action ${action} completed for ${item.id}`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error('❌ Error performing action:', { error: getErrorMessage(error) });
|
logger.error('❌ Error performing action:', { error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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.",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch reviews for a specific entity (park or ride)
|
* Hook to fetch reviews for a specific entity (park or ride)
|
||||||
*/
|
*/
|
||||||
export function useEntityReviews(
|
export function useEntityReviews(entityType: 'park' | 'ride', entityId: string | undefined, enabled = true) {
|
||||||
entityType: 'park' | 'ride',
|
return useQuery({
|
||||||
entityId: string | undefined,
|
|
||||||
enabled = true,
|
|
||||||
enableRealtime = false // New parameter for opt-in real-time updates
|
|
||||||
) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: queryKeys.reviews.entity(entityType, entityId || ''),
|
queryKey: queryKeys.reviews.entity(entityType, entityId || ''),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!entityId) return [];
|
if (!entityId) return [];
|
||||||
@@ -43,34 +35,4 @@ export function useEntityReviews(
|
|||||||
gcTime: 10 * 60 * 1000,
|
gcTime: 10 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { useAuth } from './useAuth';
|
|||||||
import { useUserRole } from './useUserRole';
|
import { useUserRole } from './useUserRole';
|
||||||
import { useToast } from './use-toast';
|
import { useToast } from './use-toast';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
|
||||||
|
|
||||||
interface AdminSetting {
|
interface AdminSetting {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,7 +24,7 @@ export function useAdminSettings() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error
|
error
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: queryKeys.admin.settings(),
|
queryKey: ['admin-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('admin_settings')
|
.from('admin_settings')
|
||||||
@@ -60,7 +59,7 @@ export function useAdminSettings() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.admin.settings() });
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
toast({
|
toast({
|
||||||
title: "Setting Updated",
|
title: "Setting Updated",
|
||||||
description: "The setting has been saved successfully.",
|
description: "The setting has been saved successfully.",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface AuthContextType {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
pendingEmail: string | null;
|
pendingEmail: string | null;
|
||||||
sessionError: string | null;
|
sessionError: string | null;
|
||||||
signOut: (scope?: 'global' | 'local' | 'others') => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
verifySession: () => Promise<boolean>;
|
verifySession: () => Promise<boolean>;
|
||||||
clearPendingEmail: () => void;
|
clearPendingEmail: () => void;
|
||||||
checkAalStepUp: () => Promise<CheckAalResult>;
|
checkAalStepUp: () => Promise<CheckAalResult>;
|
||||||
@@ -123,24 +123,6 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
return;
|
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 {
|
} else {
|
||||||
setAal(null);
|
setAal(null);
|
||||||
}
|
}
|
||||||
@@ -236,23 +218,12 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = async (scope: 'global' | 'local' | 'others' = 'global') => {
|
const signOut = async () => {
|
||||||
authLog('[Auth] Signing out with scope:', scope);
|
authLog('[Auth] Signing out...');
|
||||||
|
const result = await signOutUser();
|
||||||
try {
|
if (!result.success) {
|
||||||
const { error } = await supabase.auth.signOut({ scope });
|
authError('Error signing out:', result.error);
|
||||||
|
throw new Error(result.error);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
|
||||||
|
|
||||||
export type AvatarUploadState = {
|
export type AvatarUploadState = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -15,8 +13,6 @@ export const useAvatarUpload = (
|
|||||||
initialImageId: string = '',
|
initialImageId: string = '',
|
||||||
username: string
|
username: string
|
||||||
) => {
|
) => {
|
||||||
const { user } = useAuth();
|
|
||||||
const { invalidateUserProfile } = useQueryInvalidation();
|
|
||||||
const [state, setState] = useState<AvatarUploadState>({
|
const [state, setState] = useState<AvatarUploadState>({
|
||||||
url: initialUrl,
|
url: initialUrl,
|
||||||
imageId: initialImageId,
|
imageId: initialImageId,
|
||||||
@@ -52,11 +48,6 @@ export const useAvatarUpload = (
|
|||||||
setState(prev => ({ ...prev, isUploading: false }));
|
setState(prev => ({ ...prev, isUploading: false }));
|
||||||
handleSuccess('Avatar updated', 'Your avatar has been successfully updated.');
|
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 };
|
return { success: true };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Rollback on error
|
// Rollback on error
|
||||||
@@ -73,7 +64,7 @@ export const useAvatarUpload = (
|
|||||||
|
|
||||||
return { success: false, error };
|
return { success: false, error };
|
||||||
}
|
}
|
||||||
}, [username, initialUrl, initialImageId, user?.id, invalidateUserProfile]);
|
}, [username, initialUrl, initialImageId]);
|
||||||
|
|
||||||
const resetAvatar = useCallback(() => {
|
const resetAvatar = useCallback(() => {
|
||||||
setState({
|
setState({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
|
||||||
|
|
||||||
export interface CoasterStat {
|
export interface CoasterStat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,7 +15,7 @@ export interface CoasterStat {
|
|||||||
|
|
||||||
export function useCoasterStats(rideId: string | undefined) {
|
export function useCoasterStats(rideId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.stats.coaster(rideId || ''),
|
queryKey: ['coaster-stats', rideId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!rideId) return [];
|
if (!rideId) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -93,14 +93,7 @@ export function useEntityVersions(entityType: EntityType, entityId: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DatabaseVersion {
|
const versionsWithProfiles = (data || []).map((v: any) => ({
|
||||||
profiles?: {
|
|
||||||
username?: string;
|
|
||||||
display_name?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionsWithProfiles = (data as DatabaseVersion[] || []).map((v) => ({
|
|
||||||
...v,
|
...v,
|
||||||
profiles: v.profiles || {
|
profiles: v.profiles || {
|
||||||
username: 'Unknown',
|
username: 'Unknown',
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch public Novu settings accessible to all authenticated users
|
* Hook to fetch public Novu settings accessible to all authenticated users
|
||||||
*/
|
*/
|
||||||
export function usePublicNovuSettings() {
|
export function usePublicNovuSettings() {
|
||||||
const { data: settings, isLoading, error } = useQuery({
|
const { data: settings, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.settings.publicNovu(),
|
queryKey: ['public-novu-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('admin_settings')
|
.from('admin_settings')
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export function useRequireMFA() {
|
|||||||
isEnrolled,
|
isEnrolled,
|
||||||
needsEnrollment: requiresMFA && !isEnrolled,
|
needsEnrollment: requiresMFA && !isEnrolled,
|
||||||
needsVerification,
|
needsVerification,
|
||||||
isBlocked: requiresMFA && (!isEnrolled || (isEnrolled && aal === 'aal1')), // Convenience flag
|
|
||||||
aal,
|
aal,
|
||||||
loading: loading || roleLoading,
|
loading: loading || roleLoading,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
|
|||||||
const [filters, setFilters] = useState<RideCreditFilters>({});
|
const [filters, setFilters] = useState<RideCreditFilters>({});
|
||||||
const debouncedSearchQuery = useDebounce(filters.searchQuery || '', 300);
|
const debouncedSearchQuery = useDebounce(filters.searchQuery || '', 300);
|
||||||
|
|
||||||
const updateFilter = useCallback((key: keyof RideCreditFilters, value: RideCreditFilters[typeof key]) => {
|
const updateFilter = useCallback((key: keyof RideCreditFilters, value: any) => {
|
||||||
setFilters(prev => ({ ...prev, [key]: value }));
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -63,7 +63,6 @@ export type Database = {
|
|||||||
Row: {
|
Row: {
|
||||||
action: string
|
action: string
|
||||||
admin_user_id: string
|
admin_user_id: string
|
||||||
auth0_event_type: string | null
|
|
||||||
created_at: string
|
created_at: string
|
||||||
details: Json | null
|
details: Json | null
|
||||||
id: string
|
id: string
|
||||||
@@ -72,7 +71,6 @@ export type Database = {
|
|||||||
Insert: {
|
Insert: {
|
||||||
action: string
|
action: string
|
||||||
admin_user_id: string
|
admin_user_id: string
|
||||||
auth0_event_type?: string | null
|
|
||||||
created_at?: string
|
created_at?: string
|
||||||
details?: Json | null
|
details?: Json | null
|
||||||
id?: string
|
id?: string
|
||||||
@@ -81,7 +79,6 @@ export type Database = {
|
|||||||
Update: {
|
Update: {
|
||||||
action?: string
|
action?: string
|
||||||
admin_user_id?: string
|
admin_user_id?: string
|
||||||
auth0_event_type?: string | null
|
|
||||||
created_at?: string
|
created_at?: string
|
||||||
details?: Json | null
|
details?: Json | null
|
||||||
id?: string
|
id?: string
|
||||||
@@ -122,57 +119,6 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
blog_posts: {
|
||||||
Row: {
|
Row: {
|
||||||
author_id: string
|
author_id: string
|
||||||
@@ -2022,7 +1968,6 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
profiles: {
|
profiles: {
|
||||||
Row: {
|
Row: {
|
||||||
auth0_sub: string | null
|
|
||||||
avatar_image_id: string | null
|
avatar_image_id: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
ban_expires_at: string | null
|
ban_expires_at: string | null
|
||||||
@@ -2056,7 +2001,6 @@ export type Database = {
|
|||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
auth0_sub?: string | null
|
|
||||||
avatar_image_id?: string | null
|
avatar_image_id?: string | null
|
||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
ban_expires_at?: string | null
|
ban_expires_at?: string | null
|
||||||
@@ -2090,7 +2034,6 @@ export type Database = {
|
|||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
auth0_sub?: string | null
|
|
||||||
avatar_image_id?: string | null
|
avatar_image_id?: string | null
|
||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
ban_expires_at?: string | null
|
ban_expires_at?: string | null
|
||||||
@@ -4587,7 +4530,6 @@ export type Database = {
|
|||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
backfill_sort_orders: { Args: never; Returns: undefined }
|
backfill_sort_orders: { Args: never; Returns: undefined }
|
||||||
block_aal1_with_mfa: { Args: never; Returns: boolean }
|
|
||||||
can_approve_submission_item: {
|
can_approve_submission_item: {
|
||||||
Args: { item_id: string }
|
Args: { item_id: string }
|
||||||
Returns: boolean
|
Returns: boolean
|
||||||
@@ -4659,8 +4601,6 @@ export type Database = {
|
|||||||
extract_cf_image_id: { Args: { url: string }; Returns: string }
|
extract_cf_image_id: { Args: { url: string }; Returns: string }
|
||||||
generate_deletion_confirmation_code: { Args: never; Returns: string }
|
generate_deletion_confirmation_code: { Args: never; Returns: string }
|
||||||
generate_ticket_number: { 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_email_change_status: { Args: never; Returns: Json }
|
||||||
get_filtered_profile: {
|
get_filtered_profile: {
|
||||||
Args: { _profile_user_id: string; _viewer_id?: string }
|
Args: { _profile_user_id: string; _viewer_id?: string }
|
||||||
@@ -4729,7 +4669,6 @@ export type Database = {
|
|||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
has_aal2: { Args: never; Returns: boolean }
|
has_aal2: { Args: never; Returns: boolean }
|
||||||
has_auth0_mfa: { Args: never; Returns: boolean }
|
|
||||||
has_mfa_enabled: { Args: { _user_id: string }; Returns: boolean }
|
has_mfa_enabled: { Args: { _user_id: string }; Returns: boolean }
|
||||||
has_pending_dependents: { Args: { item_id: string }; Returns: boolean }
|
has_pending_dependents: { Args: { item_id: string }; Returns: boolean }
|
||||||
has_role: {
|
has_role: {
|
||||||
@@ -4745,7 +4684,6 @@ export type Database = {
|
|||||||
Args: { post_slug: string }
|
Args: { post_slug: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
is_auth0_user: { Args: never; Returns: boolean }
|
|
||||||
is_moderator: { Args: { _user_id: string }; Returns: boolean }
|
is_moderator: { Args: { _user_id: string }; Returns: boolean }
|
||||||
is_superuser: { Args: { _user_id: string }; Returns: boolean }
|
is_superuser: { Args: { _user_id: string }; Returns: boolean }
|
||||||
is_user_banned: { Args: { _user_id: string }; Returns: boolean }
|
is_user_banned: { Args: { _user_id: string }; Returns: boolean }
|
||||||
|
|||||||
@@ -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/`;
|
|
||||||
}
|
|
||||||
@@ -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`;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
* Add password authentication to an OAuth-only account
|
||||||
|
|||||||
@@ -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
|
* Invalidate parks listing cache
|
||||||
* Call this after creating/updating/deleting parks
|
* Call this after creating/updating/deleting parks
|
||||||
@@ -250,147 +186,5 @@ export function useQueryInvalidation() {
|
|||||||
queryKey: ['homepage', 'featured-parks']
|
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)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export const queryKeys = {
|
|||||||
|
|
||||||
// Photos queries
|
// Photos queries
|
||||||
photos: {
|
photos: {
|
||||||
entity: (entityType: string, entityId: string, sortBy?: string) =>
|
entity: (entityType: string, entityId: string) =>
|
||||||
['photos', entityType, entityId, sortBy] as const,
|
['photos', entityType, entityId] as const,
|
||||||
count: (entityType: string, entityId: string) =>
|
count: (entityType: string, entityId: string) =>
|
||||||
['photos', 'count', entityType, entityId] as const,
|
['photos', 'count', entityType, entityId] as const,
|
||||||
},
|
},
|
||||||
@@ -76,81 +76,5 @@ export const queryKeys = {
|
|||||||
// Lists queries
|
// Lists queries
|
||||||
lists: {
|
lists: {
|
||||||
items: (listId: string) => ['list-items', listId] as const,
|
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;
|
} as const;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user