From 133141d474532f00dd16b4cc4779c02066f85a4f Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:22:30 +0000 Subject: [PATCH] Reverted to commit 0091584677370a821796a8bda80be3aff95cfcf1 --- .env.example | 7 +- docs/AUTHENTICATION.md | 257 --------- package-lock.json | 61 -- package.json | 3 - src/App.tsx | 58 +- src/components/auth/AuthModal.tsx | 133 ++--- src/components/auth/EmailOTPInput.tsx | 101 ---- src/components/auth/MFAChallenge.tsx | 49 +- src/components/auth/MFAStepUpModal.tsx | 6 +- src/components/auth/MigrationBanner.tsx | 105 ---- src/components/dev/CacheMonitor.tsx | 121 ---- src/components/homepage/FeaturedParks.tsx | 48 +- src/components/lists/ListDisplay.tsx | 76 ++- .../moderation/PhotoSubmissionDisplay.tsx | 72 ++- src/components/moderation/RecentActivity.tsx | 159 +++++- src/components/moderation/ReportButton.tsx | 53 +- src/components/moderation/ReportsQueue.tsx | 76 ++- src/components/moderation/UserRoleManager.tsx | 254 ++++++--- src/components/parks/ParkCard.tsx | 44 +- src/components/privacy/BlockedUsers.tsx | 149 ++++- src/components/profile/RideCreditsManager.tsx | 30 +- src/components/reviews/ReviewForm.tsx | 7 - src/components/rides/RideCard.tsx | 40 -- src/components/rides/RideModelCard.tsx | 21 - src/components/rides/SimilarRides.tsx | 42 +- src/components/settings/AccountProfileTab.tsx | 68 ++- src/components/settings/Auth0MFASettings.tsx | 176 ------ src/components/settings/EmailChangeDialog.tsx | 67 ++- src/components/settings/EmailChangeStatus.tsx | 49 +- .../settings/IdentityManagement.tsx | 225 -------- src/components/settings/LocationTab.tsx | 80 ++- .../settings/PasswordUpdateDialog.tsx | 75 ++- src/components/settings/PrivacyTab.tsx | 112 +++- src/components/settings/SecurityTab.tsx | 202 ++++--- src/components/upload/EntityPhotoGallery.tsx | 64 ++- .../upload/PhotoManagementDialog.tsx | 25 +- src/contexts/Auth0Provider.tsx | 35 -- src/docs/API_PATTERNS.md | 371 ------------- src/docs/CACHE_DEBUGGING.md | 506 ----------------- src/docs/CACHE_INVALIDATION_GUIDE.md | 519 ------------------ src/docs/PRODUCTION_READY.md | 365 ------------ src/hooks/admin/useAuditLogs.ts | 55 -- src/hooks/admin/useVersionAudit.ts | 98 ---- src/hooks/blog/useBlogPost.ts | 45 -- src/hooks/companies/useCompanyDetail.ts | 32 -- src/hooks/companies/useCompanyParks.ts | 38 -- src/hooks/companies/useCompanyStatistics.ts | 124 ----- src/hooks/entities/useEntityName.ts | 82 --- src/hooks/homepage/useHomepageClosed.ts | 15 +- src/hooks/homepage/useHomepageClosing.ts | 15 +- src/hooks/homepage/useHomepageOpened.ts | 11 +- src/hooks/homepage/useHomepageOpeningSoon.ts | 15 +- src/hooks/homepage/useHomepageRated.ts | 2 +- src/hooks/homepage/useHomepageRecent.ts | 2 +- .../homepage/useHomepageRecentChanges.ts | 62 +-- src/hooks/homepage/useHomepageTrending.ts | 2 +- src/hooks/lists/useListItems.ts | 95 +--- src/hooks/lists/useUserLists.ts | 84 --- src/hooks/moderation/useModerationActions.ts | 58 +- src/hooks/moderation/usePhotoSubmission.ts | 80 --- src/hooks/moderation/useRecentActivity.ts | 154 ------ src/hooks/pagination/usePrefetchNextPage.ts | 47 -- src/hooks/photos/useEntityPhotos.ts | 119 ---- src/hooks/privacy/useBlockUserMutation.ts | 68 --- src/hooks/privacy/useBlockedUsers.ts | 72 --- src/hooks/privacy/usePrivacyMutations.ts | 146 ----- src/hooks/profile/useProfileActivity.ts | 242 -------- .../profile/useProfileLocationMutation.ts | 118 ---- src/hooks/profile/useProfileStats.ts | 37 -- src/hooks/profile/useProfileUpdateMutation.ts | 116 ---- src/hooks/reports/useReportActionMutation.ts | 86 --- src/hooks/reports/useReportMutation.ts | 76 --- src/hooks/reviews/useEntityReviews.ts | 44 +- src/hooks/rideModels/useModelRides.ts | 39 -- src/hooks/rideModels/useModelStatistics.ts | 32 -- src/hooks/rideModels/useRideModelDetail.ts | 48 -- src/hooks/rides/useRideCreditsMutation.ts | 50 -- src/hooks/security/useEmailChangeMutation.ts | 83 --- src/hooks/security/useEmailChangeStatus.ts | 38 -- .../security/usePasswordUpdateMutation.ts | 87 --- src/hooks/security/useSecurityMutations.ts | 54 -- src/hooks/security/useSessions.ts | 34 -- src/hooks/useAdminSettings.ts | 5 +- src/hooks/useAuth.tsx | 43 +- src/hooks/useAvatarUpload.ts | 11 +- src/hooks/useCoasterStats.ts | 3 +- src/hooks/useEntityVersions.ts | 9 +- src/hooks/usePublicNovuSettings.ts | 3 +- src/hooks/useRequireMFA.ts | 1 - src/hooks/useRideCreditFilters.ts | 2 +- src/hooks/users/useRoleMutations.ts | 144 ----- src/hooks/users/useUserRoles.ts | 66 --- src/hooks/users/useUserSearch.ts | 80 --- src/integrations/supabase/types.ts | 62 --- src/lib/auth0Config.ts | 32 -- src/lib/auth0Management.ts | 138 ----- src/lib/cacheMonitoring.ts | 387 ------------- src/lib/identityService.ts | 70 --- src/lib/queryInvalidation.ts | 206 ------- src/lib/queryKeys.ts | 80 +-- src/pages/AdminDashboard.tsx | 37 +- src/pages/Auth.tsx | 202 ++++--- src/pages/Auth0Callback.tsx | 148 ----- src/pages/AuthCallback.tsx | 6 +- src/pages/BlogPost.tsx | 28 +- src/pages/DesignerDetail.tsx | 69 ++- src/pages/ManufacturerDetail.tsx | 81 ++- src/pages/OperatorDetail.tsx | 131 ++++- src/pages/Profile.tsx | 259 +++++++-- src/pages/PropertyOwnerDetail.tsx | 131 ++++- src/pages/RideModelDetail.tsx | 83 ++- src/pages/admin/AdminContact.tsx | 33 +- src/types/auth0.ts | 101 ---- supabase/config.toml | 23 +- supabase/functions/_shared/auth0Jwt.ts | 71 --- supabase/functions/_shared/cors.ts | 4 - .../functions/auth-with-mfa-check/index.ts | 128 ----- .../auth0-get-management-token/index.ts | 114 ---- supabase/functions/auth0-get-roles/index.ts | 105 ---- supabase/functions/auth0-sync-user/index.ts | 158 ------ supabase/functions/auth0-webhook/index.ts | 154 ------ .../functions/check-mfa-enrollment/index.ts | 84 --- .../functions/verify-mfa-and-login/index.ts | 91 --- ...9_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql | 65 --- ...6_13451034-559b-4b45-956c-b2cbeb9dda7f.sql | 154 ------ 125 files changed, 2316 insertions(+), 9102 deletions(-) delete mode 100644 docs/AUTHENTICATION.md delete mode 100644 src/components/auth/EmailOTPInput.tsx delete mode 100644 src/components/auth/MigrationBanner.tsx delete mode 100644 src/components/dev/CacheMonitor.tsx delete mode 100644 src/components/settings/Auth0MFASettings.tsx delete mode 100644 src/components/settings/IdentityManagement.tsx delete mode 100644 src/contexts/Auth0Provider.tsx delete mode 100644 src/docs/API_PATTERNS.md delete mode 100644 src/docs/CACHE_DEBUGGING.md delete mode 100644 src/docs/CACHE_INVALIDATION_GUIDE.md delete mode 100644 src/docs/PRODUCTION_READY.md delete mode 100644 src/hooks/admin/useAuditLogs.ts delete mode 100644 src/hooks/admin/useVersionAudit.ts delete mode 100644 src/hooks/blog/useBlogPost.ts delete mode 100644 src/hooks/companies/useCompanyDetail.ts delete mode 100644 src/hooks/companies/useCompanyParks.ts delete mode 100644 src/hooks/companies/useCompanyStatistics.ts delete mode 100644 src/hooks/entities/useEntityName.ts delete mode 100644 src/hooks/lists/useUserLists.ts delete mode 100644 src/hooks/moderation/usePhotoSubmission.ts delete mode 100644 src/hooks/moderation/useRecentActivity.ts delete mode 100644 src/hooks/pagination/usePrefetchNextPage.ts delete mode 100644 src/hooks/photos/useEntityPhotos.ts delete mode 100644 src/hooks/privacy/useBlockUserMutation.ts delete mode 100644 src/hooks/privacy/useBlockedUsers.ts delete mode 100644 src/hooks/privacy/usePrivacyMutations.ts delete mode 100644 src/hooks/profile/useProfileActivity.ts delete mode 100644 src/hooks/profile/useProfileLocationMutation.ts delete mode 100644 src/hooks/profile/useProfileStats.ts delete mode 100644 src/hooks/profile/useProfileUpdateMutation.ts delete mode 100644 src/hooks/reports/useReportActionMutation.ts delete mode 100644 src/hooks/reports/useReportMutation.ts delete mode 100644 src/hooks/rideModels/useModelRides.ts delete mode 100644 src/hooks/rideModels/useModelStatistics.ts delete mode 100644 src/hooks/rideModels/useRideModelDetail.ts delete mode 100644 src/hooks/rides/useRideCreditsMutation.ts delete mode 100644 src/hooks/security/useEmailChangeMutation.ts delete mode 100644 src/hooks/security/useEmailChangeStatus.ts delete mode 100644 src/hooks/security/usePasswordUpdateMutation.ts delete mode 100644 src/hooks/security/useSecurityMutations.ts delete mode 100644 src/hooks/security/useSessions.ts delete mode 100644 src/hooks/users/useRoleMutations.ts delete mode 100644 src/hooks/users/useUserRoles.ts delete mode 100644 src/hooks/users/useUserSearch.ts delete mode 100644 src/lib/auth0Config.ts delete mode 100644 src/lib/auth0Management.ts delete mode 100644 src/lib/cacheMonitoring.ts delete mode 100644 src/pages/Auth0Callback.tsx delete mode 100644 src/types/auth0.ts delete mode 100644 supabase/functions/_shared/auth0Jwt.ts delete mode 100644 supabase/functions/_shared/cors.ts delete mode 100644 supabase/functions/auth-with-mfa-check/index.ts delete mode 100644 supabase/functions/auth0-get-management-token/index.ts delete mode 100644 supabase/functions/auth0-get-roles/index.ts delete mode 100644 supabase/functions/auth0-sync-user/index.ts delete mode 100644 supabase/functions/auth0-webhook/index.ts delete mode 100644 supabase/functions/check-mfa-enrollment/index.ts delete mode 100644 supabase/functions/verify-mfa-and-login/index.ts delete mode 100644 supabase/migrations/20251031170759_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql delete mode 100644 supabase/migrations/20251101010106_13451034-559b-4b45-956c-b2cbeb9dda7f.sql diff --git a/.env.example b/.env.example index 21f48af6..2630d70a 100644 --- a/.env.example +++ b/.env.example @@ -30,9 +30,4 @@ VITE_ALLOW_CAPTCHA_BYPASS=false # For self-hosted Novu, replace with your instance URLs VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier VITE_NOVU_SOCKET_URL=wss://ws.novu.co -VITE_NOVU_API_URL=https://api.novu.co - -# Auth0 Configuration -# Get these from your Auth0 dashboard: https://manage.auth0.com -VITE_AUTH0_DOMAIN=your-tenant.auth0.com -VITE_AUTH0_CLIENT_ID=your-spa-client-id \ No newline at end of file +VITE_NOVU_API_URL=https://api.novu.co \ No newline at end of file diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md deleted file mode 100644 index d0031ed2..00000000 --- a/docs/AUTHENTICATION.md +++ /dev/null @@ -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'; - - { - 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 -

Magic Link

-

Follow this link to login:

-

Log In

-``` - -**OTP Template:** -```html -

Verification Code

-

Your verification code is:

-

{{ .Token }}

-

This code expires in 1 hour.

-``` - -### 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 diff --git a/package-lock.json b/package-lock.json index ec1c325a..b367cee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,6 @@ "name": "vite_react_shadcn_ts", "version": "0.0.0", "dependencies": { - "@auth0/auth0-react": "^2.8.0", - "@auth0/auth0-spa-js": "^2.8.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -65,7 +63,6 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "jose": "^6.1.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", "react": "^18.3.1", @@ -116,30 +113,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@auth0/auth0-react": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.8.0.tgz", - "integrity": "sha512-f3KOkq+TW7AC3T+ZAo9G0hNL339z15C9q00QDVrMGCzZAPyp8lvDHKcAs21d/u+GzhU5zmssvJTQggDR7JqxSA==", - "license": "MIT", - "dependencies": { - "@auth0/auth0-spa-js": "^2.7.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17 || ^18 || ^19", - "react-dom": "^16.11.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/@auth0/auth0-spa-js": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz", - "integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==", - "license": "MIT", - "dependencies": { - "browser-tabs-lock": "^1.2.15", - "dpop": "^2.1.1", - "es-cookie": "~1.3.2" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -6034,16 +6007,6 @@ "node": ">=8" } }, - "node_modules/browser-tabs-lock": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", - "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "lodash": ">=4.17.21" - } - }, "node_modules/browserslist": { "version": "4.27.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", @@ -6783,15 +6746,6 @@ "react": ">=16.12.0" } }, - "node_modules/dpop": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", - "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6963,12 +6917,6 @@ "node": ">=10.0.0" } }, - "node_modules/es-cookie": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", - "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -8538,15 +8486,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", - "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 18e7336f..59f924e8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,6 @@ "preview": "vite preview" }, "dependencies": { - "@auth0/auth0-react": "^2.8.0", - "@auth0/auth0-spa-js": "^2.8.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -68,7 +66,6 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "jose": "^6.1.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index e2060310..8f1751fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,12 +3,10 @@ import { lazy, Suspense } from "react"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { QueryClient, QueryClientProvider, QueryCache } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { CacheMonitor } from "@/components/dev/CacheMonitor"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { AuthProvider } from "@/hooks/useAuth"; -import { Auth0Provider } from "@/contexts/Auth0Provider"; import { AuthModalProvider } from "@/contexts/AuthModalContext"; import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider"; import { Analytics } from "@vercel/analytics/react"; @@ -63,7 +61,6 @@ const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings") const Profile = lazy(() => import("./pages/Profile")); const UserSettings = lazy(() => import("./pages/UserSettings")); const AuthCallback = lazy(() => import("./pages/AuthCallback")); -const Auth0Callback = lazy(() => import("./pages/Auth0Callback")); // Utility routes (lazy-loaded) const NotFound = lazy(() => import("./pages/NotFound")); @@ -80,41 +77,8 @@ const queryClient = new QueryClient({ gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache for 5 mins }, }, - // Add cache size management - queryCache: new QueryCache({ - onSuccess: () => { - // Monitor cache size in development - if (import.meta.env.DEV) { - const cacheSize = queryClient.getQueryCache().getAll().length; - if (cacheSize > 100) { - console.warn(`⚠️ Query cache size: ${cacheSize} queries`); - } - } - }, - }), }); -// Add cache size monitoring and automatic cleanup (dev mode) -if (import.meta.env.DEV) { - setInterval(() => { - const cache = queryClient.getQueryCache(); - const queries = cache.getAll(); - - // Remove oldest queries if cache exceeds 250 items (increased limit) - if (queries.length > 250) { - const sortedByLastUpdated = queries - .sort((a, b) => (a.state.dataUpdatedAt || 0) - (b.state.dataUpdatedAt || 0)); - - const toRemove = sortedByLastUpdated.slice(0, queries.length - 200); - toRemove.forEach(query => { - queryClient.removeQueries({ queryKey: query.queryKey }); - }); - - console.log(`🧹 Removed ${toRemove.length} stale queries from cache`); - } - }, 60000); // Check every minute -} - function AppContent(): React.JSX.Element { return ( @@ -161,7 +125,6 @@ function AppContent(): React.JSX.Element { {/* User routes - lazy loaded */} } /> - } /> } /> } /> } /> @@ -193,19 +156,12 @@ function AppContent(): React.JSX.Element { const App = (): React.JSX.Element => ( - - - - - - - - {import.meta.env.DEV && ( - <> - - - - )} + + + + + + {import.meta.env.DEV && } ); diff --git a/src/components/auth/AuthModal.tsx b/src/components/auth/AuthModal.tsx index 54915369..f47af9f1 100644 --- a/src/components/auth/AuthModal.tsx +++ b/src/components/auth/AuthModal.tsx @@ -35,8 +35,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod const [signInCaptchaToken, setSignInCaptchaToken] = useState(null); const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); const [mfaFactorId, setMfaFactorId] = useState(null); - const [mfaChallengeId, setMfaChallengeId] = useState(null); - const [mfaPendingUserId, setMfaPendingUserId] = useState(null); const [formData, setFormData] = useState({ email: '', password: '', @@ -72,57 +70,73 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod setSignInCaptchaToken(null); try { - // Call server-side auth check with MFA detection - const { data: authResult, error: authError } = await supabase.functions.invoke( - 'auth-with-mfa-check', - { - body: { - email: formData.email, - password: formData.password, - captchaToken: tokenToUse, - }, - } - ); - - if (authError || authResult.error) { - throw new Error(authResult?.error || authError?.message || 'Authentication failed'); + const signInOptions: any = { + email: formData.email, + password: formData.password, + }; + + if (tokenToUse) { + signInOptions.options = { captchaToken: tokenToUse }; } - // Check if user is banned - if (authResult.banned) { - const reason = authResult.banReason - ? `Reason: ${authResult.banReason}` - : 'Contact support for assistance.'; + const { data, error } = await supabase.auth.signInWithPassword(signInOptions); + if (error) throw error; + // CRITICAL: Check ban status immediately after successful authentication + const { data: profile } = await supabase + .from('profiles') + .select('banned, ban_reason') + .eq('user_id', data.user.id) + .single(); + + if (profile?.banned) { + // Sign out immediately + await supabase.auth.signOut(); + + const reason = profile.ban_reason + ? `Reason: ${profile.ban_reason}` + : 'Contact support for assistance.'; + toast({ variant: "destructive", title: "Account Suspended", description: `Your account has been suspended. ${reason}`, - duration: 10000, + duration: 10000 }); setLoading(false); - return; + return; // Stop authentication flow } - // Check if MFA is required - if (authResult.mfaRequired) { - // NO SESSION EXISTS YET - show MFA challenge - console.log('[AuthModal] MFA required - no session created yet'); - setMfaFactorId(authResult.factorId); - setMfaChallengeId(authResult.challengeId); - setMfaPendingUserId(authResult.userId); - setLoading(false); - return; // User has NO session - MFA modal will show + // Check if MFA is required (user exists but no session) + if (data.user && !data.session) { + const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified'); + + if (totpFactor) { + setMfaFactorId(totpFactor.id); + setLoading(false); + return; + } } - - // No MFA required - user has session - console.log('[AuthModal] No MFA required - user authenticated'); - // Set the session in Supabase client - if (authResult.session) { - await supabase.auth.setSession(authResult.session); - } + // Track auth method for audit logging + setAuthMethod('password'); + // Check if MFA step-up is required + const { handlePostAuthFlow } = await import('@/lib/authService'); + const postAuthResult = await handlePostAuthFlow(data.session, 'password'); + + if (postAuthResult.success && postAuthResult.data.shouldRedirect) { + // Get the TOTP factor ID + const { data: factors } = await supabase.auth.mfa.listFactors(); + const totpFactor = factors?.totp?.find(f => f.status === 'verified'); + + if (totpFactor) { + setMfaFactorId(totpFactor.id); + setLoading(false); + return; // Stay in modal, show MFA challenge + } + } + toast({ title: "Welcome back!", description: "You've been signed in successfully." @@ -148,34 +162,30 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod }; const handleMfaSuccess = async () => { - console.log('[AuthModal] MFA verification succeeded - no further action needed'); + // Verify AAL upgrade was successful + const { data: { session } } = await supabase.auth.getSession(); + const verification = await verifyMfaUpgrade(session); + + if (!verification.success) { + toast({ + variant: "destructive", + title: "MFA Verification Failed", + description: verification.error || "Failed to upgrade session. Please try again." + }); + + // Force sign out on verification failure + await supabase.auth.signOut(); + setMfaFactorId(null); + return; + } - // Clear state setMfaFactorId(null); - setMfaChallengeId(null); - setMfaPendingUserId(null); - - toast({ - title: "Authentication complete", - description: "You've been signed in successfully.", - }); - onOpenChange(false); }; - const handleMfaCancel = async () => { - console.log('[AuthModal] User cancelled MFA verification'); - - // Clear state + const handleMfaCancel = () => { setMfaFactorId(null); - setMfaChallengeId(null); - setMfaPendingUserId(null); setSignInCaptchaKey(prev => prev + 1); - - toast({ - title: "Authentication cancelled", - description: "Please sign in again when you're ready to complete two-factor authentication.", - }); }; const handleSignUp = async (e: React.FormEvent) => { @@ -234,7 +244,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod email: formData.email, password: formData.password, options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, data: { username: formData.username, display_name: formData.displayName @@ -369,8 +378,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod {mfaFactorId ? ( diff --git a/src/components/auth/EmailOTPInput.tsx b/src/components/auth/EmailOTPInput.tsx deleted file mode 100644 index 87474ead..00000000 --- a/src/components/auth/EmailOTPInput.tsx +++ /dev/null @@ -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; - onCancel: () => void; - onResend: () => Promise; - 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 ( -
- - - - We've sent a 6-digit verification code to {email} - - - -
-
- Enter the 6-digit code -
- - - - - - - - - - - - -
- - -
- - -
-
- ); -} diff --git a/src/components/auth/MFAChallenge.tsx b/src/components/auth/MFAChallenge.tsx index 3e2ce41d..4e87922f 100644 --- a/src/components/auth/MFAChallenge.tsx +++ b/src/components/auth/MFAChallenge.tsx @@ -5,18 +5,15 @@ import { getErrorMessage } from '@/lib/errorHandler'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Shield, AlertCircle } from 'lucide-react'; +import { Shield } from 'lucide-react'; interface MFAChallengeProps { factorId: string; - challengeId?: string | null; - userId?: string | null; onSuccess: () => void; onCancel: () => void; } -export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCancel }: MFAChallengeProps) { +export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) { const { toast } = useToast(); const [code, setCode] = useState(''); const [loading, setLoading] = useState(false); @@ -26,38 +23,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance setLoading(true); try { - // NEW SERVER-SIDE FLOW: If we have challengeId and userId, use edge function - if (challengeId && userId) { - const { data: result, error: verifyError } = await supabase.functions.invoke( - 'verify-mfa-and-login', - { - body: { - challengeId, - factorId, - code: code.trim(), - userId, - }, - } - ); - - if (verifyError || result.error) { - throw new Error(result?.error || verifyError?.message || 'Verification failed'); - } - - // Set the session in Supabase client - if (result.session) { - await supabase.auth.setSession(result.session); - } - - toast({ - title: "Welcome back!", - description: "MFA verification successful." - }); - onSuccess(); - return; - } - - // OLD FLOW: For OAuth/Magic Link step-up (existing session) // Create fresh challenge for each verification attempt const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({ factorId }); @@ -94,14 +59,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance return (
- - - Security Verification Required - - Cancelling will sign you out completely. Two-factor authentication must be completed to access your account. - - -

Two-Factor Authentication

@@ -139,7 +96,7 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance className="flex-1" disabled={loading} > - Cancel & Sign Out + Cancel - -
-
- - - - ); -} diff --git a/src/components/dev/CacheMonitor.tsx b/src/components/dev/CacheMonitor.tsx deleted file mode 100644 index e2dff29d..00000000 --- a/src/components/dev/CacheMonitor.tsx +++ /dev/null @@ -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([]); - const invalidationsRef = useRef([]); - - // 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 ( -
-

Cache Monitor

- -
-
- Total Queries: - {stats.totalQueries} -
-
- Stale: - {stats.staleQueries} -
-
- Fetching: - {stats.fetchingQueries} -
-
- Size: - {(stats.cacheSize / 1024).toFixed(1)} KB -
-
- - {recentInvalidations.length > 0 && ( -
-

Recent Invalidations

-
- {recentInvalidations.map((inv, i) => ( -
-
{formatTime(inv.timestamp)}
-
{formatQueryKey(inv.queryKey)}
-
- ))} -
-
- )} -
- ); -} diff --git a/src/components/homepage/FeaturedParks.tsx b/src/components/homepage/FeaturedParks.tsx index ca49cc8c..750a729b 100644 --- a/src/components/homepage/FeaturedParks.tsx +++ b/src/components/homepage/FeaturedParks.tsx @@ -1,12 +1,54 @@ +import { useState, useEffect } from 'react'; import { Star, TrendingUp, Award, Castle, FerrisWheel, Waves, Tent } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Park } from '@/types/database'; -import { useFeaturedParks } from '@/hooks/homepage/useFeaturedParks'; +import { supabase } from '@/integrations/supabase/client'; +import { getErrorMessage } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; export function FeaturedParks() { - const { topRated, mostRides } = useFeaturedParks(); + const [topRatedParks, setTopRatedParks] = useState([]); + const [mostRidesParks, setMostRidesParks] = useState([]); + 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 }) => ( @@ -63,7 +105,7 @@ export function FeaturedParks() { ); - if (topRated.isLoading || mostRides.isLoading) { + if (loading) { return (
diff --git a/src/components/lists/ListDisplay.tsx b/src/components/lists/ListDisplay.tsx index a0461189..c77555a6 100644 --- a/src/components/lists/ListDisplay.tsx +++ b/src/components/lists/ListDisplay.tsx @@ -1,16 +1,78 @@ -import { UserTopList, Park, Ride, Company } from "@/types/database"; +import { useState, useEffect } from "react"; +import { UserTopList, UserTopListItem, Park, Ride, Company } from "@/types/database"; +import { supabase } from "@/integrations/supabase/client"; import { Link } from "react-router-dom"; import { Badge } from "@/components/ui/badge"; -import { useListItems } from "@/hooks/lists/useListItems"; interface ListDisplayProps { list: UserTopList; } -export function ListDisplay({ list }: ListDisplayProps) { - const { data: items, isLoading } = useListItems(list.id); +interface EnrichedListItem extends UserTopListItem { + entity?: Park | Ride | Company; +} - const getEntityUrl = (item: NonNullable[0]) => { +export function ListDisplay({ list }: ListDisplayProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchItemsWithEntities(); + }, [list.id]); + + const fetchItemsWithEntities = async () => { + setLoading(true); + + // First, get the list items + const { data: itemsData, error: itemsError } = await supabase + .from("user_top_list_items") + .select("*") + .eq("list_id", list.id) + .order("position", { ascending: true }); + + if (itemsError) { + console.error("Error fetching items:", itemsError); + setLoading(false); + return; + } + + // Then, fetch the entities for each item + const enrichedItems = await Promise.all( + (itemsData as UserTopListItem[]).map(async (item) => { + let entity = null; + + if (item.entity_type === "park") { + const { data } = await supabase + .from("parks") + .select("id, name, slug, park_type, location_id") + .eq("id", item.entity_id) + .single(); + entity = data; + } else if (item.entity_type === "ride") { + const { data } = await supabase + .from("rides") + .select("id, name, slug, category, park_id") + .eq("id", item.entity_id) + .single(); + entity = data; + } else if (item.entity_type === "company") { + const { data } = await supabase + .from("companies") + .select("id, name, slug, company_type") + .eq("id", item.entity_id) + .single(); + entity = data; + } + + return { ...item, entity }; + }) + ); + + setItems(enrichedItems); + setLoading(false); + }; + + const getEntityUrl = (item: EnrichedListItem) => { if (!item.entity) return "#"; const entity = item.entity as { slug?: string }; @@ -27,11 +89,11 @@ export function ListDisplay({ list }: ListDisplayProps) { return "#"; }; - if (isLoading) { + if (loading) { return
Loading...
; } - if (!items || items.length === 0) { + if (items.length === 0) { return (
This list is empty. Click "Edit" to add items. diff --git a/src/components/moderation/PhotoSubmissionDisplay.tsx b/src/components/moderation/PhotoSubmissionDisplay.tsx index 2abe76ae..6607f867 100644 --- a/src/components/moderation/PhotoSubmissionDisplay.tsx +++ b/src/components/moderation/PhotoSubmissionDisplay.tsx @@ -1,28 +1,78 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; import { PhotoGrid } from '@/components/common/PhotoGrid'; -import { usePhotoSubmission } from '@/hooks/moderation/usePhotoSubmission'; +import type { PhotoSubmissionItem } from '@/types/photo-submissions'; +import type { PhotoItem } from '@/types/photos'; +import { getErrorMessage } from '@/lib/errorHandler'; interface PhotoSubmissionDisplayProps { submissionId: string; } export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) { - const { data: photos, isLoading, error } = usePhotoSubmission(submissionId); + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Loading photos...
; } if (error) { return (
- Error loading photos: {error.message} + Error loading photos: {error}
Submission ID: {submissionId}
); } - if (!photos || photos.length === 0) { + if (photos.length === 0) { return (
No photos found for this submission @@ -32,5 +82,15 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP ); } - return ; + // 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 ; } diff --git a/src/components/moderation/RecentActivity.tsx b/src/components/moderation/RecentActivity.tsx index 9f3756e5..b006304e 100644 --- a/src/components/moderation/RecentActivity.tsx +++ b/src/components/moderation/RecentActivity.tsx @@ -1,20 +1,171 @@ -import { forwardRef, useImperativeHandle } from 'react'; -import { useRecentActivity } from '@/hooks/moderation/useRecentActivity'; +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/hooks/useAuth'; +import { handleError } from '@/lib/errorHandler'; import { ActivityCard } from './ActivityCard'; import { Skeleton } from '@/components/ui/skeleton'; import { Activity as ActivityIcon } from 'lucide-react'; +import { smartMergeArray } from '@/lib/smartStateUpdate'; +import { useAdminSettings } from '@/hooks/useAdminSettings'; + +interface ActivityItem { + id: string; + type: 'submission' | 'report' | 'review'; + action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged'; + entity_type?: string; + entity_name?: string; + timestamp: string; + moderator_id?: string; + moderator?: { + username: string; + display_name?: string; + avatar_url?: string; + }; +} export interface RecentActivityRef { refresh: () => void; } export const RecentActivity = forwardRef((props, ref) => { - const { data: activities = [], isLoading: loading, refetch } = useRecentActivity(); + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + const [isSilentRefresh, setIsSilentRefresh] = useState(false); + const { user } = useAuth(); + const { getAutoRefreshStrategy } = useAdminSettings(); + const refreshStrategy = getAutoRefreshStrategy(); useImperativeHandle(ref, () => ({ - refresh: refetch + refresh: () => fetchRecentActivity(false) })); + const fetchRecentActivity = async (silent = false) => { + if (!user) return; + + try { + if (!silent) { + setLoading(true); + } else { + setIsSilentRefresh(true); + } + + // Fetch recent approved/rejected submissions + const { data: submissions, error: submissionsError } = await supabase + .from('content_submissions') + .select('id, status, reviewed_at, reviewer_id, submission_type') + .in('status', ['approved', 'rejected']) + .not('reviewed_at', 'is', null) + .order('reviewed_at', { ascending: false }) + .limit(15); + + if (submissionsError) throw submissionsError; + + // Fetch recent report resolutions + const { data: reports, error: reportsError } = await supabase + .from('reports') + .select('id, status, reviewed_at, reviewed_by, reported_entity_type') + .in('status', ['reviewed', 'dismissed']) + .not('reviewed_at', 'is', null) + .order('reviewed_at', { ascending: false }) + .limit(15); + + if (reportsError) throw reportsError; + + // Fetch recent review moderations + const { data: reviews, error: reviewsError } = await supabase + .from('reviews') + .select('id, moderation_status, moderated_at, moderated_by, park_id, ride_id') + .in('moderation_status', ['approved', 'rejected', 'flagged']) + .not('moderated_at', 'is', null) + .order('moderated_at', { ascending: false }) + .limit(15); + + if (reviewsError) throw reviewsError; + + // Get unique moderator IDs + const moderatorIds = [ + ...(submissions?.map(s => s.reviewer_id).filter(Boolean) || []), + ...(reports?.map(r => r.reviewed_by).filter(Boolean) || []), + ...(reviews?.map(r => r.moderated_by).filter(Boolean) || []), + ].filter((id, index, arr) => id && arr.indexOf(id) === index); + + // Fetch moderator profiles + const { data: profiles } = await supabase + .from('profiles') + .select('user_id, username, display_name, avatar_url') + .in('user_id', moderatorIds); + + const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []); + + // Combine all activities + const allActivities: ActivityItem[] = [ + ...(submissions?.map(s => ({ + id: s.id, + type: 'submission' as const, + action: s.status as 'approved' | 'rejected', + entity_type: s.submission_type, + timestamp: s.reviewed_at!, + moderator_id: s.reviewer_id, + moderator: s.reviewer_id ? profileMap.get(s.reviewer_id) : undefined, + })) || []), + ...(reports?.map(r => ({ + id: r.id, + type: 'report' as const, + action: r.status as 'reviewed' | 'dismissed', + entity_type: r.reported_entity_type, + timestamp: r.reviewed_at!, + moderator_id: r.reviewed_by, + moderator: r.reviewed_by ? profileMap.get(r.reviewed_by) : undefined, + })) || []), + ...(reviews?.map(r => ({ + id: r.id, + type: 'review' as const, + action: r.moderation_status as 'approved' | 'rejected' | 'flagged', + timestamp: r.moderated_at!, + moderator_id: r.moderated_by, + moderator: r.moderated_by ? profileMap.get(r.moderated_by) : undefined, + })) || []), + ]; + + // Sort by timestamp (newest first) + allActivities.sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + const recentActivities = allActivities.slice(0, 20); // Keep top 20 most recent + + // Use smart merging for silent refreshes if strategy is 'merge' + if (silent && refreshStrategy === 'merge') { + const mergeResult = smartMergeArray(activities, recentActivities, { + compareFields: ['timestamp', 'action'], + preserveOrder: false, + addToTop: true, + }); + + if (mergeResult.hasChanges) { + setActivities(mergeResult.items); + } + } else { + // Full replacement for non-silent refreshes or 'replace' strategy + setActivities(recentActivities); + } + } catch (error: unknown) { + handleError(error, { + action: 'Load Recent Activity', + userId: user?.id + }); + } finally { + if (!silent) { + setLoading(false); + } + setIsSilentRefresh(false); + } + }; + + useEffect(() => { + fetchRecentActivity(false); + }, [user]); + if (loading) { return (
diff --git a/src/components/moderation/ReportButton.tsx b/src/components/moderation/ReportButton.tsx index 8e45b772..bf766d18 100644 --- a/src/components/moderation/ReportButton.tsx +++ b/src/components/moderation/ReportButton.tsx @@ -19,8 +19,10 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; +import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; -import { useReportMutation } from '@/hooks/reports/useReportMutation'; +import { useToast } from '@/hooks/use-toast'; +import { getErrorMessage } from '@/lib/errorHandler'; interface ReportButtonProps { entityType: 'review' | 'profile' | 'content_submission'; @@ -40,23 +42,42 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr const [open, setOpen] = useState(false); const [reportType, setReportType] = useState(''); const [reason, setReason] = useState(''); + const [loading, setLoading] = useState(false); const { user } = useAuth(); - - const reportMutation = useReportMutation(); + const { toast } = useToast(); - const handleSubmit = () => { + const handleSubmit = async () => { if (!user || !reportType) return; - reportMutation.mutate( - { entityType, entityId, reportType, reason }, - { - onSuccess: () => { - setOpen(false); - setReportType(''); - setReason(''); - }, - } - ); + setLoading(true); + try { + const { error } = await supabase.from('reports').insert({ + reporter_id: user.id, + reported_entity_type: entityType, + reported_entity_id: entityId, + report_type: reportType, + reason: reason.trim() || null, + }); + + if (error) throw error; + + toast({ + title: "Report Submitted", + description: "Thank you for your report. We'll review it shortly.", + }); + + setOpen(false); + setReportType(''); + setReason(''); + } catch (error: unknown) { + toast({ + title: "Error", + description: getErrorMessage(error), + variant: "destructive", + }); + } finally { + setLoading(false); + } }; if (!user) return null; @@ -115,10 +136,10 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr diff --git a/src/components/moderation/ReportsQueue.tsx b/src/components/moderation/ReportsQueue.tsx index f2b42ae1..fd069732 100644 --- a/src/components/moderation/ReportsQueue.tsx +++ b/src/components/moderation/ReportsQueue.tsx @@ -24,7 +24,6 @@ import { useAuth } from '@/hooks/useAuth'; import { useIsMobile } from '@/hooks/use-mobile'; import { smartMergeArray } from '@/lib/smartStateUpdate'; import { handleError, handleSuccess } from '@/lib/errorHandler'; -import { useReportActionMutation } from '@/hooks/reports/useReportActionMutation'; // Type-safe reported content interfaces interface ReportedReview { @@ -116,7 +115,6 @@ export const ReportsQueue = forwardRef((props, ref) => { const [actionLoading, setActionLoading] = useState(null); const [newReportsCount, setNewReportsCount] = useState(0); const { user } = useAuth(); - const { resolveReport, isResolving } = useReportActionMutation(); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -348,29 +346,67 @@ export const ReportsQueue = forwardRef((props, ref) => { }; }, [user, refreshMode, pollInterval, isInitialLoad]); - const handleReportAction = (reportId: string, action: 'reviewed' | 'dismissed') => { + const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => { setActionLoading(reportId); - - resolveReport.mutate( - { reportId, action }, - { - onSuccess: () => { - // Remove report from queue - setReports(prev => { - const newReports = prev.filter(r => r.id !== reportId); - // If last item on page and not page 1, go to previous page - if (newReports.length === 0 && currentPage > 1) { - setCurrentPage(prev => prev - 1); + try { + // Fetch full report details including reporter_id for audit log + const { data: reportData } = await supabase + .from('reports') + .select('reporter_id, reported_entity_type, reported_entity_id, reason') + .eq('id', reportId) + .single(); + + const { error } = await supabase + .from('reports') + .update({ + status: action, + reviewed_by: user?.id, + reviewed_at: new Date().toISOString(), + }) + .eq('id', reportId); + + if (error) throw error; + + // Log audit trail for report resolution + if (user && reportData) { + try { + await supabase.rpc('log_admin_action', { + _admin_user_id: user.id, + _target_user_id: reportData.reporter_id, + _action: action === 'reviewed' ? 'report_resolved' : 'report_dismissed', + _details: { + report_id: reportId, + reported_entity_type: reportData.reported_entity_type, + reported_entity_id: reportData.reported_entity_id, + report_reason: reportData.reason, + action: action } - return newReports; }); - setActionLoading(null); - }, - onError: () => { - setActionLoading(null); + } catch (auditError) { + console.error('Failed to log report action audit:', auditError); } } - ); + + handleSuccess(`Report ${action}`, `The report has been marked as ${action}`); + + // Remove report from queue + setReports(prev => { + const newReports = prev.filter(r => r.id !== reportId); + // If last item on page and not page 1, go to previous page + if (newReports.length === 0 && currentPage > 1) { + setCurrentPage(prev => prev - 1); + } + return newReports; + }); + } catch (error: unknown) { + handleError(error, { + action: `${action === 'reviewed' ? 'Resolve' : 'Dismiss'} Report`, + userId: user?.id, + metadata: { reportId, action } + }); + } finally { + setActionLoading(null); + } }; // Sort reports function diff --git a/src/components/moderation/UserRoleManager.tsx b/src/components/moderation/UserRoleManager.tsx index d24490f8..09a3d7e0 100644 --- a/src/components/moderation/UserRoleManager.tsx +++ b/src/components/moderation/UserRoleManager.tsx @@ -9,11 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; import { useUserRole } from '@/hooks/useUserRole'; -import { handleError, getErrorMessage } from '@/lib/errorHandler'; +import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; -import { useUserRoles } from '@/hooks/users/useUserRoles'; -import { useUserSearch } from '@/hooks/users/useUserSearch'; -import { useRoleMutations } from '@/hooks/users/useRoleMutations'; // Type-safe role definitions const VALID_ROLES = ['admin', 'moderator', 'user'] as const; @@ -55,36 +52,175 @@ interface UserRole { }; } export function UserRoleManager() { + const [userRoles, setUserRoles] = useState([]); + const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [newUserSearch, setNewUserSearch] = useState(''); const [newRole, setNewRole] = useState(''); - const [selectedUsers, setSelectedUsers] = useState([]); - - const { user } = useAuth(); - const { isAdmin, isSuperuser } = useUserRole(); - const { data: userRoles = [], isLoading: loading } = useUserRoles(); - const { data: searchResults = [] } = useUserSearch(newUserSearch); - const { grantRole, revokeRole } = useRoleMutations(); - const handleGrantRole = () => { - const selectedUser = selectedUsers[0]; - if (!selectedUser || !newRole || !isValidRole(newRole)) return; + const [searchResults, setSearchResults] = useState([]); + const [actionLoading, setActionLoading] = useState(null); + const { + user + } = useAuth(); + const { + isAdmin, + isSuperuser, + permissions + } = useUserRole(); + const fetchUserRoles = async () => { + try { + const { + data, + error + } = await supabase.from('user_roles').select(` + id, + user_id, + role, + granted_at + `).order('granted_at', { + ascending: false + }); + if (error) throw error; - grantRole.mutate( - { userId: selectedUser.user_id, role: newRole }, - { - onSuccess: () => { - setNewUserSearch(''); - setNewRole(''); - setSelectedUsers([]); - }, + // Get unique user IDs + const userIds = [...new Set((data || []).map(r => r.user_id))]; + + // Fetch user profiles with emails (for admins) + let profiles: Array<{ user_id: string; username: string; display_name?: string }> | null = null; + const { data: allProfiles, error: rpcError } = await supabase + .rpc('get_users_with_emails'); + + if (rpcError) { + logger.warn('Failed to fetch users with emails, using basic profiles', { error: getErrorMessage(rpcError) }); + const { data: basicProfiles } = await supabase + .from('profiles') + .select('user_id, username, display_name') + .in('user_id', userIds); + profiles = basicProfiles as typeof profiles; + } else { + profiles = allProfiles?.filter(p => userIds.includes(p.user_id)) || null; } - ); - }; + const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []); - const handleRevokeRole = (roleId: string) => { - revokeRole.mutate({ roleId }); + // Combine data with profiles + const userRolesWithProfiles = (data || []).map(role => ({ + ...role, + profiles: profileMap.get(role.user_id) + })); + setUserRoles(userRolesWithProfiles); + } catch (error: unknown) { + handleError(error, { + action: 'Load User Roles', + userId: user?.id + }); + } finally { + setLoading(false); + } }; + const searchUsers = async (search: string) => { + if (!search.trim()) { + setSearchResults([]); + return; + } + try { + let data; + const { data: allUsers, error: rpcError } = await supabase + .rpc('get_users_with_emails'); + + if (rpcError) { + logger.warn('Failed to fetch users with emails, using basic profiles', { error: getErrorMessage(rpcError) }); + const { data: basicProfiles, error: profilesError } = await supabase + .from('profiles') + .select('user_id, username, display_name') + .ilike('username', `%${search}%`); + + if (profilesError) throw profilesError; + data = basicProfiles?.slice(0, 10); + } else { + // Filter by search term + data = allUsers?.filter(user => + user.username.toLowerCase().includes(search.toLowerCase()) || + user.display_name?.toLowerCase().includes(search.toLowerCase()) + ).slice(0, 10); + } + // Filter out users who already have roles + const existingUserIds = userRoles.map(ur => ur.user_id); + const filteredResults = (data || []).filter(profile => !existingUserIds.includes(profile.user_id)); + setSearchResults(filteredResults); + } catch (error: unknown) { + logger.error('User search failed', { error: getErrorMessage(error) }); + } + }; + useEffect(() => { + fetchUserRoles(); + }, []); + useEffect(() => { + const debounceTimer = setTimeout(() => { + searchUsers(newUserSearch); + }, 300); + return () => clearTimeout(debounceTimer); + }, [newUserSearch, userRoles]); + const grantRole = async (userId: string, role: ValidRole) => { + if (!isAdmin()) return; + + // Double-check role validity before database operation + if (!isValidRole(role)) { + handleError(new Error('Invalid role'), { + action: 'Grant Role', + userId: user?.id, + metadata: { targetUserId: userId, attemptedRole: role } + }); + return; + } + + setActionLoading('grant'); + try { + const { + error + } = await supabase.from('user_roles').insert([{ + user_id: userId, + role, + granted_by: user?.id + }]); + + if (error) throw error; + + handleSuccess('Role Granted', `User has been granted ${getRoleLabel(role)} role`); + setNewUserSearch(''); + setNewRole(''); + setSearchResults([]); + fetchUserRoles(); + } catch (error: unknown) { + handleError(error, { + action: 'Grant Role', + userId: user?.id, + metadata: { targetUserId: userId, role } + }); + } finally { + setActionLoading(null); + } + }; + const revokeRole = async (roleId: string) => { + if (!isAdmin()) return; + setActionLoading(roleId); + try { + const { + error + } = await supabase.from('user_roles').delete().eq('id', roleId); + if (error) throw error; + handleSuccess('Role Revoked', 'User role has been revoked'); + fetchUserRoles(); + } catch (error: unknown) { + handleError(error, { + action: 'Revoke Role', + userId: user?.id, + metadata: { roleId } + }); + } finally { + setActionLoading(null); + } + }; if (!isAdmin()) { return
@@ -99,17 +235,7 @@ export function UserRoleManager() {
; } - // Filter existing user IDs for search results - const existingUserIds = userRoles.map(ur => ur.user_id); - const availableSearchResults = searchResults.filter( - profile => !existingUserIds.includes(profile.user_id) - ); - - const filteredRoles = userRoles.filter( - role => - role.username?.toLowerCase().includes(searchTerm.toLowerCase()) || - role.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const filteredRoles = userRoles.filter(role => role.profiles?.username?.toLowerCase().includes(searchTerm.toLowerCase()) || role.profiles?.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) || role.role.toLowerCase().includes(searchTerm.toLowerCase())); return
{/* Add new role */} @@ -123,10 +249,10 @@ export function UserRoleManager() { setNewUserSearch(e.target.value)} className="pl-10" />
- {availableSearchResults.length > 0 &&
- {availableSearchResults.map(profile =>
{ + {searchResults.length > 0 &&
+ {searchResults.map(profile =>
{ setNewUserSearch(profile.display_name || profile.username); - setSelectedUsers([profile]); + setSearchResults([profile]); }}>
{profile.display_name || profile.username} @@ -152,13 +278,23 @@ export function UserRoleManager() {
- @@ -185,31 +321,21 @@ export function UserRoleManager() {
- {userRole.display_name || userRole.username} + {userRole.profiles?.display_name || userRole.profiles?.username}
- {userRole.display_name &&
- @{userRole.username} -
} - {userRole.email &&
- {userRole.email} + {userRole.profiles?.display_name &&
+ @{userRole.profiles.username}
}
- - {getRoleLabel(userRole.id)} + + {userRole.role}
{/* Only show revoke button if current user can manage this role */} - {(isSuperuser() || (isAdmin() && !['admin', 'superuser'].includes(userRole.id))) && ( - - )} + } )}
diff --git a/src/components/parks/ParkCard.tsx b/src/components/parks/ParkCard.tsx index 4a42ee8b..8fcf47de 100644 --- a/src/components/parks/ParkCard.tsx +++ b/src/components/parks/ParkCard.tsx @@ -1,61 +1,19 @@ import { MapPin, Star, Users, Clock, Castle, FerrisWheel, Waves, Tent } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Park } from '@/types/database'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; -import { queryKeys } from '@/lib/queryKeys'; -import { supabase } from '@/integrations/supabase/client'; interface ParkCardProps { park: Park; } export function ParkCard({ park }: ParkCardProps) { const navigate = useNavigate(); - const queryClient = useQueryClient(); const handleClick = () => { navigate(`/parks/${park.slug}`); }; - - // Smart prefetch - only if not already cached - const handleMouseEnter = () => { - // Check if already cached before prefetching - const detailCached = queryClient.getQueryData(queryKeys.parks.detail(park.slug)); - const photosCached = queryClient.getQueryData(queryKeys.photos.entity('park', park.id)); - - if (!detailCached) { - queryClient.prefetchQuery({ - queryKey: queryKeys.parks.detail(park.slug), - queryFn: async () => { - const { data } = await supabase - .from('parks') - .select('*') - .eq('slug', park.slug) - .single(); - return data; - }, - staleTime: 5 * 60 * 1000, - }); - } - - if (!photosCached) { - queryClient.prefetchQuery({ - queryKey: queryKeys.photos.entity('park', park.id), - queryFn: async () => { - const { data } = await supabase - .from('photos') - .select('*') - .eq('entity_type', 'park') - .eq('entity_id', park.id) - .limit(10); - return data; - }, - staleTime: 5 * 60 * 1000, - }); - } - }; const getStatusColor = (status: string) => { switch (status) { case 'operating': @@ -98,7 +56,7 @@ export function ParkCard({ park }: ParkCardProps) { const formatParkType = (type: string) => { return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); }; - return + return
{/* Image Placeholder with Gradient */}
diff --git a/src/components/privacy/BlockedUsers.tsx b/src/components/privacy/BlockedUsers.tsx index 25583208..2a2ddd16 100644 --- a/src/components/privacy/BlockedUsers.tsx +++ b/src/components/privacy/BlockedUsers.tsx @@ -1,18 +1,153 @@ +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { UserX, Trash2 } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; -import { useBlockedUsers } from '@/hooks/privacy/useBlockedUsers'; -import { useBlockUserMutation } from '@/hooks/privacy/useBlockUserMutation'; +import { handleError, handleSuccess } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; +import type { UserBlock } from '@/types/privacy'; export function BlockedUsers() { const { user } = useAuth(); - const { data: blockedUsers = [], isLoading: loading } = useBlockedUsers(user?.id); - const { unblockUser, isUnblocking } = useBlockUserMutation(); + const [blockedUsers, setBlockedUsers] = useState([]); + const [loading, setLoading] = useState(true); - const handleUnblock = (blockId: string, blockedUserId: string, username: string) => { - unblockUser.mutate({ blockId, blockedUserId, username }); + useEffect(() => { + if (user) { + fetchBlockedUsers(); + } + }, [user]); + + const fetchBlockedUsers = async () => { + if (!user) return; + + try { + // First get the blocked user IDs + const { data: blocks, error: blocksError } = await supabase + .from('user_blocks') + .select('id, blocked_id, reason, created_at') + .eq('blocker_id', user.id) + .order('created_at', { ascending: false }); + + if (blocksError) { + logger.error('Failed to fetch user blocks', { + userId: user.id, + action: 'fetch_blocked_users', + error: blocksError.message, + errorCode: blocksError.code + }); + throw blocksError; + } + + if (!blocks || blocks.length === 0) { + setBlockedUsers([]); + return; + } + + // Then get the profile information for blocked users + const blockedIds = blocks.map(b => b.blocked_id); + const { data: profiles, error: profilesError } = await supabase + .from('profiles') + .select('user_id, username, display_name, avatar_url') + .in('user_id', blockedIds); + + if (profilesError) { + logger.error('Failed to fetch blocked user profiles', { + userId: user.id, + action: 'fetch_blocked_user_profiles', + error: profilesError.message, + errorCode: profilesError.code + }); + throw profilesError; + } + + // Combine the data + const blockedUsersWithProfiles = blocks.map(block => ({ + ...block, + blocker_id: user.id, + blocked_profile: profiles?.find(p => p.user_id === block.blocked_id) + })); + + setBlockedUsers(blockedUsersWithProfiles); + + logger.info('Blocked users fetched successfully', { + userId: user.id, + action: 'fetch_blocked_users', + count: blockedUsersWithProfiles.length + }); + } catch (error: unknown) { + logger.error('Error fetching blocked users', { + userId: user.id, + action: 'fetch_blocked_users', + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Load blocked users', + userId: user.id + }); + } finally { + setLoading(false); + } + }; + + const handleUnblock = async (blockId: string, blockedUserId: string, username: string) => { + if (!user) return; + + try { + const { error } = await supabase + .from('user_blocks') + .delete() + .eq('id', blockId); + + if (error) { + logger.error('Failed to unblock user', { + userId: user.id, + action: 'unblock_user', + targetUserId: blockedUserId, + error: error.message, + errorCode: error.code + }); + throw error; + } + + // Log to audit trail + await supabase.from('profile_audit_log').insert([{ + user_id: user.id, + changed_by: user.id, + action: 'user_unblocked', + changes: JSON.parse(JSON.stringify({ + blocked_user_id: blockedUserId, + username, + timestamp: new Date().toISOString() + })) + }]); + + setBlockedUsers(prev => prev.filter(block => block.id !== blockId)); + + logger.info('User unblocked successfully', { + userId: user.id, + action: 'unblock_user', + targetUserId: blockedUserId + }); + + handleSuccess('User unblocked', `You have unblocked @${username}`); + } catch (error: unknown) { + logger.error('Error unblocking user', { + userId: user.id, + action: 'unblock_user', + targetUserId: blockedUserId, + error: error instanceof Error ? error.message : String(error) + }); + + handleError(error, { + action: 'Unblock user', + userId: user.id, + metadata: { targetUsername: username } + }); + } }; if (loading) { @@ -76,7 +211,7 @@ export function BlockedUsers() { - diff --git a/src/components/profile/RideCreditsManager.tsx b/src/components/profile/RideCreditsManager.tsx index 9a313e9d..71e16ef2 100644 --- a/src/components/profile/RideCreditsManager.tsx +++ b/src/components/profile/RideCreditsManager.tsx @@ -13,7 +13,6 @@ import { RideCreditFilters } from './RideCreditFilters'; import { UserRideCredit } from '@/types/database'; import { useRideCreditFilters } from '@/hooks/useRideCreditFilters'; import { useIsMobile } from '@/hooks/use-mobile'; -import { useRideCreditsMutation } from '@/hooks/rides/useRideCreditsMutation'; import { DndContext, DragEndEvent, @@ -40,7 +39,6 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const isMobile = useIsMobile(); - const { reorderCredit, isReordering } = useRideCreditsMutation(); // Use the filter hook const { @@ -248,16 +246,24 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { } }; - const handleReorder = (creditId: string, newPosition: number) => { - return new Promise((resolve, reject) => { - reorderCredit.mutate( - { creditId, newPosition }, - { - onSuccess: () => resolve(), - onError: (error) => reject(error) - } - ); - }); + const handleReorder = async (creditId: string, newPosition: number) => { + try { + const { error } = await supabase.rpc('reorder_ride_credit', { + p_credit_id: creditId, + p_new_position: newPosition + }); + + if (error) throw error; + + // No refetch - optimistic update is already applied + } catch (error: unknown) { + handleError(error, { + action: 'Reorder Ride Credit', + userId, + metadata: { creditId, newPosition } + }); + throw error; + } }; const handleDragEnd = async (event: DragEndEvent) => { diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx index 99196bf5..14e291b1 100644 --- a/src/components/reviews/ReviewForm.tsx +++ b/src/components/reviews/ReviewForm.tsx @@ -17,8 +17,6 @@ import { StarRating } from './StarRating'; import { toDateOnly } from '@/lib/dateUtils'; import { getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; -import { useQueryInvalidation } from '@/lib/queryInvalidation'; - const reviewSchema = z.object({ rating: z.number().min(0.5).max(5).multipleOf(0.5), title: z.string().optional(), @@ -43,7 +41,6 @@ export function ReviewForm({ const { user } = useAuth(); - const { invalidateEntityReviews } = useQueryInvalidation(); const [rating, setRating] = useState(0); const [submitting, setSubmitting] = useState(false); const [photos, setPhotos] = useState([]); @@ -121,10 +118,6 @@ export function ReviewForm({ title: "Review Submitted!", description: "Thank you for your review. It will be published after moderation." }); - - // Invalidate review cache for instant UI update - invalidateEntityReviews(entityType, entityId); - reset(); setRating(0); setPhotos([]); diff --git a/src/components/rides/RideCard.tsx b/src/components/rides/RideCard.tsx index 66a0ffa2..78209d06 100644 --- a/src/components/rides/RideCard.tsx +++ b/src/components/rides/RideCard.tsx @@ -1,13 +1,10 @@ import { useNavigate } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Star, MapPin, Clock, Zap, FerrisWheel, Waves, Theater, Train, ArrowUp, CheckCircle, Calendar, Hammer, XCircle } from 'lucide-react'; import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { Ride } from '@/types/database'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; -import { queryKeys } from '@/lib/queryKeys'; -import { supabase } from '@/integrations/supabase/client'; interface RideCardProps { ride: Ride; @@ -18,47 +15,11 @@ interface RideCardProps { export function RideCard({ ride, showParkName = true, className, parkSlug }: RideCardProps) { const navigate = useNavigate(); - const queryClient = useQueryClient(); const handleRideClick = () => { const slug = parkSlug || ride.park?.slug; navigate(`/parks/${slug}/rides/${ride.slug}`); }; - - // Prefetch ride detail data on hover - const handleMouseEnter = () => { - const slug = parkSlug || ride.park?.slug; - if (!slug) return; - - // Prefetch ride detail page data - queryClient.prefetchQuery({ - queryKey: queryKeys.rides.detail(slug, ride.slug), - queryFn: async () => { - const { data } = await supabase - .from('rides') - .select('*') - .eq('slug', ride.slug) - .single(); - return data; - }, - staleTime: 5 * 60 * 1000, - }); - - // Prefetch ride photos (first 10) - queryClient.prefetchQuery({ - queryKey: queryKeys.photos.entity('ride', ride.id), - queryFn: async () => { - const { data } = await supabase - .from('photos') - .select('*') - .eq('entity_type', 'ride') - .eq('entity_id', ride.id) - .limit(10); - return data; - }, - staleTime: 5 * 60 * 1000, - }); - }; const getRideIcon = (category: string) => { switch (category) { @@ -100,7 +61,6 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid
{/* Image/Icon Section */} diff --git a/src/components/rides/RideModelCard.tsx b/src/components/rides/RideModelCard.tsx index 8749677f..13598b32 100644 --- a/src/components/rides/RideModelCard.tsx +++ b/src/components/rides/RideModelCard.tsx @@ -3,10 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { FerrisWheel } from 'lucide-react'; import { RideModel } from '@/types/database'; import { useNavigate } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils'; -import { queryKeys } from '@/lib/queryKeys'; -import { supabase } from '@/integrations/supabase/client'; interface RideModelCardProps { model: RideModel; @@ -15,23 +12,6 @@ interface RideModelCardProps { export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) { const navigate = useNavigate(); - const queryClient = useQueryClient(); - - // Prefetch ride model detail data on hover - const handleMouseEnter = () => { - queryClient.prefetchQuery({ - queryKey: queryKeys.rideModels.detail(manufacturerSlug, model.slug), - queryFn: async () => { - const { data } = await supabase - .from('ride_models') - .select('*') - .eq('slug', model.slug) - .single(); - return data; - }, - staleTime: 5 * 60 * 1000, - }); - }; const formatCategory = (category: string | null | undefined) => { if (!category) return 'Unknown'; @@ -62,7 +42,6 @@ export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) { navigate(`/manufacturers/${manufacturerSlug}/models/${model.slug}`)} - onMouseEnter={handleMouseEnter} >
{(cardImageUrl || cardImageId) ? ( diff --git a/src/components/rides/SimilarRides.tsx b/src/components/rides/SimilarRides.tsx index 8be8c695..c5a92bf2 100644 --- a/src/components/rides/SimilarRides.tsx +++ b/src/components/rides/SimilarRides.tsx @@ -1,8 +1,9 @@ +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; +import { supabase } from '@/integrations/supabase/client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { RideCard } from '@/components/rides/RideCard'; -import { useSimilarRides } from '@/hooks/rides/useSimilarRides'; interface SimilarRidesProps { currentRideId: string; @@ -31,9 +32,44 @@ interface SimilarRide { } export function SimilarRides({ currentRideId, parkId, parkSlug, category }: SimilarRidesProps) { - const { data: rides, isLoading } = useSimilarRides(currentRideId, parkId, category); + const [rides, setRides] = useState([]); + 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; } diff --git a/src/components/settings/AccountProfileTab.tsx b/src/components/settings/AccountProfileTab.tsx index 77fc8738..cc16f5f6 100644 --- a/src/components/settings/AccountProfileTab.tsx +++ b/src/components/settings/AccountProfileTab.tsx @@ -28,7 +28,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler'; import { useAvatarUpload } from '@/hooks/useAvatarUpload'; import { useUsernameValidation } from '@/hooks/useUsernameValidation'; import { useAutoSave } from '@/hooks/useAutoSave'; -import { useProfileUpdateMutation } from '@/hooks/profile/useProfileUpdateMutation'; import { formatDistanceToNow } from 'date-fns'; import { cn } from '@/lib/utils'; @@ -43,7 +42,7 @@ type ProfileFormData = z.infer; export function AccountProfileTab() { const { user, pendingEmail, clearPendingEmail } = useAuth(); const { data: profile, refreshProfile } = useProfile(user?.id); - const updateProfileMutation = useProfileUpdateMutation(); + const [loading, setLoading] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showEmailDialog, setShowEmailDialog] = useState(false); const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false); @@ -108,28 +107,47 @@ export function AccountProfileTab() { const handleFormSubmit = async (data: ProfileFormData) => { if (!user) return; - // Update Novu subscriber if username changed (before mutation for optimistic update) - const usernameChanged = data.username !== profile?.username; - - updateProfileMutation.mutate({ - userId: user.id, - updates: { - username: data.username, - display_name: data.display_name || null, - bio: data.bio || null - } - }, { - onSuccess: async () => { - if (usernameChanged && notificationService.isEnabled()) { - await notificationService.updateSubscriber({ - subscriberId: user.id, - email: user.email, - firstName: data.username, - }); + setLoading(true); + try { + // Use the update_profile RPC function with server-side validation + const { data: result, error } = await supabase.rpc('update_profile', { + p_username: data.username, + p_display_name: data.display_name || null, + p_bio: data.bio || null + }); + + if (error) { + // Handle rate limiting error + if (error.code === 'P0001') { + const resetTime = error.message.match(/Try again at (.+)$/)?.[1]; + throw new AppError( + error.message, + 'RATE_LIMIT', + `Too many profile updates. ${resetTime ? 'Try again at ' + new Date(resetTime).toLocaleTimeString() : 'Please wait a few minutes.'}` + ); } - await refreshProfile(); + throw error; } - }); + + // Type the RPC result + const rpcResult = result as unknown as { success: boolean; changes_count: number }; + + // Update Novu subscriber if username changed + if (rpcResult?.changes_count > 0 && notificationService.isEnabled()) { + await notificationService.updateSubscriber({ + subscriberId: user.id, + email: user.email, + firstName: data.username, + }); + } + + await refreshProfile(); + handleSuccess('Profile updated', 'Your profile has been successfully updated.'); + } catch (error: unknown) { + handleError(error, { action: 'Update profile', userId: user.id }); + } finally { + setLoading(false); + } }; const onSubmit = async (data: ProfileFormData) => { @@ -382,17 +400,17 @@ export function AccountProfileTab() { - {lastSaved && !updateProfileMutation.isPending && !isSaving && ( + {lastSaved && !loading && !isSaving && ( Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })} diff --git a/src/components/settings/Auth0MFASettings.tsx b/src/components/settings/Auth0MFASettings.tsx deleted file mode 100644 index 377341be..00000000 --- a/src/components/settings/Auth0MFASettings.tsx +++ /dev/null @@ -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(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 ( - - -
- - Multi-Factor Authentication (MFA) -
- - Add an extra layer of security to your account - -
- -
- -
-
-
- ); - } - - return ( - - -
- - Multi-Factor Authentication (MFA) -
- - Add an extra layer of security to your account with two-factor authentication - -
- - {/* MFA Status */} -
-
- {mfaStatus?.enrolled ? ( - - ) : ( - - )} -
-

- MFA Status -

-

- {mfaStatus?.enrolled - ? 'Multi-factor authentication is active' - : 'MFA is not enabled on your account'} -

-
-
- - {mfaStatus?.enrolled ? 'Enabled' : 'Disabled'} - -
- - {/* Enrolled Methods */} - {mfaStatus?.enrolled && mfaStatus.methods.length > 0 && ( -
-

Active Methods:

-
- {mfaStatus.methods.map((method) => ( -
-
-

{method.type}

- {method.name && ( -

{method.name}

- )} -
- - {method.confirmed ? 'Active' : 'Pending'} - -
- ))} -
-
- )} - - {/* Info Alert */} - - - - {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. - - )} - - - - {/* Action Buttons */} -
- {!mfaStatus?.enrolled ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/src/components/settings/EmailChangeDialog.tsx b/src/components/settings/EmailChangeDialog.tsx index 9b550e1f..9283629f 100644 --- a/src/components/settings/EmailChangeDialog.tsx +++ b/src/components/settings/EmailChangeDialog.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { useEmailChangeMutation } from '@/hooks/security/useEmailChangeMutation'; import { Dialog, DialogContent, @@ -53,7 +52,6 @@ interface EmailChangeDialogProps { export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) { const { theme } = useTheme(); - const { changeEmail, isChanging } = useEmailChangeMutation(); const [step, setStep] = useState('verification'); const [loading, setLoading] = useState(false); const [captchaToken, setCaptchaToken] = useState(''); @@ -158,18 +156,63 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: throw signInError; } - // Step 3: Update email address using mutation hook - changeEmail.mutate( - { newEmail: data.newEmail, currentEmail, userId }, - { - onSuccess: () => { - setStep('success'); - }, - onError: (error) => { - throw error; - } + // Step 3: Update email address + // Supabase will send verification emails to both old and new addresses + const { error: updateError } = await supabase.auth.updateUser({ + email: data.newEmail + }); + + if (updateError) throw updateError; + + // Step 4: Novu subscriber will be updated automatically after both emails are confirmed + // This happens in the useAuth hook when the email change is fully verified + + // Step 5: Log the email change attempt + supabase.from('admin_audit_log').insert({ + admin_user_id: userId, + target_user_id: userId, + action: 'email_change_initiated', + details: { + old_email: currentEmail, + new_email: data.newEmail, + timestamp: new Date().toISOString(), } + }).then(({ error }) => { + if (error) { + logger.error('Failed to log email change', { + userId, + action: 'email_change_audit_log', + error: error.message + }); + } + }); + + // Step 6: Send security notifications (non-blocking) + if (notificationService.isEnabled()) { + notificationService.trigger({ + workflowId: 'security-alert', + subscriberId: userId, + payload: { + alert_type: 'email_change_initiated', + old_email: currentEmail, + new_email: data.newEmail, + timestamp: new Date().toISOString(), + } + }).catch(error => { + logger.error('Failed to send security notification', { + userId, + action: 'email_change_notification', + error: error instanceof Error ? error.message : String(error) + }); + }); + } + + handleSuccess( + 'Email change initiated', + 'Check both email addresses for confirmation links.' ); + + setStep('success'); } catch (error: unknown) { const errorMsg = getErrorMessage(error); logger.error('Email change failed', { diff --git a/src/components/settings/EmailChangeStatus.tsx b/src/components/settings/EmailChangeStatus.tsx index 17ecd46a..2301c4dd 100644 --- a/src/components/settings/EmailChangeStatus.tsx +++ b/src/components/settings/EmailChangeStatus.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -8,7 +8,6 @@ import { Progress } from '@/components/ui/progress'; import { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; import { handleError, handleSuccess } from '@/lib/errorHandler'; -import { useEmailChangeStatus } from '@/hooks/security/useEmailChangeStatus'; interface EmailChangeStatusProps { currentEmail: string; @@ -16,19 +15,55 @@ interface EmailChangeStatusProps { onCancel: () => void; } +type EmailChangeData = { + has_pending_change: boolean; + current_email?: string; + new_email?: string; + current_email_verified?: boolean; + new_email_verified?: boolean; + change_sent_at?: string; +}; + export function EmailChangeStatus({ currentEmail, pendingEmail, onCancel }: EmailChangeStatusProps) { + const [verificationStatus, setVerificationStatus] = useState({ + oldEmailVerified: false, + newEmailVerified: false + }); + const [loading, setLoading] = useState(true); const [resending, setResending] = useState(false); - const { data: emailStatus, isLoading } = useEmailChangeStatus(); - const verificationStatus = { - oldEmailVerified: emailStatus?.current_email_verified || false, - newEmailVerified: emailStatus?.new_email_verified || false + const checkVerificationStatus = async () => { + try { + const { data, error } = await supabase.rpc('get_email_change_status'); + + if (error) throw error; + + const emailData = data as EmailChangeData; + + if (emailData.has_pending_change) { + setVerificationStatus({ + oldEmailVerified: emailData.current_email_verified || false, + newEmailVerified: emailData.new_email_verified || false + }); + } + } catch (error: unknown) { + handleError(error, { action: 'Check verification status' }); + } finally { + setLoading(false); + } }; + useEffect(() => { + checkVerificationStatus(); + // Poll every 30 seconds + const interval = setInterval(checkVerificationStatus, 30000); + return () => clearInterval(interval); + }, []); + const handleResendVerification = async () => { setResending(true); try { @@ -53,7 +88,7 @@ export function EmailChangeStatus({ (verificationStatus.oldEmailVerified ? 50 : 0) + (verificationStatus.newEmailVerified ? 50 : 0); - if (isLoading) { + if (loading) { return ( diff --git a/src/components/settings/IdentityManagement.tsx b/src/components/settings/IdentityManagement.tsx deleted file mode 100644 index 47a4b342..00000000 --- a/src/components/settings/IdentityManagement.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [actionLoading, setActionLoading] = useState(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 ( - - - Connected Accounts - Loading... - - - ); - } - - return ( - - - - - Connected Accounts - - - Link multiple sign-in methods to your account for easy access - - - - {identities.length === 1 && !hasPassword && ( - - - - Add a password as a backup sign-in method - - - )} - - {/* Password Authentication */} -
-
-
- -
-
-
Email & Password
-
- {hasPassword ? 'Connected' : 'Not set up'} -
-
-
- {!hasPassword && ( - - )} -
- - {/* OAuth Providers */} - {providers.map((provider) => { - const isConnected = hasProvider(provider.id); - - return ( -
-
-
- {provider.icon} -
-
-
{provider.label}
-
- {isConnected ? 'Connected' : 'Not connected'} -
-
-
- -
- ); - })} -
-
- ); -} diff --git a/src/components/settings/LocationTab.tsx b/src/components/settings/LocationTab.tsx index bd14588c..4350bf03 100644 --- a/src/components/settings/LocationTab.tsx +++ b/src/components/settings/LocationTab.tsx @@ -13,7 +13,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useAuth } from '@/hooks/useAuth'; import { useProfile } from '@/hooks/useProfile'; import { useUnitPreferences } from '@/hooks/useUnitPreferences'; -import { useProfileLocationMutation } from '@/hooks/profile/useProfileLocationMutation'; import { supabase } from '@/integrations/supabase/client'; import { handleError, handleSuccess, AppError } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; @@ -31,8 +30,8 @@ export function LocationTab() { const { user } = useAuth(); const { data: profile, refreshProfile } = useProfile(user?.id); const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences(); - const { updateLocation, isUpdating } = useProfileLocationMutation(); const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [parks, setParks] = useState([]); const [accessibility, setAccessibility] = useState(DEFAULT_ACCESSIBILITY_OPTIONS); @@ -172,11 +171,42 @@ export function LocationTab() { const onSubmit = async (data: LocationFormData) => { if (!user) return; + setSaving(true); + try { const validatedData = locationFormSchema.parse(data); const validatedAccessibility = accessibilityOptionsSchema.parse(accessibility); - // Update accessibility preferences first + const previousProfile = { + personal_location: profile?.personal_location, + home_park_id: profile?.home_park_id, + timezone: profile?.timezone, + preferred_language: profile?.preferred_language, + preferred_pronouns: profile?.preferred_pronouns + }; + + const { error: profileError } = await supabase + .from('profiles') + .update({ + preferred_pronouns: validatedData.preferred_pronouns || null, + timezone: validatedData.timezone, + preferred_language: validatedData.preferred_language, + personal_location: validatedData.personal_location || null, + home_park_id: validatedData.home_park_id || null, + updated_at: new Date().toISOString() + }) + .eq('user_id', user.id); + + if (profileError) { + logger.error('Failed to update profile', { + userId: user.id, + action: 'update_profile_location', + error: profileError.message, + errorCode: profileError.code + }); + throw profileError; + } + const { error: accessibilityError } = await supabase .from('user_preferences') .update({ @@ -197,20 +227,34 @@ export function LocationTab() { await updateUnitPreferences(unitPreferences); - // Update profile via mutation hook with complete validated data - const locationData: LocationFormData = { - personal_location: validatedData.personal_location || null, - home_park_id: validatedData.home_park_id || null, - timezone: validatedData.timezone, - preferred_language: validatedData.preferred_language, - preferred_pronouns: validatedData.preferred_pronouns || null, - }; + await supabase.from('profile_audit_log').insert([{ + user_id: user.id, + changed_by: user.id, + action: 'location_info_updated', + changes: JSON.parse(JSON.stringify({ + previous: { + profile: previousProfile, + accessibility: DEFAULT_ACCESSIBILITY_OPTIONS + }, + updated: { + profile: validatedData, + accessibility: validatedAccessibility + }, + timestamp: new Date().toISOString() + })) + }]); - updateLocation.mutate(locationData, { - onSuccess: () => { - refreshProfile(); - } + await refreshProfile(); + + logger.info('Location and info settings updated', { + userId: user.id, + action: 'update_location_info' }); + + handleSuccess( + 'Settings saved', + 'Your location, personal information, accessibility, and unit preferences have been updated.' + ); } catch (error: unknown) { logger.error('Error saving location settings', { userId: user.id, @@ -233,6 +277,8 @@ export function LocationTab() { userId: user.id }); } + } finally { + setSaving(false); } }; @@ -512,8 +558,8 @@ export function LocationTab() { {/* Save Button */}
-
diff --git a/src/components/settings/PasswordUpdateDialog.tsx b/src/components/settings/PasswordUpdateDialog.tsx index d4ca0317..e0ff3d28 100644 --- a/src/components/settings/PasswordUpdateDialog.tsx +++ b/src/components/settings/PasswordUpdateDialog.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; +import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { usePasswordUpdateMutation } from '@/hooks/security/usePasswordUpdateMutation'; import { Dialog, DialogContent, @@ -45,7 +45,6 @@ function isErrorWithCode(error: unknown): error is Error & ErrorWithCode { export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) { const { theme } = useTheme(); - const { updatePassword, isUpdating } = usePasswordUpdateMutation(); const [step, setStep] = useState('password'); const [loading, setLoading] = useState(false); const [nonce, setNonce] = useState(''); @@ -289,26 +288,62 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password const updatePasswordWithNonce = async (password: string, nonceValue: string) => { try { - updatePassword.mutate( - { password, hasMFA, userId }, - { - onSuccess: () => { - setStep('success'); - form.reset(); - - // Auto-close after 2 seconds - setTimeout(() => { - onOpenChange(false); - onSuccess(); - setStep('password'); - setTotpCode(''); - }, 2000); - }, - onError: (error) => { - throw error; + // Step 2: Update password + const { error: updateError } = await supabase.auth.updateUser({ + password + }); + + if (updateError) throw updateError; + + // Step 3: Log audit trail + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + await supabase.from('admin_audit_log').insert({ + admin_user_id: user.id, + target_user_id: user.id, + action: 'password_changed', + details: { + timestamp: new Date().toISOString(), + method: hasMFA ? 'password_with_mfa' : 'password_only', + user_agent: navigator.userAgent } + }); + + // Step 4: Send security notification + try { + await invokeWithTracking( + 'trigger-notification', + { + workflowId: 'security-alert', + subscriberId: user.id, + payload: { + alert_type: 'password_changed', + timestamp: new Date().toISOString(), + device: navigator.userAgent.split(' ')[0] + } + }, + user.id + ); + } catch (notifError) { + logger.error('Failed to send password change notification', { + userId: user!.id, + action: 'password_change_notification', + error: getErrorMessage(notifError) + }); + // Don't fail the password update if notification fails } - ); + } + + setStep('success'); + form.reset(); + + // Auto-close after 2 seconds + setTimeout(() => { + onOpenChange(false); + onSuccess(); + setStep('password'); + setTotpCode(''); + }, 2000); } catch (error: unknown) { throw error; } diff --git a/src/components/settings/PrivacyTab.tsx b/src/components/settings/PrivacyTab.tsx index 0e37de9c..dff8da03 100644 --- a/src/components/settings/PrivacyTab.tsx +++ b/src/components/settings/PrivacyTab.tsx @@ -11,7 +11,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; import { useAuth } from '@/hooks/useAuth'; import { useProfile } from '@/hooks/useProfile'; -import { usePrivacyMutations } from '@/hooks/privacy/usePrivacyMutations'; import { supabase } from '@/integrations/supabase/client'; import { Eye, UserX, Shield, Search } from 'lucide-react'; import { BlockedUsers } from '@/components/privacy/BlockedUsers'; @@ -22,7 +21,7 @@ import { z } from 'zod'; export function PrivacyTab() { const { user } = useAuth(); const { data: profile, refreshProfile } = useProfile(user?.id); - const { updatePrivacy, isUpdating } = usePrivacyMutations(); + const [loading, setLoading] = useState(false); const [preferences, setPreferences] = useState(null); const form = useForm({ @@ -135,17 +134,106 @@ export function PrivacyTab() { } }; - const onSubmit = (data: PrivacyFormData) => { + const onSubmit = async (data: PrivacyFormData) => { if (!user) return; - updatePrivacy.mutate(data, { - onSuccess: () => { - refreshProfile(); - // Extract privacy settings (exclude profile fields) - const { privacy_level, show_pronouns, ...privacySettings } = data; - setPreferences(privacySettings); + setLoading(true); + + try { + // Validate the form data + const validated = privacyFormSchema.parse(data); + + // Update profile privacy settings + const { error: profileError } = await supabase + .from('profiles') + .update({ + privacy_level: validated.privacy_level, + show_pronouns: validated.show_pronouns, + updated_at: new Date().toISOString() + }) + .eq('user_id', user.id); + + if (profileError) { + logger.error('Failed to update profile privacy', { + userId: user.id, + action: 'update_profile_privacy', + error: profileError.message, + errorCode: profileError.code + }); + throw profileError; } - }); + + // Extract privacy settings (exclude profile fields) + const { privacy_level, show_pronouns, ...privacySettings } = validated; + + // Update user preferences + const { error: prefsError } = await supabase + .from('user_preferences') + .upsert([{ + user_id: user.id, + privacy_settings: privacySettings, + updated_at: new Date().toISOString() + }]); + + if (prefsError) { + logger.error('Failed to update privacy preferences', { + userId: user.id, + action: 'update_privacy_preferences', + error: prefsError.message, + errorCode: prefsError.code + }); + throw prefsError; + } + + // Log to audit trail + await supabase.from('profile_audit_log').insert([{ + user_id: user.id, + changed_by: user.id, + action: 'privacy_settings_updated', + changes: JSON.parse(JSON.stringify({ + previous: preferences, + updated: privacySettings, + timestamp: new Date().toISOString() + })) + }]); + + await refreshProfile(); + setPreferences(privacySettings); + + logger.info('Privacy settings updated successfully', { + userId: user.id, + action: 'update_privacy_settings' + }); + + handleSuccess( + 'Privacy settings updated', + 'Your privacy preferences have been successfully saved.' + ); + } catch (error: unknown) { + logger.error('Failed to update privacy settings', { + userId: user.id, + action: 'update_privacy_settings', + error: error instanceof Error ? error.message : String(error) + }); + + if (error instanceof z.ZodError) { + handleError( + new AppError( + 'Invalid privacy settings', + 'VALIDATION_ERROR', + error.issues.map(e => e.message).join(', ') + ), + { action: 'Validate privacy settings', userId: user.id } + ); + } else { + handleError(error, { + action: 'Update privacy settings', + userId: user.id + }); + } + } finally { + setLoading(false); + } }; return ( @@ -362,8 +450,8 @@ export function PrivacyTab() { {/* Save Button */}
-
diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index 4bc274fb..f257a654 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -6,7 +6,6 @@ import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { handleError, handleSuccess } from '@/lib/errorHandler'; import { useAuth } from '@/hooks/useAuth'; -import { useSecurityMutations } from '@/hooks/security/useSecurityMutations'; import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react'; import { format } from 'date-fns'; import { Skeleton } from '@/components/ui/skeleton'; @@ -14,7 +13,6 @@ import { TOTPSetup } from '@/components/auth/TOTPSetup'; import { GoogleIcon } from '@/components/icons/GoogleIcon'; import { DiscordIcon } from '@/components/icons/DiscordIcon'; import { PasswordUpdateDialog } from './PasswordUpdateDialog'; -import { useSessions } from '@/hooks/security/useSessions'; import { getUserIdentities, checkDisconnectSafety, @@ -31,21 +29,20 @@ import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog'; export function SecurityTab() { const { user } = useAuth(); const navigate = useNavigate(); - const { revokeSession, isRevoking } = useSecurityMutations(); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [identities, setIdentities] = useState([]); const [loadingIdentities, setLoadingIdentities] = useState(true); const [disconnectingProvider, setDisconnectingProvider] = useState(null); const [hasPassword, setHasPassword] = useState(false); const [addingPassword, setAddingPassword] = useState(false); + const [sessions, setSessions] = useState([]); + const [loadingSessions, setLoadingSessions] = useState(true); const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null); - - // Fetch sessions using hook - const { data: sessions = [], isLoading: loadingSessions, refetch: refetchSessions } = useSessions(user?.id); // Load user identities on mount useEffect(() => { loadIdentities(); + fetchSessions(); }, []); const loadIdentities = async () => { @@ -146,6 +143,35 @@ export function SecurityTab() { setAddingPassword(false); }; + const fetchSessions = async () => { + if (!user) return; + + setLoadingSessions(true); + + try { + const { data, error } = await supabase.rpc('get_my_sessions'); + + if (error) { + throw error; + } + + setSessions((data as AuthSession[]) || []); + } catch (error: unknown) { + logger.error('Failed to fetch sessions', { + userId: user.id, + action: 'fetch_sessions', + error: error instanceof Error ? error.message : String(error) + }); + handleError(error, { + action: 'Load active sessions', + userId: user.id + }); + setSessions([]); + } finally { + setLoadingSessions(false); + } + }; + const initiateSessionRevoke = async (sessionId: string) => { // Get current session to check if revoking self const { data: { session: currentSession } } = await supabase.auth.getSession(); @@ -156,23 +182,33 @@ export function SecurityTab() { setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession }); }; - const confirmRevokeSession = () => { + const confirmRevokeSession = async () => { if (!sessionToRevoke) return; - revokeSession.mutate( - { sessionId: sessionToRevoke.id, isCurrent: sessionToRevoke.isCurrent }, - { - onSuccess: () => { - if (!sessionToRevoke.isCurrent) { - refetchSessions(); - } - setSessionToRevoke(null); - }, - onError: () => { - setSessionToRevoke(null); - } + const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionToRevoke.id }); + + if (error) { + logger.error('Failed to revoke session', { + userId: user?.id, + action: 'revoke_session', + sessionId: sessionToRevoke.id, + error: error.message + }); + handleError(error, { action: 'Revoke session', userId: user?.id }); + } else { + handleSuccess('Success', 'Session revoked successfully'); + + if (sessionToRevoke.isCurrent) { + // Redirect to login after revoking current session + setTimeout(() => { + window.location.href = '/auth'; + }, 1000); + } else { + fetchSessions(); } - ); + } + + setSessionToRevoke(null); }; const getDeviceIcon = (userAgent: string | null) => { @@ -261,77 +297,77 @@ export function SecurityTab() {
- {/* Identity Management Section */} - - -
- - Connected Accounts -
- - Manage your social login connections for easier access to your account. - -
- - {loadingIdentities ? ( -
- -
- ) : ( - connectedAccounts.map(account => { - const isConnected = !!account.identity; - const isDisconnecting = disconnectingProvider === account.provider; - const email = account.identity?.identity_data?.email; + {/* Connected Accounts */} + + +
+ + Connected Accounts +
+ + Manage your social login connections for easier access to your account. + +
+ + {loadingIdentities ? ( +
+ +
+ ) : ( + connectedAccounts.map(account => { + const isConnected = !!account.identity; + const isDisconnecting = disconnectingProvider === account.provider; + const email = account.identity?.identity_data?.email; - return ( -
-
-
- {account.icon} + return ( +
+
+
+ {account.icon} +
+
+

{account.provider}

+ {isConnected && email && ( +

{email}

+ )} +
-
-

{account.provider}

- {isConnected && email && ( -

{email}

- )} -
-
-
- {isConnected ? ( - <> - Connected +
+ {isConnected ? ( + <> + Connected + + + ) : ( - - ) : ( - - )} + )} +
-
- ); - }) - )} - - + ); + }) + )} + +
{/* Two-Factor Authentication - Full Width */} diff --git a/src/components/upload/EntityPhotoGallery.tsx b/src/components/upload/EntityPhotoGallery.tsx index b58e24ec..6bf8ab98 100644 --- a/src/components/upload/EntityPhotoGallery.tsx +++ b/src/components/upload/EntityPhotoGallery.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -14,10 +14,11 @@ import { useNavigate } from 'react-router-dom'; import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload'; import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog'; import { PhotoModal } from '@/components/moderation/PhotoModal'; +import { supabase } from '@/integrations/supabase/client'; import { EntityPhotoGalleryProps } from '@/types/submissions'; import { useUserRole } from '@/hooks/useUserRole'; -import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos'; -import { useQueryInvalidation } from '@/lib/queryInvalidation'; +import { getErrorMessage } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; interface Photo { id: string; @@ -37,25 +38,47 @@ export function EntityPhotoGallery({ const { user } = useAuth(); const navigate = useNavigate(); const { isModerator } = useUserRole(); + const [photos, setPhotos] = useState([]); const [showUpload, setShowUpload] = useState(false); const [showManagement, setShowManagement] = useState(false); + const [loading, setLoading] = useState(true); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest'); - // Use optimized photos hook with caching - const { data: photos = [], isLoading: loading, refetch } = useEntityPhotos( - entityType, - entityId, - sortBy - ); + useEffect(() => { + fetchPhotos(); + }, [entityId, entityType, sortBy]); - // Query invalidation for cross-component cache updates - const { - invalidateEntityPhotos, - invalidatePhotoCount, - invalidateHomepageData - } = useQueryInvalidation(); + const fetchPhotos = async () => { + try { + // Fetch photos directly from the photos table + const { data: photoData, error } = await supabase + .from('photos') + .select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .order('created_at', { ascending: sortBy === 'oldest' }); + + if (error) throw error; + + // Map to Photo interface + const mappedPhotos: Photo[] = photoData?.map((photo) => ({ + id: photo.id, + url: photo.cloudflare_image_url, + caption: photo.caption || undefined, + title: photo.title || undefined, + user_id: photo.submitted_by, + created_at: photo.created_at, + })) || []; + + setPhotos(mappedPhotos); + } catch (error: unknown) { + logger.error('Failed to fetch photos', { error: getErrorMessage(error), entityId, entityType }); + } finally { + setLoading(false); + } + }; const handleUploadClick = () => { if (!user) { @@ -67,14 +90,7 @@ export function EntityPhotoGallery({ const handleSubmissionComplete = () => { setShowUpload(false); - - // Invalidate all related caches - invalidateEntityPhotos(entityType, entityId); - invalidatePhotoCount(entityType, entityId); - invalidateHomepageData(); // Photos affect homepage stats - - // Also refetch local component (immediate UI update) - refetch(); + fetchPhotos(); // Refresh photos after submission }; const handlePhotoClick = (index: number) => { @@ -174,7 +190,7 @@ export function EntityPhotoGallery({ entityType={entityType} open={showManagement} onOpenChange={setShowManagement} - onUpdate={() => refetch()} + onUpdate={fetchPhotos} /> {/* Photo Grid */} diff --git a/src/components/upload/PhotoManagementDialog.tsx b/src/components/upload/PhotoManagementDialog.tsx index 726bac1e..7b3d38a2 100644 --- a/src/components/upload/PhotoManagementDialog.tsx +++ b/src/components/upload/PhotoManagementDialog.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react'; import { supabase } from '@/integrations/supabase/client'; -import { useEntityName } from '@/hooks/entities/useEntityName'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -61,9 +60,6 @@ export function PhotoManagementDialog({ const [photoToDelete, setPhotoToDelete] = useState(null); const [deleteReason, setDeleteReason] = useState(''); const { toast } = useToast(); - - // Fetch entity name once using cached hook (replaces 4 sequential direct queries) - const { data: entityName = 'Unknown' } = useEntityName(entityType, entityId); useEffect(() => { if (open) { @@ -110,6 +106,27 @@ export function PhotoManagementDialog({ const { data: { user } } = await supabase.auth.getUser(); if (!user) throw new Error('Not authenticated'); + // Fetch entity name from database based on entity type + let entityName = 'Unknown'; + + try { + if (entityType === 'park') { + const { data } = await supabase.from('parks').select('name').eq('id', entityId).single(); + if (data?.name) entityName = data.name; + } else if (entityType === 'ride') { + const { data } = await supabase.from('rides').select('name').eq('id', entityId).single(); + if (data?.name) entityName = data.name; + } else if (entityType === 'ride_model') { + const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).single(); + if (data?.name) entityName = data.name; + } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) { + const { data } = await supabase.from('companies').select('name').eq('id', entityId).single(); + if (data?.name) entityName = data.name; + } + } catch (err) { + logger.error('Failed to fetch entity name', { error: getErrorMessage(err), entityType, entityId }); + } + // Create content submission const { data: submission, error: submissionError } = await supabase .from('content_submissions') diff --git a/src/contexts/Auth0Provider.tsx b/src/contexts/Auth0Provider.tsx deleted file mode 100644 index 93f3c58f..00000000 --- a/src/contexts/Auth0Provider.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -} diff --git a/src/docs/API_PATTERNS.md b/src/docs/API_PATTERNS.md deleted file mode 100644 index 26e0aff7..00000000 --- a/src/docs/API_PATTERNS.md +++ /dev/null @@ -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', 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 -``` diff --git a/src/docs/CACHE_DEBUGGING.md b/src/docs/CACHE_DEBUGGING.md deleted file mode 100644 index 316b081f..00000000 --- a/src/docs/CACHE_DEBUGGING.md +++ /dev/null @@ -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) - -``` diff --git a/src/docs/CACHE_INVALIDATION_GUIDE.md b/src/docs/CACHE_INVALIDATION_GUIDE.md deleted file mode 100644 index 03e55c19..00000000 --- a/src/docs/CACHE_INVALIDATION_GUIDE.md +++ /dev/null @@ -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 }) => ( - - {children} - - ) - } - ); - - 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 diff --git a/src/docs/PRODUCTION_READY.md b/src/docs/PRODUCTION_READY.md deleted file mode 100644 index 254d96c0..00000000 --- a/src/docs/PRODUCTION_READY.md +++ /dev/null @@ -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) diff --git a/src/hooks/admin/useAuditLogs.ts b/src/hooks/admin/useAuditLogs.ts deleted file mode 100644 index f09a506a..00000000 --- a/src/hooks/admin/useAuditLogs.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/admin/useVersionAudit.ts b/src/hooks/admin/useVersionAudit.ts deleted file mode 100644 index e377996f..00000000 --- a/src/hooks/admin/useVersionAudit.ts +++ /dev/null @@ -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({ - 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, - }); -} diff --git a/src/hooks/blog/useBlogPost.ts b/src/hooks/blog/useBlogPost.ts deleted file mode 100644 index c1c24fcf..00000000 --- a/src/hooks/blog/useBlogPost.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/companies/useCompanyDetail.ts b/src/hooks/companies/useCompanyDetail.ts deleted file mode 100644 index 6cc7c0b8..00000000 --- a/src/hooks/companies/useCompanyDetail.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/companies/useCompanyParks.ts b/src/hooks/companies/useCompanyParks.ts deleted file mode 100644 index e243b216..00000000 --- a/src/hooks/companies/useCompanyParks.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/companies/useCompanyStatistics.ts b/src/hooks/companies/useCompanyStatistics.ts deleted file mode 100644 index 4e5d1835..00000000 --- a/src/hooks/companies/useCompanyStatistics.ts +++ /dev/null @@ -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 { - 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, - }); -} diff --git a/src/hooks/entities/useEntityName.ts b/src/hooks/entities/useEntityName.ts deleted file mode 100644 index 1ff3faff..00000000 --- a/src/hooks/entities/useEntityName.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/homepage/useHomepageClosed.ts b/src/hooks/homepage/useHomepageClosed.ts index 95a76ab0..49beb807 100644 --- a/src/hooks/homepage/useHomepageClosed.ts +++ b/src/hooks/homepage/useHomepageClosed.ts @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; -import { toDateOnly } from '@/lib/dateUtils'; export function useHomepageRecentlyClosedParks(enabled = true) { return useQuery({ @@ -14,9 +13,9 @@ export function useHomepageRecentlyClosedParks(enabled = true) { const { data, error } = await supabase .from('parks') .select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`) - .gte('closing_date', toDateOnly(oneYearAgo)) - .lte('closing_date', toDateOnly(today)) - .order('closing_date', { ascending: false }) + .gte('closed_date', oneYearAgo.toISOString()) + .lte('closed_date', today.toISOString()) + .order('closed_date', { ascending: false }) .limit(12); if (error) throw error; @@ -39,10 +38,10 @@ export function useHomepageRecentlyClosedRides(enabled = true) { const { data, error } = await supabase .from('rides') - .select(`*, park:parks(*, location:locations(*))`) - .gte('closing_date', toDateOnly(oneYearAgo)) - .lte('closing_date', toDateOnly(today)) - .order('closing_date', { ascending: false }) + .select(`*, park:parks(*), location:locations(*)`) + .gte('closed_date', oneYearAgo.toISOString()) + .lte('closed_date', today.toISOString()) + .order('closed_date', { ascending: false }) .limit(12); if (error) throw error; diff --git a/src/hooks/homepage/useHomepageClosing.ts b/src/hooks/homepage/useHomepageClosing.ts index 438fa002..0b0ebb91 100644 --- a/src/hooks/homepage/useHomepageClosing.ts +++ b/src/hooks/homepage/useHomepageClosing.ts @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; -import { toDateOnly } from '@/lib/dateUtils'; export function useHomepageClosingSoonParks(enabled = true) { return useQuery({ @@ -14,9 +13,9 @@ export function useHomepageClosingSoonParks(enabled = true) { const { data, error } = await supabase .from('parks') .select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`) - .gte('closing_date', toDateOnly(today)) - .lte('closing_date', toDateOnly(sixMonthsFromNow)) - .order('closing_date', { ascending: true }) + .gte('closed_date', today.toISOString()) + .lte('closed_date', sixMonthsFromNow.toISOString()) + .order('closed_date', { ascending: true }) .limit(12); if (error) throw error; @@ -39,10 +38,10 @@ export function useHomepageClosingSoonRides(enabled = true) { const { data, error } = await supabase .from('rides') - .select(`*, park:parks(*, location:locations(*))`) - .gte('closing_date', toDateOnly(today)) - .lte('closing_date', toDateOnly(sixMonthsFromNow)) - .order('closing_date', { ascending: true }) + .select(`*, park:parks(*), location:locations(*)`) + .gte('closed_date', today.toISOString()) + .lte('closed_date', sixMonthsFromNow.toISOString()) + .order('closed_date', { ascending: true }) .limit(12); if (error) throw error; diff --git a/src/hooks/homepage/useHomepageOpened.ts b/src/hooks/homepage/useHomepageOpened.ts index 212290da..bf8f5a61 100644 --- a/src/hooks/homepage/useHomepageOpened.ts +++ b/src/hooks/homepage/useHomepageOpened.ts @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; -import { toDateOnly } from '@/lib/dateUtils'; export function useHomepageRecentlyOpenedParks(enabled = true) { return useQuery({ @@ -13,8 +12,8 @@ export function useHomepageRecentlyOpenedParks(enabled = true) { const { data, error } = await supabase .from('parks') .select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`) - .gte('opening_date', toDateOnly(oneYearAgo)) - .order('opening_date', { ascending: false }) + .gte('opened_date', oneYearAgo.toISOString()) + .order('opened_date', { ascending: false }) .limit(12); if (error) throw error; @@ -36,9 +35,9 @@ export function useHomepageRecentlyOpenedRides(enabled = true) { const { data, error } = await supabase .from('rides') - .select(`*, park:parks(*, location:locations(*))`) - .gte('opening_date', toDateOnly(oneYearAgo)) - .order('opening_date', { ascending: false }) + .select(`*, park:parks(*), location:locations(*)`) + .gte('opened_date', oneYearAgo.toISOString()) + .order('opened_date', { ascending: false }) .limit(12); if (error) throw error; diff --git a/src/hooks/homepage/useHomepageOpeningSoon.ts b/src/hooks/homepage/useHomepageOpeningSoon.ts index 5045c5d5..34a38262 100644 --- a/src/hooks/homepage/useHomepageOpeningSoon.ts +++ b/src/hooks/homepage/useHomepageOpeningSoon.ts @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; -import { toDateOnly } from '@/lib/dateUtils'; export function useHomepageOpeningSoonParks(enabled = true) { return useQuery({ @@ -14,9 +13,9 @@ export function useHomepageOpeningSoonParks(enabled = true) { const { data, error } = await supabase .from('parks') .select(`*, location:locations(*), operator:companies!parks_operator_id_fkey(*)`) - .gte('opening_date', toDateOnly(today)) - .lte('opening_date', toDateOnly(sixMonthsFromNow)) - .order('opening_date', { ascending: true }) + .gte('opened_date', today.toISOString()) + .lte('opened_date', sixMonthsFromNow.toISOString()) + .order('opened_date', { ascending: true }) .limit(12); if (error) throw error; @@ -39,10 +38,10 @@ export function useHomepageOpeningSoonRides(enabled = true) { const { data, error } = await supabase .from('rides') - .select(`*, park:parks(*, location:locations(*))`) - .gte('opening_date', toDateOnly(today)) - .lte('opening_date', toDateOnly(sixMonthsFromNow)) - .order('opening_date', { ascending: true }) + .select(`*, park:parks(*), location:locations(*)`) + .gte('opened_date', today.toISOString()) + .lte('opened_date', sixMonthsFromNow.toISOString()) + .order('opened_date', { ascending: true }) .limit(12); if (error) throw error; diff --git a/src/hooks/homepage/useHomepageRated.ts b/src/hooks/homepage/useHomepageRated.ts index 11e93071..67eb5001 100644 --- a/src/hooks/homepage/useHomepageRated.ts +++ b/src/hooks/homepage/useHomepageRated.ts @@ -29,7 +29,7 @@ export function useHomepageHighestRatedRides(enabled = true) { queryFn: async () => { const { data, error } = await supabase .from('rides') - .select(`*, park:parks(*, location:locations(*))`) + .select(`*, park:parks(*), location:locations(*)`) .not('average_rating', 'is', null) .order('average_rating', { ascending: false }) .limit(12); diff --git a/src/hooks/homepage/useHomepageRecent.ts b/src/hooks/homepage/useHomepageRecent.ts index 4a5303c5..8ae789b0 100644 --- a/src/hooks/homepage/useHomepageRecent.ts +++ b/src/hooks/homepage/useHomepageRecent.ts @@ -28,7 +28,7 @@ export function useHomepageRecentRides(enabled = true) { queryFn: async () => { const { data, error } = await supabase .from('rides') - .select(`*, park:parks(*, location:locations(*))`) + .select(`*, park:parks(*), location:locations(*)`) .order('created_at', { ascending: false }) .limit(12); diff --git a/src/hooks/homepage/useHomepageRecentChanges.ts b/src/hooks/homepage/useHomepageRecentChanges.ts index 702ccbd6..37e673d7 100644 --- a/src/hooks/homepage/useHomepageRecentChanges.ts +++ b/src/hooks/homepage/useHomepageRecentChanges.ts @@ -1,30 +1,4 @@ -/** - * Homepage Recent Changes Hook - * - * Fetches recent entity changes (parks, rides, companies) for homepage display. - * Uses optimized RPC function for single-query fetch of all data. - * - * Features: - * - Fetches up to 24 recent changes - * - Includes entity details, change metadata, and user info - * - Single database query via RPC - * - 5 minute cache for homepage performance - * - Performance monitoring - * - * @param enabled - Whether the query should run (default: true) - * @returns Array of recent changes with full entity context - * - * @example - * ```tsx - * const { data: changes, isLoading } = useHomepageRecentChanges(); - * - * changes?.forEach(change => { - * console.log(`${change.name} was ${change.changeType} by ${change.changedBy?.username}`); - * }); - * ``` - */ - -import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; @@ -44,35 +18,17 @@ interface RecentChange { changeReason?: string; } -export function useHomepageRecentChanges( - enabled = true -): UseQueryResult { +export function useHomepageRecentChanges(enabled = true) { return useQuery({ queryKey: queryKeys.homepage.recentChanges(), queryFn: async () => { - const startTime = performance.now(); - // Use the new database function to get all changes in a single query const { data, error } = await supabase.rpc('get_recent_changes', { limit_count: 24 }); if (error) throw error; - interface DatabaseRecentChange { - entity_id: string; - entity_name: string; - entity_type: string; - entity_slug: string; - park_slug?: string; - image_url?: string; - change_type: string; - changed_at: string; - changed_by_username?: string; - changed_by_avatar?: string; - change_reason?: string; - } - // Transform the database response to match our interface - const result: RecentChange[] = (data as unknown as DatabaseRecentChange[] || []).map((item) => ({ + return (data || []).map((item: any) => ({ id: item.entity_id, name: item.entity_name, type: item.entity_type as 'park' | 'ride' | 'company', @@ -86,17 +42,7 @@ export function useHomepageRecentChanges( avatarUrl: item.changed_by_avatar || undefined } : undefined, changeReason: item.change_reason || undefined - })); - - // Performance monitoring (dev only) - if (import.meta.env.DEV) { - const duration = performance.now() - startTime; - if (duration > 500) { - console.warn(`⚠️ Slow query: useHomepageRecentChanges took ${duration.toFixed(0)}ms`, { changeCount: result.length }); - } - } - - return result; + })) as RecentChange[]; }, enabled, staleTime: 5 * 60 * 1000, diff --git a/src/hooks/homepage/useHomepageTrending.ts b/src/hooks/homepage/useHomepageTrending.ts index a8f87498..bd9dabe8 100644 --- a/src/hooks/homepage/useHomepageTrending.ts +++ b/src/hooks/homepage/useHomepageTrending.ts @@ -28,7 +28,7 @@ export function useHomepageTrendingRides(enabled = true) { queryFn: async () => { const { data, error } = await supabase .from('rides') - .select(`*, park:parks(*, location:locations(*))`) + .select(`*, park:parks(*), location:locations(*)`) .order('view_count_30d', { ascending: false }) .limit(12); diff --git a/src/hooks/lists/useListItems.ts b/src/hooks/lists/useListItems.ts index 168b0384..67f3eff2 100644 --- a/src/hooks/lists/useListItems.ts +++ b/src/hooks/lists/useListItems.ts @@ -1,66 +1,14 @@ -import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; /** - * List Item with Entity Data + * Hook to fetch list items with entities (batch fetching to avoid N+1) */ -interface ListItemWithEntity { - id: string; - list_id: string; - entity_type: string; // Allow any string from DB - entity_id: string; - position: number; - notes: string; - created_at: string; - updated_at: string; - entity?: { - id: string; - name: string; - slug: string; - park_type?: string; - category?: string; - company_type?: string; - location_id?: string; - park_id?: string; - }; -} - -/** - * Fetch List Items Hook - * - * Fetches list items with their associated entities using optimized batch fetching. - * Prevents N+1 queries by grouping entity requests by type. - * - * Features: - * - Batch fetches parks, rides, and companies in parallel - * - Caches results for 5 minutes (staleTime) - * - Background refetch every 15 minutes (gcTime) - * - Type-safe entity data - * - Performance monitoring in dev mode - * - * @param listId - UUID of the list to fetch items for - * @param enabled - Whether the query should run (default: true) - * @returns TanStack Query result with array of list items - * - * @example - * ```tsx - * const { data: items, isLoading } = useListItems(listId); - * - * items?.forEach(item => { - * console.log(item.entity?.name); // Entity data is pre-loaded - * }); - * ``` - */ -export function useListItems( - listId: string | undefined, - enabled = true -): UseQueryResult { +export function useListItems(listId: string | undefined, enabled = true) { return useQuery({ queryKey: queryKeys.lists.items(listId || ''), queryFn: async () => { - const startTime = performance.now(); - if (!listId) return []; // Get items @@ -78,47 +26,30 @@ export function useListItems( const rideIds = items.filter(i => i.entity_type === 'ride').map(i => i.entity_id); const companyIds = items.filter(i => i.entity_type === 'company').map(i => i.entity_id); - // Batch fetch all entities in parallel with error handling + // Batch fetch all entities in parallel const [parksResult, ridesResult, companiesResult] = await Promise.all([ parkIds.length > 0 ? supabase.from('parks').select('id, name, slug, park_type, location_id').in('id', parkIds) - : Promise.resolve({ data: [], error: null }), + : Promise.resolve({ data: [] }), rideIds.length > 0 ? supabase.from('rides').select('id, name, slug, category, park_id').in('id', rideIds) - : Promise.resolve({ data: [], error: null }), + : Promise.resolve({ data: [] }), companyIds.length > 0 ? supabase.from('companies').select('id, name, slug, company_type').in('id', companyIds) - : Promise.resolve({ data: [], error: null }), + : Promise.resolve({ data: [] }), ]); - // Check for errors in batch fetches - if (parksResult.error) throw parksResult.error; - if (ridesResult.error) throw ridesResult.error; - if (companiesResult.error) throw companiesResult.error; - - // Create entities map for quick lookup (properly typed) - type EntityData = NonNullable; - const entitiesMap = new Map(); - - (parksResult.data || []).forEach(p => entitiesMap.set(p.id, p as EntityData)); - (ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r as EntityData)); - (companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c as EntityData)); + // Create entities map for quick lookup + const entitiesMap = new Map(); + (parksResult.data || []).forEach(p => entitiesMap.set(p.id, p)); + (ridesResult.data || []).forEach(r => entitiesMap.set(r.id, r)); + (companiesResult.data || []).forEach(c => entitiesMap.set(c.id, c)); // Map entities to items - const result = items.map(item => ({ + return items.map(item => ({ ...item, entity: entitiesMap.get(item.entity_id), })); - - // Performance monitoring (dev only) - if (import.meta.env.DEV) { - const duration = performance.now() - startTime; - if (duration > 1000) { - console.warn(`⚠️ Slow query: useListItems took ${duration.toFixed(0)}ms`, { listId, itemCount: items.length }); - } - } - - return result; }, enabled: enabled && !!listId, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/src/hooks/lists/useUserLists.ts b/src/hooks/lists/useUserLists.ts deleted file mode 100644 index 59b435ff..00000000 --- a/src/hooks/lists/useUserLists.ts +++ /dev/null @@ -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({ - 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, - }); -} diff --git a/src/hooks/moderation/useModerationActions.ts b/src/hooks/moderation/useModerationActions.ts index f5e732c2..016b7a9f 100644 --- a/src/hooks/moderation/useModerationActions.ts +++ b/src/hooks/moderation/useModerationActions.ts @@ -7,7 +7,6 @@ import { validateMultipleItems } from '@/lib/entityValidationSchemas'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import type { User } from '@supabase/supabase-js'; import type { ModerationItem } from '@/types/moderation'; -import { useQueryInvalidation } from '@/lib/queryInvalidation'; /** * Configuration for moderation actions @@ -30,42 +29,15 @@ export interface ModerationActions { } /** - * Moderation Actions Hook + * Hook for moderation action handlers + * Extracted from useModerationQueueManager for better separation of concerns * - * Provides functions for performing moderation actions on content submissions. - * Handles approval, rejection, deletion, and retry operations with proper - * cache invalidation and audit logging. - * - * Features: - * - Photo submission processing - * - Submission item validation - * - Selective approval via edge function - * - Comprehensive error handling - * - Cache invalidation for affected entities - * - Audit trail logging - * - Performance monitoring - * - * @param config - Configuration with user, callbacks, and lock state + * @param config - Configuration object with user, callbacks, and dependencies * @returns Object with action handler functions - * - * @example - * ```tsx - * const actions = useModerationActions({ - * user, - * onActionStart: (id) => console.log('Starting:', id), - * onActionComplete: () => refetch(), - * currentLockSubmissionId: lockedId - * }); - * - * await actions.performAction(item, 'approved', 'Looks good!'); - * ``` */ export function useModerationActions(config: ModerationActionsConfig): ModerationActions { const { user, onActionStart, onActionComplete } = config; const { toast } = useToast(); - - // Cache invalidation for moderation and affected entities - const invalidation = useQueryInvalidation(); /** * Perform moderation action (approve/reject) @@ -291,30 +263,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio description: `The ${item.type} has been ${action}`, }); - // Invalidate specific entity caches based on submission type - if (action === 'approved') { - if (item.submission_type === 'photo' && item.content) { - const entityType = item.content.entity_type as string; - const entityId = item.content.entity_id as string; - if (entityType && entityId) { - invalidation.invalidateEntityPhotos(entityType, entityId); - invalidation.invalidatePhotoCount(entityType, entityId); - } - } else if (item.submission_type === 'park') { - invalidation.invalidateParks(); - invalidation.invalidateHomepageData('parks'); - } else if (item.submission_type === 'ride') { - invalidation.invalidateRides(); - invalidation.invalidateHomepageData('rides'); - } else if (item.submission_type === 'company') { - invalidation.invalidateHomepageData('all'); - } - } - - // Always invalidate moderation queue - invalidation.invalidateModerationQueue(); - invalidation.invalidateModerationStats(); - logger.log(`✅ Action ${action} completed for ${item.id}`); } catch (error: unknown) { logger.error('❌ Error performing action:', { error: getErrorMessage(error) }); diff --git a/src/hooks/moderation/usePhotoSubmission.ts b/src/hooks/moderation/usePhotoSubmission.ts deleted file mode 100644 index 4291d0bc..00000000 --- a/src/hooks/moderation/usePhotoSubmission.ts +++ /dev/null @@ -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 ; - * } - * ``` - */ - -export function usePhotoSubmission(submissionId?: string) { - return useQuery({ - 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, - }); -} diff --git a/src/hooks/moderation/useRecentActivity.ts b/src/hooks/moderation/useRecentActivity.ts deleted file mode 100644 index ba04c512..00000000 --- a/src/hooks/moderation/useRecentActivity.ts +++ /dev/null @@ -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: - * - * ``` - */ - -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({ - 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(); - 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(); - 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, - }); -} diff --git a/src/hooks/pagination/usePrefetchNextPage.ts b/src/hooks/pagination/usePrefetchNextPage.ts deleted file mode 100644 index 280f6d46..00000000 --- a/src/hooks/pagination/usePrefetchNextPage.ts +++ /dev/null @@ -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( - baseQueryKey: readonly unknown[], - currentPage: number, - hasNextPage: boolean, - queryFn: (page: number) => Promise -) { - 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]); -} diff --git a/src/hooks/photos/useEntityPhotos.ts b/src/hooks/photos/useEntityPhotos.ts deleted file mode 100644 index d9af0f19..00000000 --- a/src/hooks/photos/useEntityPhotos.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/src/hooks/privacy/useBlockUserMutation.ts b/src/hooks/privacy/useBlockUserMutation.ts deleted file mode 100644 index 0b16e16a..00000000 --- a/src/hooks/privacy/useBlockUserMutation.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/hooks/privacy/useBlockedUsers.ts b/src/hooks/privacy/useBlockedUsers.ts deleted file mode 100644 index 938f4acb..00000000 --- a/src/hooks/privacy/useBlockedUsers.ts +++ /dev/null @@ -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 - }); -} diff --git a/src/hooks/privacy/usePrivacyMutations.ts b/src/hooks/privacy/usePrivacyMutations.ts deleted file mode 100644 index 4f2695ff..00000000 --- a/src/hooks/privacy/usePrivacyMutations.ts +++ /dev/null @@ -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', user?.id]); - - // Optimistically update cache - if (previousProfile) { - queryClient.setQueryData(['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, - }; -} diff --git a/src/hooks/profile/useProfileActivity.ts b/src/hooks/profile/useProfileActivity.ts deleted file mode 100644 index beb350e7..00000000 --- a/src/hooks/profile/useProfileActivity.ts +++ /dev/null @@ -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 { - 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( - photoSubs.map(ps => [ps.submission_id, ps as PhotoSubmissionData]) - ); - - const photoItemsMap = new Map(); - 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([ - ...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, - }); -} diff --git a/src/hooks/profile/useProfileLocationMutation.ts b/src/hooks/profile/useProfileLocationMutation.ts deleted file mode 100644 index cc27607f..00000000 --- a/src/hooks/profile/useProfileLocationMutation.ts +++ /dev/null @@ -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', user?.id]); - - // Optimistically update cache - if (previousProfile) { - queryClient.setQueryData(['profile', user?.id], (old) => - old ? { - ...old, - personal_location: newData.personal_location, - home_park_id: newData.home_park_id, - timezone: newData.timezone, - preferred_language: newData.preferred_language, - preferred_pronouns: newData.preferred_pronouns, - } : old - ); - } - - return { previousProfile }; - }, - 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, - }; -} diff --git a/src/hooks/profile/useProfileStats.ts b/src/hooks/profile/useProfileStats.ts deleted file mode 100644 index 562faa54..00000000 --- a/src/hooks/profile/useProfileStats.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/profile/useProfileUpdateMutation.ts b/src/hooks/profile/useProfileUpdateMutation.ts deleted file mode 100644 index fd1072b4..00000000 --- a/src/hooks/profile/useProfileUpdateMutation.ts +++ /dev/null @@ -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', userId]); - - // Optimistically update - queryClient.setQueryData(['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.", - }); - }, - }); -} diff --git a/src/hooks/reports/useReportActionMutation.ts b/src/hooks/reports/useReportActionMutation.ts deleted file mode 100644 index b5ba573f..00000000 --- a/src/hooks/reports/useReportActionMutation.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/hooks/reports/useReportMutation.ts b/src/hooks/reports/useReportMutation.ts deleted file mode 100644 index f01263e1..00000000 --- a/src/hooks/reports/useReportMutation.ts +++ /dev/null @@ -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), - }); - }, - }); -} diff --git a/src/hooks/reviews/useEntityReviews.ts b/src/hooks/reviews/useEntityReviews.ts index feee9e89..b39483bf 100644 --- a/src/hooks/reviews/useEntityReviews.ts +++ b/src/hooks/reviews/useEntityReviews.ts @@ -1,20 +1,12 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { queryKeys } from '@/lib/queryKeys'; /** * Hook to fetch reviews for a specific entity (park or ride) */ -export function useEntityReviews( - entityType: 'park' | 'ride', - entityId: string | undefined, - enabled = true, - enableRealtime = false // New parameter for opt-in real-time updates -) { - const queryClient = useQueryClient(); - - const query = useQuery({ +export function useEntityReviews(entityType: 'park' | 'ride', entityId: string | undefined, enabled = true) { + return useQuery({ queryKey: queryKeys.reviews.entity(entityType, entityId || ''), queryFn: async () => { if (!entityId) return []; @@ -43,34 +35,4 @@ export function useEntityReviews( gcTime: 10 * 60 * 1000, refetchOnWindowFocus: false, }); - - // Real-time subscription for new reviews (opt-in) - useEffect(() => { - if (!enableRealtime || !entityId || !enabled) return; - - const channel = supabase - .channel(`reviews-${entityType}-${entityId}`) - .on( - 'postgres_changes', - { - event: 'INSERT', - schema: 'public', - table: 'reviews', - filter: `${entityType}_id=eq.${entityId},moderation_status=eq.approved`, - }, - (payload) => { - console.log('⭐ New review posted:', payload.new); - queryClient.invalidateQueries({ - queryKey: queryKeys.reviews.entity(entityType, entityId) - }); - } - ) - .subscribe(); - - return () => { - supabase.removeChannel(channel); - }; - }, [enableRealtime, entityType, entityId, enabled, queryClient]); - - return query; } diff --git a/src/hooks/rideModels/useModelRides.ts b/src/hooks/rideModels/useModelRides.ts deleted file mode 100644 index 1f9eb381..00000000 --- a/src/hooks/rideModels/useModelRides.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/rideModels/useModelStatistics.ts b/src/hooks/rideModels/useModelStatistics.ts deleted file mode 100644 index 86066f6e..00000000 --- a/src/hooks/rideModels/useModelStatistics.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/rideModels/useRideModelDetail.ts b/src/hooks/rideModels/useRideModelDetail.ts deleted file mode 100644 index a15d19db..00000000 --- a/src/hooks/rideModels/useRideModelDetail.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/rides/useRideCreditsMutation.ts b/src/hooks/rides/useRideCreditsMutation.ts deleted file mode 100644 index bcdab986..00000000 --- a/src/hooks/rides/useRideCreditsMutation.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/hooks/security/useEmailChangeMutation.ts b/src/hooks/security/useEmailChangeMutation.ts deleted file mode 100644 index 5facf163..00000000 --- a/src/hooks/security/useEmailChangeMutation.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/hooks/security/useEmailChangeStatus.ts b/src/hooks/security/useEmailChangeStatus.ts deleted file mode 100644 index 582dc5c6..00000000 --- a/src/hooks/security/useEmailChangeStatus.ts +++ /dev/null @@ -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 - }); -} diff --git a/src/hooks/security/usePasswordUpdateMutation.ts b/src/hooks/security/usePasswordUpdateMutation.ts deleted file mode 100644 index 745422ac..00000000 --- a/src/hooks/security/usePasswordUpdateMutation.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/hooks/security/useSecurityMutations.ts b/src/hooks/security/useSecurityMutations.ts deleted file mode 100644 index 6ae389b1..00000000 --- a/src/hooks/security/useSecurityMutations.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/hooks/security/useSessions.ts b/src/hooks/security/useSessions.ts deleted file mode 100644 index a0288410..00000000 --- a/src/hooks/security/useSessions.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/hooks/useAdminSettings.ts b/src/hooks/useAdminSettings.ts index 6247755e..fbcc2e52 100644 --- a/src/hooks/useAdminSettings.ts +++ b/src/hooks/useAdminSettings.ts @@ -4,7 +4,6 @@ import { useAuth } from './useAuth'; import { useUserRole } from './useUserRole'; import { useToast } from './use-toast'; import { useCallback, useMemo } from 'react'; -import { queryKeys } from '@/lib/queryKeys'; interface AdminSetting { id: string; @@ -25,7 +24,7 @@ export function useAdminSettings() { isLoading, error } = useQuery({ - queryKey: queryKeys.admin.settings(), + queryKey: ['admin-settings'], queryFn: async () => { const { data, error } = await supabase .from('admin_settings') @@ -60,7 +59,7 @@ export function useAdminSettings() { if (error) throw error; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.settings() }); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); toast({ title: "Setting Updated", description: "The setting has been saved successfully.", diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index c722ff09..49a113cc 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -15,7 +15,7 @@ interface AuthContextType { loading: boolean; pendingEmail: string | null; sessionError: string | null; - signOut: (scope?: 'global' | 'local' | 'others') => Promise; + signOut: () => Promise; verifySession: () => Promise; clearPendingEmail: () => void; checkAalStepUp: () => Promise; @@ -123,24 +123,6 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { await supabase.auth.signOut(); return; } - - // Enhanced session monitoring: Proactively refresh tokens before expiry - const expiresAt = session.expires_at; - if (expiresAt) { - const now = Math.floor(Date.now() / 1000); - const timeUntilExpiry = expiresAt - now; - - // Refresh 5 minutes (300 seconds) before expiry - if (timeUntilExpiry < 300 && timeUntilExpiry > 0) { - authLog('[Auth] Token expiring soon, refreshing session...'); - const { error } = await supabase.auth.refreshSession(); - if (error) { - authError('[Auth] Session refresh failed:', error); - } else { - authLog('[Auth] Session refreshed successfully'); - } - } - } } else { setAal(null); } @@ -236,23 +218,12 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { }; }, []); - const signOut = async (scope: 'global' | 'local' | 'others' = 'global') => { - authLog('[Auth] Signing out with scope:', scope); - - try { - const { error } = await supabase.auth.signOut({ scope }); - - if (error) throw error; - - // Clear all auth flags (only on global/local sign out) - if (scope !== 'others') { - clearAllAuthFlags(); - } - - authLog('[Auth] Sign out successful'); - } catch (error) { - authError('[Auth] Error signing out:', error); - throw error; + const signOut = async () => { + authLog('[Auth] Signing out...'); + const result = await signOutUser(); + if (!result.success) { + authError('Error signing out:', result.error); + throw new Error(result.error); } }; diff --git a/src/hooks/useAvatarUpload.ts b/src/hooks/useAvatarUpload.ts index a5048722..b6467065 100644 --- a/src/hooks/useAvatarUpload.ts +++ b/src/hooks/useAvatarUpload.ts @@ -1,8 +1,6 @@ import { useState, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { handleError, handleSuccess } from '@/lib/errorHandler'; -import { useQueryInvalidation } from '@/lib/queryInvalidation'; -import { useAuth } from '@/hooks/useAuth'; export type AvatarUploadState = { url: string; @@ -15,8 +13,6 @@ export const useAvatarUpload = ( initialImageId: string = '', username: string ) => { - const { user } = useAuth(); - const { invalidateUserProfile } = useQueryInvalidation(); const [state, setState] = useState({ url: initialUrl, imageId: initialImageId, @@ -52,11 +48,6 @@ export const useAvatarUpload = ( setState(prev => ({ ...prev, isUploading: false })); handleSuccess('Avatar updated', 'Your avatar has been successfully updated.'); - // Invalidate user profile cache for instant UI update - if (user?.id) { - invalidateUserProfile(user.id); - } - return { success: true }; } catch (error: unknown) { // Rollback on error @@ -73,7 +64,7 @@ export const useAvatarUpload = ( return { success: false, error }; } - }, [username, initialUrl, initialImageId, user?.id, invalidateUserProfile]); + }, [username, initialUrl, initialImageId]); const resetAvatar = useCallback(() => { setState({ diff --git a/src/hooks/useCoasterStats.ts b/src/hooks/useCoasterStats.ts index 28ca6c54..83503b0b 100644 --- a/src/hooks/useCoasterStats.ts +++ b/src/hooks/useCoasterStats.ts @@ -1,6 +1,5 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; -import { queryKeys } from '@/lib/queryKeys'; export interface CoasterStat { id: string; @@ -16,7 +15,7 @@ export interface CoasterStat { export function useCoasterStats(rideId: string | undefined) { return useQuery({ - queryKey: queryKeys.stats.coaster(rideId || ''), + queryKey: ['coaster-stats', rideId], queryFn: async () => { if (!rideId) return []; diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts index c3bdc75e..471ee430 100644 --- a/src/hooks/useEntityVersions.ts +++ b/src/hooks/useEntityVersions.ts @@ -93,14 +93,7 @@ export function useEntityVersions(entityType: EntityType, entityId: string) { return; } - interface DatabaseVersion { - profiles?: { - username?: string; - display_name?: string; - }; - } - - const versionsWithProfiles = (data as DatabaseVersion[] || []).map((v) => ({ + const versionsWithProfiles = (data || []).map((v: any) => ({ ...v, profiles: v.profiles || { username: 'Unknown', diff --git a/src/hooks/usePublicNovuSettings.ts b/src/hooks/usePublicNovuSettings.ts index a6592f4b..7711e181 100644 --- a/src/hooks/usePublicNovuSettings.ts +++ b/src/hooks/usePublicNovuSettings.ts @@ -1,13 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; -import { queryKeys } from '@/lib/queryKeys'; /** * Hook to fetch public Novu settings accessible to all authenticated users */ export function usePublicNovuSettings() { const { data: settings, isLoading, error } = useQuery({ - queryKey: queryKeys.settings.publicNovu(), + queryKey: ['public-novu-settings'], queryFn: async () => { const { data, error } = await supabase .from('admin_settings') diff --git a/src/hooks/useRequireMFA.ts b/src/hooks/useRequireMFA.ts index 31684df4..7ae841a2 100644 --- a/src/hooks/useRequireMFA.ts +++ b/src/hooks/useRequireMFA.ts @@ -43,7 +43,6 @@ export function useRequireMFA() { isEnrolled, needsEnrollment: requiresMFA && !isEnrolled, needsVerification, - isBlocked: requiresMFA && (!isEnrolled || (isEnrolled && aal === 'aal1')), // Convenience flag aal, loading: loading || roleLoading, }; diff --git a/src/hooks/useRideCreditFilters.ts b/src/hooks/useRideCreditFilters.ts index 7721e927..6de3dcdb 100644 --- a/src/hooks/useRideCreditFilters.ts +++ b/src/hooks/useRideCreditFilters.ts @@ -7,7 +7,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) { const [filters, setFilters] = useState({}); const debouncedSearchQuery = useDebounce(filters.searchQuery || '', 300); - const updateFilter = useCallback((key: keyof RideCreditFilters, value: RideCreditFilters[typeof key]) => { + const updateFilter = useCallback((key: keyof RideCreditFilters, value: any) => { setFilters(prev => ({ ...prev, [key]: value })); }, []); diff --git a/src/hooks/users/useRoleMutations.ts b/src/hooks/users/useRoleMutations.ts deleted file mode 100644 index 4b11f3d6..00000000 --- a/src/hooks/users/useRoleMutations.ts +++ /dev/null @@ -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(queryKeys.users.roles()); - - // Optimistically update cache - add role to user - queryClient.setQueryData(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(queryKeys.users.roles()); - - // Optimistically remove role from cache - queryClient.setQueryData(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, - }; -} diff --git a/src/hooks/users/useUserRoles.ts b/src/hooks/users/useUserRoles.ts deleted file mode 100644 index cac09408..00000000 --- a/src/hooks/users/useUserRoles.ts +++ /dev/null @@ -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({ - 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, - }); -} diff --git a/src/hooks/users/useUserSearch.ts b/src/hooks/users/useUserSearch.ts deleted file mode 100644 index 26d38a20..00000000 --- a/src/hooks/users/useUserSearch.ts +++ /dev/null @@ -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 - * 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({ - 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, - }); -} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index a44d0ca5..3eb52a8d 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -63,7 +63,6 @@ export type Database = { Row: { action: string admin_user_id: string - auth0_event_type: string | null created_at: string details: Json | null id: string @@ -72,7 +71,6 @@ export type Database = { Insert: { action: string admin_user_id: string - auth0_event_type?: string | null created_at?: string details?: Json | null id?: string @@ -81,7 +79,6 @@ export type Database = { Update: { action?: string admin_user_id?: string - auth0_event_type?: string | null created_at?: string details?: Json | null id?: string @@ -122,57 +119,6 @@ export type Database = { } Relationships: [] } - auth0_sync_log: { - Row: { - auth0_sub: string - completed_at: string | null - created_at: string - error_message: string | null - id: string - metadata: Json | null - sync_status: string - sync_type: string - user_id: string | null - } - Insert: { - auth0_sub: string - completed_at?: string | null - created_at?: string - error_message?: string | null - id?: string - metadata?: Json | null - sync_status?: string - sync_type: string - user_id?: string | null - } - Update: { - auth0_sub?: string - completed_at?: string | null - created_at?: string - error_message?: string | null - id?: string - metadata?: Json | null - sync_status?: string - sync_type?: string - user_id?: string | null - } - Relationships: [ - { - foreignKeyName: "auth0_sync_log_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "filtered_profiles" - referencedColumns: ["user_id"] - }, - { - foreignKeyName: "auth0_sync_log_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "profiles" - referencedColumns: ["user_id"] - }, - ] - } blog_posts: { Row: { author_id: string @@ -2022,7 +1968,6 @@ export type Database = { } profiles: { Row: { - auth0_sub: string | null avatar_image_id: string | null avatar_url: string | null ban_expires_at: string | null @@ -2056,7 +2001,6 @@ export type Database = { username: string } Insert: { - auth0_sub?: string | null avatar_image_id?: string | null avatar_url?: string | null ban_expires_at?: string | null @@ -2090,7 +2034,6 @@ export type Database = { username: string } Update: { - auth0_sub?: string | null avatar_image_id?: string | null avatar_url?: string | null ban_expires_at?: string | null @@ -4587,7 +4530,6 @@ export type Database = { Returns: undefined } backfill_sort_orders: { Args: never; Returns: undefined } - block_aal1_with_mfa: { Args: never; Returns: boolean } can_approve_submission_item: { Args: { item_id: string } Returns: boolean @@ -4659,8 +4601,6 @@ export type Database = { extract_cf_image_id: { Args: { url: string }; Returns: string } generate_deletion_confirmation_code: { Args: never; Returns: string } generate_ticket_number: { Args: never; Returns: string } - get_auth0_sub_from_jwt: { Args: never; Returns: string } - get_current_user_id: { Args: never; Returns: string } get_email_change_status: { Args: never; Returns: Json } get_filtered_profile: { Args: { _profile_user_id: string; _viewer_id?: string } @@ -4729,7 +4669,6 @@ export type Database = { Returns: Json } has_aal2: { Args: never; Returns: boolean } - has_auth0_mfa: { Args: never; Returns: boolean } has_mfa_enabled: { Args: { _user_id: string }; Returns: boolean } has_pending_dependents: { Args: { item_id: string }; Returns: boolean } has_role: { @@ -4745,7 +4684,6 @@ export type Database = { Args: { post_slug: string } Returns: undefined } - is_auth0_user: { Args: never; Returns: boolean } is_moderator: { Args: { _user_id: string }; Returns: boolean } is_superuser: { Args: { _user_id: string }; Returns: boolean } is_user_banned: { Args: { _user_id: string }; Returns: boolean } diff --git a/src/lib/auth0Config.ts b/src/lib/auth0Config.ts deleted file mode 100644 index 035efacf..00000000 --- a/src/lib/auth0Config.ts +++ /dev/null @@ -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/`; -} diff --git a/src/lib/auth0Management.ts b/src/lib/auth0Management.ts deleted file mode 100644 index e390adc5..00000000 --- a/src/lib/auth0Management.ts +++ /dev/null @@ -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 { - const { data, error } = await supabase.functions.invoke( - '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 { - 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 { - 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 -): Promise { - 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 { - 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`; -} diff --git a/src/lib/cacheMonitoring.ts b/src/lib/cacheMonitoring.ts deleted file mode 100644 index 01eb7514..00000000 --- a/src/lib/cacheMonitoring.ts +++ /dev/null @@ -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; - 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 { - 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 ( - *
- *

Cache Stats

- *

Hit Rate: {(metrics.hitRate * 100).toFixed(1)}%

- *

Avg Query Time: {Math.round(metrics.avgQueryTime)}ms

- *
- * ); - * } - */ -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 -} diff --git a/src/lib/identityService.ts b/src/lib/identityService.ts index 6fdaa6e8..3d185bdb 100644 --- a/src/lib/identityService.ts +++ b/src/lib/identityService.ts @@ -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 { - 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 { - 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 diff --git a/src/lib/queryInvalidation.ts b/src/lib/queryInvalidation.ts index 7be30724..e3601461 100644 --- a/src/lib/queryInvalidation.ts +++ b/src/lib/queryInvalidation.ts @@ -95,70 +95,6 @@ export function useQueryInvalidation() { } }, - /** - * Invalidate user profile cache - * Call this after profile updates - */ - invalidateUserProfile: (userId: string) => { - queryClient.invalidateQueries({ queryKey: queryKeys.profile.detail(userId) }); - }, - - /** - * Invalidate profile stats cache - * Call this after profile-related changes - */ - invalidateProfileStats: (userId: string) => { - queryClient.invalidateQueries({ queryKey: queryKeys.profile.stats(userId) }); - }, - - /** - * Invalidate profile activity cache - * Call this after activity changes - */ - invalidateProfileActivity: (userId: string) => { - queryClient.invalidateQueries({ queryKey: ['profile', 'activity', userId] }); - }, - - /** - * Invalidate user search results - * Call this when display names change - */ - invalidateUserSearch: () => { - queryClient.invalidateQueries({ queryKey: ['users', 'search'] }); - }, - - /** - * Invalidate admin settings cache - * Call this after updating admin settings - */ - invalidateAdminSettings: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.settings() }); - }, - - /** - * Invalidate audit logs cache - * Call this after inserting audit log entries - */ - invalidateAuditLogs: (userId?: string) => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.auditLogs(userId) }); - }, - - /** - * Invalidate contact submissions cache - * Call this after updating contact submissions - */ - invalidateContactSubmissions: () => { - queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); - }, - - /** - * Invalidate blog posts cache - * Call this after creating/updating/deleting blog posts - */ - invalidateBlogPosts: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.blogPosts() }); - }, - /** * Invalidate parks listing cache * Call this after creating/updating/deleting parks @@ -250,147 +186,5 @@ export function useQueryInvalidation() { queryKey: ['homepage', 'featured-parks'] }); }, - - /** - * Invalidate company detail cache - * Call this after updating a company - */ - invalidateCompanyDetail: (slug: string, type: string) => { - queryClient.invalidateQueries({ queryKey: queryKeys.companies.detail(slug, type) }); - }, - - /** - * Invalidate company statistics cache - * Call this after changes affecting company stats - */ - invalidateCompanyStatistics: (id: string, type: string) => { - queryClient.invalidateQueries({ queryKey: queryKeys.companies.statistics(id, type) }); - }, - - /** - * Invalidate company parks cache - * Call this after park changes - */ - invalidateCompanyParks: (id: string, type: 'operator' | 'property_owner') => { - queryClient.invalidateQueries({ queryKey: ['companies', 'parks', id, type] }); - }, - - /** - * Invalidate ride model detail cache - * Call this after updating a ride model - */ - invalidateRideModelDetail: (manufacturerSlug: string, modelSlug: string) => { - queryClient.invalidateQueries({ queryKey: queryKeys.rideModels.detail(manufacturerSlug, modelSlug) }); - }, - - /** - * Invalidate ride model statistics cache - * Call this after changes affecting model stats - */ - invalidateRideModelStatistics: (modelId: string) => { - queryClient.invalidateQueries({ queryKey: queryKeys.rideModels.statistics(modelId) }); - }, - - /** - * Invalidate model rides cache - * Call this after ride changes - */ - invalidateModelRides: (modelId: string, limit?: number) => { - queryClient.invalidateQueries({ - queryKey: queryKeys.rideModels.rides(modelId, limit), - }); - }, - - /** - * Invalidate entity name cache - * Call this after updating an entity's name - */ - invalidateEntityName: (entityType: string, entityId: string) => { - queryClient.invalidateQueries({ - queryKey: queryKeys.entities.name(entityType, entityId) - }); - }, - - /** - * Invalidate blog post cache - * Call this after updating a blog post - */ - invalidateBlogPost: (slug: string) => { - queryClient.invalidateQueries({ - queryKey: queryKeys.blog.post(slug) - }); - }, - - /** - * Invalidate coaster stats cache - * Call this after updating ride statistics - */ - invalidateCoasterStats: (rideId: string) => { - queryClient.invalidateQueries({ - queryKey: queryKeys.stats.coaster(rideId) - }); - }, - - /** - * Invalidate email change status cache - * Call this after email change operations - */ - invalidateEmailChangeStatus: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.security.emailChangeStatus() - }); - }, - - /** - * Invalidate sessions cache - * Call this after session operations (login, logout, revoke) - */ - invalidateSessions: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.security.sessions() - }); - }, - - /** - * Invalidate security queries - * Call this after security-related changes (email, sessions) - */ - invalidateSecurityQueries: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.security.emailChangeStatus() - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.security.sessions() - }); - }, - - /** - * Smart invalidation for related entities - * Invalidates entity detail, photos, reviews, and name cache - * Call this after any entity update - */ - invalidateRelatedEntities: (entityType: string, entityId: string) => { - // Invalidate the entity itself - if (entityType === 'park') { - queryClient.invalidateQueries({ - queryKey: queryKeys.parks.detail(entityId) - }); - } else if (entityType === 'ride') { - queryClient.invalidateQueries({ - queryKey: queryKeys.rides.detail('', entityId) - }); - } - - // Invalidate photos, reviews, and entity name - queryClient.invalidateQueries({ - queryKey: queryKeys.photos.entity(entityType, entityId) - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.reviews.entity(entityType as 'park' | 'ride', entityId) - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.entities.name(entityType, entityId) - }); - }, }; } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 291164d4..184ebb40 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -62,8 +62,8 @@ export const queryKeys = { // Photos queries photos: { - entity: (entityType: string, entityId: string, sortBy?: string) => - ['photos', entityType, entityId, sortBy] as const, + entity: (entityType: string, entityId: string) => + ['photos', entityType, entityId] as const, count: (entityType: string, entityId: string) => ['photos', 'count', entityType, entityId] as const, }, @@ -76,81 +76,5 @@ export const queryKeys = { // Lists queries lists: { items: (listId: string) => ['list-items', listId] as const, - user: (userId?: string) => ['lists', 'user', userId] as const, - }, - - // Users queries - users: { - roles: (userId?: string) => ['users', 'roles', userId] as const, - search: (searchTerm: string) => ['users', 'search', searchTerm] as const, - }, - - // Admin queries - admin: { - versionAudit: ['admin', 'version-audit'] as const, - settings: () => ['admin-settings'] as const, - blogPosts: () => ['admin-blog-posts'] as const, - contactSubmissions: (statusFilter?: string, categoryFilter?: string, searchQuery?: string, showArchived?: boolean) => - ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived] as const, - auditLogs: (userId?: string) => ['admin', 'audit-logs', userId] as const, - }, - - // Moderation queries - moderation: { - photoSubmission: (submissionId?: string) => ['moderation', 'photo-submission', submissionId] as const, - recentActivity: ['moderation', 'recent-activity'] as const, - }, - - // Company queries - companies: { - all: (type: string) => ['companies', 'all', type] as const, - detail: (slug: string, type: string) => ['companies', 'detail', slug, type] as const, - statistics: (id: string, type: string) => ['companies', 'statistics', id, type] as const, - parks: (id: string, type: string, limit: number) => ['companies', 'parks', id, type, limit] as const, - }, - - // Profile queries - profile: { - detail: (userId: string) => ['profile', userId] as const, - activity: (userId: string, isOwn: boolean, isMod: boolean) => - ['profile', 'activity', userId, isOwn, isMod] as const, - stats: (userId: string) => ['profile', 'stats', userId] as const, - }, - - // Ride Models queries - rideModels: { - all: (manufacturerId: string) => ['ride-models', 'all', manufacturerId] as const, - detail: (manufacturerSlug: string, modelSlug: string) => - ['ride-models', 'detail', manufacturerSlug, modelSlug] as const, - rides: (modelId: string, limit?: number) => - ['ride-models', 'rides', modelId, limit] as const, - statistics: (modelId: string) => ['ride-models', 'statistics', modelId] as const, - }, - - // Settings queries - settings: { - publicNovu: () => ['public-novu-settings'] as const, - }, - - // Stats queries - stats: { - coaster: (rideId: string) => ['coaster-stats', rideId] as const, - }, - - // Blog queries - blog: { - post: (slug: string) => ['blog-post', slug] as const, - viewIncrement: (slug: string) => ['blog-view-increment', slug] as const, - }, - - // Entity name queries (for PhotoManagementDialog) - entities: { - name: (entityType: string, entityId: string) => ['entity-name', entityType, entityId] as const, - }, - - // Security queries - security: { - emailChangeStatus: () => ['email-change-status'] as const, - sessions: () => ['my-sessions'] as const, }, } as const; diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 7c206418..72d7ed8c 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -14,7 +14,7 @@ import { ReportsQueue } from '@/components/moderation/ReportsQueue'; import { RecentActivity } from '@/components/moderation/RecentActivity'; import { useModerationStats } from '@/hooks/useModerationStats'; import { useAdminSettings } from '@/hooks/useAdminSettings'; -import { useVersionAudit } from '@/hooks/admin/useVersionAudit'; +import { supabase } from '@/integrations/supabase/client'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; import { QueueSkeleton } from '@/components/moderation/QueueSkeleton'; @@ -24,13 +24,11 @@ export default function AdminDashboard() { useDocumentTitle('Dashboard - Admin'); const { user, loading: authLoading } = useAuth(); const { isModerator, loading: roleLoading } = useUserRole(); - const { needsEnrollment, needsVerification, loading: mfaLoading } = useRequireMFA(); + const { needsEnrollment, loading: mfaLoading } = useRequireMFA(); const navigate = useNavigate(); const [isRefreshing, setIsRefreshing] = useState(false); const [activeTab, setActiveTab] = useState('moderation'); - - const { data: versionAudit } = useVersionAudit(); - const suspiciousVersionsCount = versionAudit?.totalCount || 0; + const [suspiciousVersionsCount, setSuspiciousVersionsCount] = useState(0); const moderationQueueRef = useRef(null); const reportsQueueRef = useRef(null); @@ -50,9 +48,32 @@ export default function AdminDashboard() { pollingInterval: pollInterval, }); + // Check for suspicious versions (bypassed submission flow) + const checkSuspiciousVersions = useCallback(async () => { + if (!user || !isModerator()) return; + + // Query all version tables for suspicious entries (no changed_by) + const queries = [ + 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), + ]; + + const results = await Promise.all(queries); + const totalCount = results.reduce((sum, result) => sum + (result.count || 0), 0); + + setSuspiciousVersionsCount(totalCount); + }, [user, isModerator]); + + useEffect(() => { + checkSuspiciousVersions(); + }, [checkSuspiciousVersions]); + const handleRefresh = useCallback(async () => { setIsRefreshing(true); await refreshStats(); + await checkSuspiciousVersions(); // Refresh active tab's content switch (activeTab) { @@ -68,7 +89,7 @@ export default function AdminDashboard() { } setTimeout(() => setIsRefreshing(false), 500); - }, [refreshStats, activeTab]); + }, [refreshStats, checkSuspiciousVersions, activeTab]); const handleStatCardClick = (cardType: 'submissions' | 'reports' | 'flagged') => { switch (cardType) { @@ -138,8 +159,8 @@ export default function AdminDashboard() { return null; } - // MFA enforcement - CRITICAL: Block if EITHER not enrolled OR needs verification - if (needsEnrollment || needsVerification) { + // MFA enforcement + if (needsEnrollment) { return ( diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index 73b72454..ce460ae8 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -8,9 +8,8 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Separator } from '@/components/ui/separator'; -import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff, Shield } from 'lucide-react'; +import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { getErrorMessage } from '@/lib/errorHandler'; @@ -37,9 +36,6 @@ export default function Auth() { const [signInCaptchaToken, setSignInCaptchaToken] = useState(null); const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); const [mfaFactorId, setMfaFactorId] = useState(null); - const [mfaPendingEmail, setMfaPendingEmail] = useState(null); - const [mfaChallengeId, setMfaChallengeId] = useState(null); - const [mfaPendingUserId, setMfaPendingUserId] = useState(null); const emailParam = searchParams.get('email'); const messageParam = searchParams.get('message'); @@ -95,65 +91,89 @@ export default function Auth() { setSignInCaptchaToken(null); try { - // Call server-side auth check with MFA detection - const { data: authResult, error: authError } = await supabase.functions.invoke( - 'auth-with-mfa-check', - { - body: { - email: formData.email, - password: formData.password, - captchaToken: tokenToUse, - }, + const { + data, + error + } = await supabase.auth.signInWithPassword({ + email: formData.email, + password: formData.password, + options: { + captchaToken: tokenToUse } - ); + }); + + if (error) throw error; - if (authError || authResult.error) { - throw new Error(authResult?.error || authError?.message || 'Authentication failed'); - } - - // Check if user is banned - if (authResult.banned) { - const reason = authResult.banReason - ? `Reason: ${authResult.banReason}` + // CRITICAL: Check ban status immediately after successful authentication + const { data: profile } = await supabase + .from('profiles') + .select('banned, ban_reason') + .eq('user_id', data.user.id) + .single(); + + if (profile?.banned) { + // Sign out immediately + await supabase.auth.signOut(); + + const reason = profile.ban_reason + ? `Reason: ${profile.ban_reason}` : 'Contact support for assistance.'; - + toast({ variant: "destructive", title: "Account Suspended", description: `Your account has been suspended. ${reason}`, - duration: 10000, + duration: 10000 }); setLoading(false); - return; + return; // Stop authentication flow } - // Check if MFA is required - if (authResult.mfaRequired) { - // NO SESSION EXISTS YET - show MFA challenge - console.log('[Auth] MFA required - no session created yet'); - setMfaFactorId(authResult.factorId); - setMfaChallengeId(authResult.challengeId); - setMfaPendingUserId(authResult.userId); - setLoading(false); - return; // User has NO session - MFA modal will show + // Check if MFA is required (user exists but no session) + if (data.user && !data.session) { + const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified'); + + if (totpFactor) { + setMfaFactorId(totpFactor.id); + setLoading(false); + return; + } } - - // No MFA required - user has session - console.log('[Auth] No MFA required - user authenticated'); - // Set the session in Supabase client - if (authResult.session) { - await supabase.auth.setSession(authResult.session); + // Track auth method for audit logging + setAuthMethod('password'); + + // Check if MFA step-up is required + const { handlePostAuthFlow } = await import('@/lib/authService'); + const postAuthResult = await handlePostAuthFlow(data.session, 'password'); + + if (postAuthResult.success && postAuthResult.data.shouldRedirect) { + // Get the TOTP factor ID + const { data: factors } = await supabase.auth.mfa.listFactors(); + const totpFactor = factors?.totp?.find(f => f.status === 'verified'); + + if (totpFactor) { + setMfaFactorId(totpFactor.id); + setLoading(false); + return; // Stay on page, show MFA modal + } } - - toast({ - title: "Welcome back!", - description: "You've been signed in successfully." - }); - - // Navigate after brief delay - setTimeout(() => { - navigate('/'); + + // Verify session was stored + setTimeout(async () => { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + toast({ + variant: "destructive", + title: "Session Error", + description: "Login succeeded but session was not stored. Please check your browser settings and enable cookies/storage." + }); + } else { + toast({ + title: "Welcome back!", + description: "You've been signed in successfully." + }); + } }, 500); } catch (error) { @@ -165,11 +185,11 @@ export default function Auth() { // Enhanced error messages const errorMsg = getErrorMessage(error); let errorMessage = errorMsg; - if (errorMsg.includes('Invalid login credentials') || errorMsg.includes('Invalid credentials')) { + if (errorMsg.includes('Invalid login credentials')) { errorMessage = 'Invalid email or password. Please try again.'; } else if (errorMsg.includes('Email not confirmed')) { errorMessage = 'Please confirm your email address before signing in.'; - } else if (errorMsg.includes('Too many requests')) { + } else if (error.message.includes('Too many requests')) { errorMessage = 'Too many login attempts. Please wait a few minutes and try again.'; } @@ -184,40 +204,33 @@ export default function Auth() { }; const handleMfaSuccess = async () => { - console.log('[Auth] MFA verification succeeded - no further action needed'); + // Verify AAL upgrade was successful + const { data: { session } } = await supabase.auth.getSession(); + const verification = await verifyMfaUpgrade(session); - // MFA verification is handled by MFAChallenge component - // which calls verify-mfa-and-login edge function - // The session is automatically set by the edge function + if (!verification.success) { + toast({ + variant: "destructive", + title: "MFA Verification Failed", + description: verification.error || "Failed to upgrade session. Please try again." + }); + + // Force sign out on verification failure + await supabase.auth.signOut(); + setMfaFactorId(null); + return; + } - // Clear state setMfaFactorId(null); - setMfaChallengeId(null); - setMfaPendingUserId(null); - toast({ - title: "Authentication complete", - description: "You've been signed in successfully.", + title: "Welcome back!", + description: "You've been signed in successfully." }); - - setTimeout(() => { - navigate('/'); - }, 500); }; - const handleMfaCancel = async () => { - console.log('[Auth] User cancelled MFA verification'); - - // Clear state - no credentials stored + const handleMfaCancel = () => { setMfaFactorId(null); - setMfaChallengeId(null); - setMfaPendingUserId(null); setSignInCaptchaKey(prev => prev + 1); - - toast({ - title: "Authentication cancelled", - description: "Please sign in again when you're ready to complete two-factor authentication.", - }); }; const handleSignUp = async (e: React.FormEvent) => { e.preventDefault(); @@ -268,7 +281,6 @@ export default function Auth() { email: formData.email, password: formData.password, options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, captchaToken: tokenToUse, data: { username: formData.username, @@ -415,35 +427,11 @@ export default function Auth() { )} {mfaFactorId ? ( - { - if (!isOpen) { - handleMfaCancel(); - } - }}> - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > - -
- - Two-Factor Authentication Required -
- - Your account security settings require MFA verification to continue. - -
- - -
-
+ ) : ( <>
diff --git a/src/pages/Auth0Callback.tsx b/src/pages/Auth0Callback.tsx deleted file mode 100644 index bb18a8ae..00000000 --- a/src/pages/Auth0Callback.tsx +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Auth0 Callback Page - * - * Handles Auth0 authentication callback and syncs user to Supabase - */ - -import { useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useAuth0 } from '@auth0/auth0-react'; -import { supabase } from '@/integrations/supabase/client'; -import { Header } from '@/components/layout/Header'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { Loader2, CheckCircle, XCircle, Shield } from 'lucide-react'; -import { useToast } from '@/hooks/use-toast'; - -type SyncStatus = 'processing' | 'success' | 'error'; - -export default function Auth0Callback() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { isAuthenticated, isLoading, user, getAccessTokenSilently } = useAuth0(); - const [syncStatus, setSyncStatus] = useState('processing'); - const [errorMessage, setErrorMessage] = useState(''); - const { toast } = useToast(); - - useEffect(() => { - const syncUserToSupabase = async () => { - if (isLoading) return; - - if (!isAuthenticated || !user) { - setSyncStatus('error'); - setErrorMessage('Authentication failed. Please try again.'); - return; - } - - try { - console.log('[Auth0Callback] Syncing user to Supabase:', user.sub); - - // Get Auth0 access token - const accessToken = await getAccessTokenSilently(); - - // Call sync edge function - const { data, error } = await supabase.functions.invoke('auth0-sync-user', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - body: { - email: user.email, - name: user.name, - picture: user.picture, - email_verified: user.email_verified, - }, - }); - - if (error || !data?.success) { - throw new Error(data?.error || error?.message || 'Sync failed'); - } - - console.log('[Auth0Callback] User synced successfully:', data.profile); - - setSyncStatus('success'); - - toast({ - title: 'Welcome back!', - description: 'You\'ve been signed in successfully.', - }); - - // Redirect after brief delay - setTimeout(() => { - const redirectTo = searchParams.get('redirect') || '/'; - navigate(redirectTo); - }, 1500); - } catch (error) { - console.error('[Auth0Callback] Sync error:', error); - setSyncStatus('error'); - setErrorMessage(error instanceof Error ? error.message : 'Failed to sync user data'); - } - }; - - syncUserToSupabase(); - }, [isAuthenticated, isLoading, user, getAccessTokenSilently, navigate, searchParams, toast]); - - return ( -
-
- -
-
- - -
- {syncStatus === 'processing' && ( - - )} - {syncStatus === 'success' && ( - - )} - {syncStatus === 'error' && ( - - )} -
- - {syncStatus === 'processing' && 'Completing Sign In...'} - {syncStatus === 'success' && 'Sign In Successful!'} - {syncStatus === 'error' && 'Sign In Error'} - - - {syncStatus === 'processing' && 'Please wait while we set up your account'} - {syncStatus === 'success' && 'Redirecting you to ThrillWiki...'} - {syncStatus === 'error' && 'Something went wrong during authentication'} - -
- - {syncStatus === 'error' && ( - - - - {errorMessage || 'An unexpected error occurred. Please try signing in again.'} - - - -
- -
-
- )} - - {syncStatus === 'processing' && ( - -
-

Syncing your profile...

-

This should only take a moment

-
-
- )} -
-
-
-
- ); -} diff --git a/src/pages/AuthCallback.tsx b/src/pages/AuthCallback.tsx index 3351ac0e..9e82d090 100644 --- a/src/pages/AuthCallback.tsx +++ b/src/pages/AuthCallback.tsx @@ -114,15 +114,11 @@ export default function AuthCallback() { const result = await handlePostAuthFlow(session, authMethod); if (result.success && result.data?.shouldRedirect) { - // CRITICAL SECURITY FIX: Get factor BEFORE destroying session + // Get factor ID and show modal instead of redirecting const { data: factors } = await supabase.auth.mfa.listFactors(); const totpFactor = factors?.totp?.find(f => f.status === 'verified'); if (totpFactor) { - // OAuth flow: We can't store the OAuth token, so we keep the AAL1 session - // This is unavoidable for OAuth flows - but RLS blocks sensitive operations - console.log('[AuthCallback] OAuth MFA required - keeping AAL1 session (OAuth limitation)'); - setMfaFactorId(totpFactor.id); setStatus('mfa_required'); return; diff --git a/src/pages/BlogPost.tsx b/src/pages/BlogPost.tsx index cf14501d..bcf05d1c 100644 --- a/src/pages/BlogPost.tsx +++ b/src/pages/BlogPost.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; -import { useBlogPost } from '@/hooks/blog/useBlogPost'; import { MarkdownRenderer } from '@/components/blog/MarkdownRenderer'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; @@ -13,13 +13,26 @@ import { Header } from '@/components/layout/Header'; import { Footer } from '@/components/layout/Footer'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; -import { useQueryInvalidation } from '@/lib/queryInvalidation'; export default function BlogPost() { const { slug } = useParams<{ slug: string }>(); - const { invalidateBlogPost } = useQueryInvalidation(); - const { data: post, isLoading } = useBlogPost(slug); + const { data: post, isLoading } = useQuery({ + queryKey: ['blog-post', slug], + queryFn: async () => { + const query = supabase + .from('blog_posts') + .select('*, profiles!inner(username, display_name, avatar_url, avatar_image_id)') + .eq('slug', slug) + .eq('status', 'published') + .single(); + + const { data, error } = await query; + if (error) throw error; + return data; + }, + enabled: !!slug, + }); // Update document title when post changes useDocumentTitle(post?.title || 'Blog Post'); @@ -36,12 +49,9 @@ export default function BlogPost() { useEffect(() => { if (slug) { - supabase.rpc('increment_blog_view_count', { post_slug: slug }).then(() => { - // Invalidate blog post cache to update view count - invalidateBlogPost(slug); - }); + supabase.rpc('increment_blog_view_count', { post_slug: slug }); } - }, [slug, invalidateBlogPost]); + }, [slug]); if (isLoading) { return ( diff --git a/src/pages/DesignerDetail.tsx b/src/pages/DesignerDetail.tsx index ea67963f..e8218551 100644 --- a/src/pages/DesignerDetail.tsx +++ b/src/pages/DesignerDetail.tsx @@ -26,21 +26,20 @@ import { trackPageView } from '@/lib/viewTracking'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; -import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; -import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; export default function DesignerDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); + const [designer, setDesigner] = useState(null); + const [loading, setLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [totalRides, setTotalRides] = useState(0); + const [totalPhotos, setTotalPhotos] = useState(0); + const [statsLoading, setStatsLoading] = useState(true); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); - // Use custom hooks for data fetching - const { data: designer, isLoading: loading } = useCompanyDetail(slug, 'designer'); - const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(designer?.id, 'designer'); - // Update document title when designer changes useDocumentTitle(designer?.name || 'Designer Details'); @@ -54,6 +53,12 @@ export default function DesignerDetail() { enabled: !!designer }); + useEffect(() => { + if (slug) { + fetchDesignerData(); + } + }, [slug]); + // Track page view when designer is loaded useEffect(() => { if (designer?.id) { @@ -61,6 +66,54 @@ export default function DesignerDetail() { } }, [designer?.id]); + const fetchDesignerData = async () => { + try { + const { data, error } = await supabase + .from('companies') + .select('*') + .eq('slug', slug) + .eq('company_type', 'designer') + .maybeSingle(); + + if (error) throw error; + setDesigner(data); + if (data) { + fetchStatistics(data.id); + } + } catch (error) { + console.error('Error fetching designer:', error); + } finally { + setLoading(false); + } + }; + + const fetchStatistics = async (designerId: string) => { + try { + // Count rides + const { count: ridesCount, error: ridesError } = await supabase + .from('rides') + .select('id', { count: 'exact', head: true }) + .eq('designer_id', designerId); + + if (ridesError) throw ridesError; + setTotalRides(ridesCount || 0); + + // Count photos + const { count: photosCount, error: photosError } = await supabase + .from('photos') + .select('id', { count: 'exact', head: true }) + .eq('entity_type', 'designer') + .eq('entity_id', designerId); + + if (photosError) throw photosError; + setTotalPhotos(photosCount || 0); + } catch (error) { + console.error('Error fetching statistics:', error); + } finally { + setStatsLoading(false); + } + }; + const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -242,10 +295,10 @@ export default function DesignerDetail() { Overview - Rides {!statsLoading && statistics?.ridesCount ? `(${statistics.ridesCount})` : ''} + Rides {!statsLoading && totalRides > 0 && `(${totalRides})`} - Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} + Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} History diff --git a/src/pages/ManufacturerDetail.tsx b/src/pages/ManufacturerDetail.tsx index 14f61d3d..39257d2e 100644 --- a/src/pages/ManufacturerDetail.tsx +++ b/src/pages/ManufacturerDetail.tsx @@ -26,21 +26,21 @@ import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; -import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; -import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; export default function ManufacturerDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); + const [manufacturer, setManufacturer] = useState(null); + const [loading, setLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [totalRides, setTotalRides] = useState(0); + const [totalModels, setTotalModels] = useState(0); + const [totalPhotos, setTotalPhotos] = useState(0); + const [statsLoading, setStatsLoading] = useState(true); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); - // Use custom hooks for data fetching - const { data: manufacturer, isLoading: loading } = useCompanyDetail(slug, 'manufacturer'); - const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(manufacturer?.id, 'manufacturer'); - // Update document title when manufacturer changes useDocumentTitle(manufacturer?.name || 'Manufacturer Details'); @@ -54,6 +54,12 @@ export default function ManufacturerDetail() { enabled: !!manufacturer }); + useEffect(() => { + if (slug) { + fetchManufacturerData(); + } + }, [slug]); + // Track page view when manufacturer is loaded useEffect(() => { if (manufacturer?.id) { @@ -61,6 +67,63 @@ export default function ManufacturerDetail() { } }, [manufacturer?.id]); + const fetchManufacturerData = async () => { + try { + const { data, error } = await supabase + .from('companies') + .select('*') + .eq('slug', slug) + .eq('company_type', 'manufacturer') + .maybeSingle(); + + if (error) throw error; + setManufacturer(data); + if (data) { + fetchStatistics(data.id); + } + } catch (error) { + console.error('Error fetching manufacturer:', error); + } finally { + setLoading(false); + } + }; + + const fetchStatistics = async (manufacturerId: string) => { + try { + // Count rides + const { count: ridesCount, error: ridesError } = await supabase + .from('rides') + .select('id', { count: 'exact', head: true }) + .eq('manufacturer_id', manufacturerId); + + if (ridesError) throw ridesError; + setTotalRides(ridesCount || 0); + + // Count models + const { count: modelsCount, error: modelsError } = await supabase + .from('ride_models') + .select('id', { count: 'exact', head: true }) + .eq('manufacturer_id', manufacturerId); + + if (modelsError) throw modelsError; + setTotalModels(modelsCount || 0); + + // Count photos + const { count: photosCount, error: photosError } = await supabase + .from('photos') + .select('id', { count: 'exact', head: true }) + .eq('entity_type', 'manufacturer') + .eq('entity_id', manufacturerId); + + if (photosError) throw photosError; + setTotalPhotos(photosCount || 0); + } catch (error) { + console.error('Error fetching statistics:', error); + } finally { + setStatsLoading(false); + } + }; + const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -244,13 +307,13 @@ export default function ManufacturerDetail() { Overview - Rides {!statsLoading && statistics?.ridesCount ? `(${statistics.ridesCount})` : ''} + Rides {!statsLoading && totalRides > 0 && `(${totalRides})`} - Models {!statsLoading && statistics?.modelsCount ? `(${statistics.modelsCount})` : ''} + Models {!statsLoading && totalModels > 0 && `(${totalModels})`} - Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} + Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} History diff --git a/src/pages/OperatorDetail.tsx b/src/pages/OperatorDetail.tsx index 666266a5..fb78cbc1 100644 --- a/src/pages/OperatorDetail.tsx +++ b/src/pages/OperatorDetail.tsx @@ -27,23 +27,23 @@ import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; -import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; -import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; -import { useCompanyParks } from '@/hooks/companies/useCompanyParks'; export default function OperatorDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); + const [operator, setOperator] = useState(null); + const [parks, setParks] = useState([]); + const [loading, setLoading] = useState(true); + const [parksLoading, setParksLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [totalParks, setTotalParks] = useState(0); + const [operatingRides, setOperatingRides] = useState(0); + const [statsLoading, setStatsLoading] = useState(true); + const [totalPhotos, setTotalPhotos] = useState(0); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); - // Use custom hooks for data fetching - const { data: operator, isLoading: loading } = useCompanyDetail(slug, 'operator'); - const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(operator?.id, 'operator'); - const { data: parks = [], isLoading: parksLoading } = useCompanyParks(operator?.id, 'operator', 6); - // Update document title when operator changes useDocumentTitle(operator?.name || 'Operator Details'); @@ -57,6 +57,12 @@ export default function OperatorDetail() { enabled: !!operator }); + useEffect(() => { + if (slug) { + fetchOperatorData(); + } + }, [slug]); + // Track page view when operator is loaded useEffect(() => { if (operator?.id) { @@ -64,6 +70,95 @@ export default function OperatorDetail() { } }, [operator?.id]); + const fetchOperatorData = async () => { + try { + const { data, error } = await supabase + .from('companies') + .select('*') + .eq('slug', slug) + .eq('company_type', 'operator') + .maybeSingle(); + + if (error) throw error; + setOperator(data); + + // Fetch parks operated by this operator + if (data) { + fetchParks(data.id); + fetchStatistics(data.id); + fetchPhotoCount(data.id); + } + } catch (error) { + console.error('Error fetching operator:', error); + } finally { + setLoading(false); + } + }; + + const fetchParks = async (operatorId: string) => { + try { + const { data, error } = await supabase + .from('parks') + .select(` + *, + location:locations(*) + `) + .eq('operator_id', operatorId) + .order('name') + .limit(6); + + if (error) throw error; + setParks(data || []); + } catch (error) { + console.error('Error fetching parks:', error); + } finally { + setParksLoading(false); + } + }; + + const fetchStatistics = async (operatorId: string) => { + try { + // Get total parks count + const { count: parksCount, error: parksError } = await supabase + .from('parks') + .select('id', { count: 'exact', head: true }) + .eq('operator_id', operatorId); + + if (parksError) throw parksError; + setTotalParks(parksCount || 0); + + // Get operating rides count across all parks + const { data: ridesData, error: ridesError } = await supabase + .from('rides') + .select('id, parks!inner(operator_id)') + .eq('parks.operator_id', operatorId) + .eq('status', 'operating'); + + if (ridesError) throw ridesError; + setOperatingRides(ridesData?.length || 0); + } catch (error) { + console.error('Error fetching statistics:', error); + } finally { + setStatsLoading(false); + } + }; + + const fetchPhotoCount = async (operatorId: string) => { + try { + const { count, error } = await supabase + .from('photos') + .select('id', { count: 'exact', head: true }) + .eq('entity_type', 'operator') + .eq('entity_id', operatorId); + + if (error) throw error; + setTotalPhotos(count || 0); + } catch (error) { + console.error('Error fetching photo count:', error); + setTotalPhotos(0); + } + }; + const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -214,29 +309,29 @@ export default function OperatorDetail() { {/* Company Info */}
- {!statsLoading && statistics?.parksCount ? ( + {!statsLoading && totalParks > 0 && ( -
{statistics.parksCount}
+
{totalParks}
- {statistics.parksCount === 1 ? 'Park Operated' : 'Parks Operated'} + {totalParks === 1 ? 'Park Operated' : 'Parks Operated'}
- ) : null} + )} - {!statsLoading && statistics?.operatingRidesCount ? ( + {!statsLoading && operatingRides > 0 && ( -
{statistics.operatingRidesCount}
+
{operatingRides}
- Operating {statistics.operatingRidesCount === 1 ? 'Ride' : 'Rides'} + Operating {operatingRides === 1 ? 'Ride' : 'Rides'}
- ) : null} + )} {operator.founded_year && ( @@ -270,10 +365,10 @@ export default function OperatorDetail() { Overview - Parks {!statsLoading && statistics?.parksCount ? `(${statistics.parksCount})` : ''} + Parks {!statsLoading && totalParks > 0 && `(${totalParks})`} - Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} + Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} History diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 0df5fe04..118d7495 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -32,9 +32,6 @@ import { PersonalLocationDisplay } from '@/components/profile/PersonalLocationDi import { useUserRole } from '@/hooks/useUserRole'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; -import { useProfileActivity } from '@/hooks/profile/useProfileActivity'; -import { useProfileStats } from '@/hooks/profile/useProfileStats'; -import { useQueryInvalidation } from '@/lib/queryInvalidation'; // Activity type definitions interface SubmissionActivity { @@ -150,9 +147,13 @@ export default function Profile() { const [formErrors, setFormErrors] = useState>({}); const [avatarUrl, setAvatarUrl] = useState(''); const [avatarImageId, setAvatarImageId] = useState(''); - - // Query invalidation for cache updates - const { invalidateProfileActivity, invalidateProfileStats } = useQueryInvalidation(); + const [calculatedStats, setCalculatedStats] = useState({ + rideCount: 0, + coasterCount: 0, + parkCount: 0 + }); + const [recentActivity, setRecentActivity] = useState([]); + const [activityLoading, setActivityLoading] = useState(false); // User role checking const { isModerator, loading: rolesLoading } = useUserRole(); @@ -171,18 +172,6 @@ export default function Profile() { // Username validation const usernameValidation = useUsernameValidation(editForm.username, profile?.username); - - // Optimized activity and stats hooks - const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id; - const { data: calculatedStats = { rideCount: 0, coasterCount: 0, parkCount: 0 } } = useProfileStats(profile?.user_id); - const { data: recentActivity = [], isLoading: activityLoading } = useProfileActivity( - profile?.user_id, - isOwnProfile || false, - isModerator() - ); - // Cast activity to local types for type safety - const typedActivity = recentActivity as ActivityEntry[]; - useEffect(() => { getCurrentUser(); if (username) { @@ -192,6 +181,214 @@ export default function Profile() { } }, [username]); + const fetchCalculatedStats = async (userId: string) => { + try { + // Fetch ride credits stats + const { data: ridesData, error: ridesError } = await supabase + .from('user_ride_credits') + .select(` + ride_count, + rides!inner(category, park_id) + `) + .eq('user_id', userId); + + if (ridesError) throw ridesError; + + // Calculate total rides count (sum of all ride_count values) + const totalRides = ridesData?.reduce((sum, credit) => sum + (credit.ride_count || 0), 0) || 0; + + // Calculate coasters count (distinct rides where category is roller_coaster) + const coasterRides = ridesData?.filter(credit => + credit.rides?.category === 'roller_coaster' + ) || []; + const uniqueCoasters = new Set(coasterRides.map(credit => credit.rides)); + const coasterCount = uniqueCoasters.size; + + // Calculate parks count (distinct parks where user has ridden at least one ride) + const parkRides = ridesData?.map(credit => credit.rides?.park_id).filter(Boolean) || []; + const uniqueParks = new Set(parkRides); + const parkCount = uniqueParks.size; + + setCalculatedStats({ + rideCount: totalRides, + coasterCount: coasterCount, + parkCount: parkCount + }); + } catch (error) { + console.error('Error fetching calculated stats:', error); + toast({ + variant: 'destructive', + description: getErrorMessage(error), + }); + // Set defaults on error + setCalculatedStats({ + rideCount: 0, + coasterCount: 0, + parkCount: 0 + }); + } + }; + + const fetchRecentActivity = async (userId: string) => { + setActivityLoading(true); + try { + const isOwnProfile = currentUser && currentUser.id === userId; + + // Wait for role loading to complete + if (rolesLoading) { + setActivityLoading(false); + return; + } + + // Check user privacy settings for activity visibility + 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 activity is not public and viewer is not owner or moderator, show empty + if (activityVisibility !== 'public' && !isOwnProfile && !isModerator()) { + setRecentActivity([]); + setActivityLoading(false); + return; + } + + // Fetch last 10 reviews + let 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) + .order('created_at', { ascending: false }) + .limit(10); + + // Regular users viewing others: show only approved reviews + if (!isOwnProfile && !isModerator()) { + reviewsQuery = reviewsQuery.eq('moderation_status', 'approved'); + } + + const { data: reviews, error: reviewsError } = await reviewsQuery; + if (reviewsError) throw reviewsError; + + // Fetch last 10 ride credits + const { data: credits, error: creditsError } = await 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); + + if (creditsError) throw creditsError; + + // Fetch last 10 submissions with enriched data + let submissionsQuery = supabase + .from('content_submissions') + .select('id, submission_type, content, status, created_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(10); + + // Regular users viewing others: show only approved submissions + // Moderators/Admins/Superusers see all submissions (they bypass the submission process) + if (!isOwnProfile && !isModerator()) { + submissionsQuery = submissionsQuery.eq('status', 'approved'); + } + + const { data: submissions, error: submissionsError } = await submissionsQuery; + if (submissionsError) throw submissionsError; + + // Enrich submissions with entity data and photos + const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => { + const enriched: any = { ...sub }; + + // For photo submissions, get photo count and preview + if (sub.submission_type === 'photo') { + const { data: photoSubs } = await supabase + .from('photo_submissions') + .select('id, entity_type, entity_id') + .eq('submission_id', sub.id) + .maybeSingle(); + + if (photoSubs) { + const { data: photoItems, count } = await supabase + .from('photo_submission_items') + .select('cloudflare_image_url', { count: 'exact' }) + .eq('photo_submission_id', photoSubs.id) + .order('order_index', { ascending: true }) + .limit(1); + + enriched.photo_count = count || 0; + enriched.photo_preview = photoItems?.[0]?.cloudflare_image_url; + enriched.entity_type = photoSubs.entity_type; + enriched.entity_id = photoSubs.entity_id; + + // Get entity name/slug for linking + if (photoSubs.entity_type === 'park') { + const { data: park } = await supabase + .from('parks') + .select('name, slug') + .eq('id', photoSubs.entity_id) + .single(); + enriched.content = { ...enriched.content, entity_name: park?.name, entity_slug: park?.slug }; + } else if (photoSubs.entity_type === 'ride') { + const { data: ride } = await supabase + .from('rides') + .select('name, slug, parks!inner(name, slug)') + .eq('id', photoSubs.entity_id) + .single(); + enriched.content = { + ...enriched.content, + entity_name: ride?.name, + entity_slug: ride?.slug, + park_name: ride?.parks?.name, + park_slug: ride?.parks?.slug + }; + } + } + } + + return enriched; + })); + + // Fetch last 10 rankings (public top lists) + let rankingsQuery = supabase + .from('user_top_lists') + .select('id, title, description, list_type, created_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(10); + + if (!isOwnProfile) { + rankingsQuery = rankingsQuery.eq('is_public', true); + } + + const { data: rankings, error: rankingsError } = await rankingsQuery; + if (rankingsError) throw rankingsError; + + // Combine and sort by date + const combined = [ + ...(reviews?.map(r => ({ ...r, type: 'review' as const })) || []), + ...(credits?.map(c => ({ ...c, type: 'credit' as const })) || []), + ...(enrichedSubmissions?.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) as ActivityEntry[]; + + setRecentActivity(combined); + } catch (error) { + console.error('Error fetching recent activity:', error); + toast({ + variant: 'destructive', + description: getErrorMessage(error), + }); + setRecentActivity([]); + } finally { + setActivityLoading(false); + } + }; const getCurrentUser = async () => { const { data: { @@ -237,6 +434,10 @@ export default function Profile() { }); setAvatarUrl(data.avatar_url || ''); setAvatarImageId(data.avatar_image_id || ''); + + // Fetch calculated stats and recent activity for this user + await fetchCalculatedStats(data.user_id); + await fetchRecentActivity(data.user_id); } } catch (error) { console.error('Error fetching profile:', error); @@ -274,6 +475,10 @@ export default function Profile() { }); setAvatarUrl(data.avatar_url || ''); setAvatarImageId(data.avatar_image_id || ''); + + // Fetch calculated stats and recent activity for the current user + await fetchCalculatedStats(user.id); + await fetchRecentActivity(user.id); } } catch (error) { console.error('Error fetching profile:', error); @@ -329,13 +534,6 @@ export default function Profile() { error } = await supabase.from('profiles').update(updateData).eq('user_id', currentUser.id); if (error) throw error; - - // Invalidate profile caches across the app - if (currentUser.id) { - invalidateProfileActivity(currentUser.id); - invalidateProfileStats(currentUser.id); - } - setProfile(prev => prev ? { ...prev, ...updateData @@ -385,11 +583,6 @@ export default function Profile() { }).eq('user_id', currentUser.id); if (error) throw error; - // Invalidate profile activity cache (avatar shows in activity) - if (currentUser.id) { - invalidateProfileActivity(currentUser.id); - } - // Update local profile state setProfile(prev => prev ? { ...prev, @@ -416,7 +609,7 @@ export default function Profile() { }); } }; - + const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id; if (loading) { return
@@ -639,7 +832,7 @@ export default function Profile() {
- ) : typedActivity.length === 0 ? ( + ) : recentActivity.length === 0 ? (

No recent activity yet

@@ -649,7 +842,7 @@ export default function Profile() {
) : (
- {typedActivity.map(activity => ( + {recentActivity.map(activity => (
{activity.type === 'review' ? ( diff --git a/src/pages/PropertyOwnerDetail.tsx b/src/pages/PropertyOwnerDetail.tsx index d6401e1c..e5994716 100644 --- a/src/pages/PropertyOwnerDetail.tsx +++ b/src/pages/PropertyOwnerDetail.tsx @@ -27,23 +27,23 @@ import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs'; import { useAuthModal } from '@/hooks/useAuthModal'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; -import { useCompanyDetail } from '@/hooks/companies/useCompanyDetail'; -import { useCompanyStatistics } from '@/hooks/companies/useCompanyStatistics'; -import { useCompanyParks } from '@/hooks/companies/useCompanyParks'; export default function PropertyOwnerDetail() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); + const [owner, setOwner] = useState(null); + const [parks, setParks] = useState([]); + const [loading, setLoading] = useState(true); + const [parksLoading, setParksLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [totalParks, setTotalParks] = useState(0); + const [operatingRides, setOperatingRides] = useState(0); + const [statsLoading, setStatsLoading] = useState(true); + const [totalPhotos, setTotalPhotos] = useState(0); const { user } = useAuth(); const { isModerator } = useUserRole(); const { requireAuth } = useAuthModal(); - // Use custom hooks for data fetching - const { data: owner, isLoading: loading } = useCompanyDetail(slug, 'property_owner'); - const { data: statistics, isLoading: statsLoading } = useCompanyStatistics(owner?.id, 'property_owner'); - const { data: parks = [], isLoading: parksLoading } = useCompanyParks(owner?.id, 'property_owner', 6); - // Update document title when owner changes useDocumentTitle(owner?.name || 'Property Owner Details'); @@ -57,6 +57,12 @@ export default function PropertyOwnerDetail() { enabled: !!owner }); + useEffect(() => { + if (slug) { + fetchOwnerData(); + } + }, [slug]); + // Track page view when property owner is loaded useEffect(() => { if (owner?.id) { @@ -64,6 +70,95 @@ export default function PropertyOwnerDetail() { } }, [owner?.id]); + const fetchOwnerData = async () => { + try { + const { data, error } = await supabase + .from('companies') + .select('*') + .eq('slug', slug) + .eq('company_type', 'property_owner') + .maybeSingle(); + + if (error) throw error; + setOwner(data); + + // Fetch parks owned by this property owner + if (data) { + fetchParks(data.id); + fetchStatistics(data.id); + fetchPhotoCount(data.id); + } + } catch (error) { + console.error('Error fetching property owner:', error); + } finally { + setLoading(false); + } + }; + + const fetchParks = async (ownerId: string) => { + try { + const { data, error } = await supabase + .from('parks') + .select(` + *, + location:locations(*) + `) + .eq('property_owner_id', ownerId) + .order('name') + .limit(6); + + if (error) throw error; + setParks(data || []); + } catch (error) { + console.error('Error fetching parks:', error); + } finally { + setParksLoading(false); + } + }; + + const fetchStatistics = async (ownerId: string) => { + try { + // Get total parks count + const { count: parksCount, error: parksError } = await supabase + .from('parks') + .select('id', { count: 'exact', head: true }) + .eq('property_owner_id', ownerId); + + if (parksError) throw parksError; + setTotalParks(parksCount || 0); + + // Get operating rides count across all owned parks + const { data: ridesData, error: ridesError } = await supabase + .from('rides') + .select('id, parks!inner(property_owner_id)') + .eq('parks.property_owner_id', ownerId) + .eq('status', 'operating'); + + if (ridesError) throw ridesError; + setOperatingRides(ridesData?.length || 0); + } catch (error) { + console.error('Error fetching statistics:', error); + } finally { + setStatsLoading(false); + } + }; + + const fetchPhotoCount = async (ownerId: string) => { + try { + const { count, error } = await supabase + .from('photos') + .select('id', { count: 'exact', head: true }) + .eq('entity_type', 'property_owner') + .eq('entity_id', ownerId); + + if (error) throw error; + setTotalPhotos(count || 0); + } catch (error) { + console.error('Error fetching photo count:', error); + setTotalPhotos(0); + } + }; + const handleEditSubmit = async (data: any) => { try { await submitCompanyUpdate( @@ -214,29 +309,29 @@ export default function PropertyOwnerDetail() { {/* Company Info */}
- {!statsLoading && statistics?.parksCount ? ( + {!statsLoading && totalParks > 0 && ( -
{statistics.parksCount}
+
{totalParks}
- {statistics.parksCount === 1 ? 'Park Owned' : 'Parks Owned'} + {totalParks === 1 ? 'Park Owned' : 'Parks Owned'}
- ) : null} + )} - {!statsLoading && statistics?.operatingRidesCount ? ( + {!statsLoading && operatingRides > 0 && ( -
{statistics.operatingRidesCount}
+
{operatingRides}
- Operating {statistics.operatingRidesCount === 1 ? 'Ride' : 'Rides'} + Operating {operatingRides === 1 ? 'Ride' : 'Rides'}
- ) : null} + )} {owner.founded_year && ( @@ -270,10 +365,10 @@ export default function PropertyOwnerDetail() { Overview - Parks {!statsLoading && statistics?.parksCount ? `(${statistics.parksCount})` : ''} + Parks {!statsLoading && totalParks > 0 && `(${totalParks})`} - Photos {!statsLoading && statistics?.photosCount ? `(${statistics.photosCount})` : ''} + Photos {!statsLoading && totalPhotos > 0 && `(${totalPhotos})`} History diff --git a/src/pages/RideModelDetail.tsx b/src/pages/RideModelDetail.tsx index af4fef26..fb685389 100644 --- a/src/pages/RideModelDetail.tsx +++ b/src/pages/RideModelDetail.tsx @@ -24,24 +24,18 @@ import { VersionIndicator } from '@/components/versioning/VersionIndicator'; import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; -import { useRideModelDetail } from '@/hooks/rideModels/useRideModelDetail'; -import { useModelRides } from '@/hooks/rideModels/useModelRides'; -import { useModelStatistics } from '@/hooks/rideModels/useModelStatistics'; export default function RideModelDetail() { const { manufacturerSlug, modelSlug } = useParams<{ manufacturerSlug: string; modelSlug: string }>(); const navigate = useNavigate(); const { user } = useAuth(); const { requireAuth } = useAuthModal(); + const [model, setModel] = useState(null); + const [manufacturer, setManufacturer] = useState(null); + const [rides, setRides] = useState([]); + const [loading, setLoading] = useState(true); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - // Use custom hooks for data fetching - const { data: modelData, isLoading: loading } = useRideModelDetail(manufacturerSlug, modelSlug); - const model = modelData?.model; - const manufacturer = modelData?.manufacturer; - const { data: rides = [] } = useModelRides(model?.id); - const { data: statistics = { rideCount: 0, photoCount: 0 } } = useModelStatistics(model?.id); - // Update document title when model changes useDocumentTitle(model?.name || 'Ride Model Details'); @@ -54,10 +48,78 @@ export default function RideModelDetail() { type: 'website', enabled: !!model }); + const [statistics, setStatistics] = useState({ rideCount: 0, photoCount: 0 }); // Fetch technical specifications from relational table const { data: technicalSpecs } = useTechnicalSpecifications('ride_model', model?.id); + const fetchData = useCallback(async () => { + try { + // Fetch manufacturer + const { data: manufacturerData, error: manufacturerError } = await supabase + .from('companies') + .select('*') + .eq('slug', manufacturerSlug) + .eq('company_type', 'manufacturer') + .maybeSingle(); + + if (manufacturerError) throw manufacturerError; + setManufacturer(manufacturerData); + + if (manufacturerData) { + // Fetch ride model + const { data: modelData, error: modelError } = await supabase + .from('ride_models') + .select('*') + .eq('slug', modelSlug) + .eq('manufacturer_id', manufacturerData.id) + .maybeSingle(); + + if (modelError) throw modelError; + setModel(modelData as RideModel); + + if (modelData) { + // Fetch rides using this model with proper joins + const { data: ridesData, error: ridesError } = await 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', modelData.id) + .order('name'); + + if (ridesError) throw ridesError; + setRides(ridesData as Ride[] || []); + + // Fetch statistics + const { count: photoCount } = await supabase + .from('photos') + .select('*', { count: 'exact', head: true }) + .eq('entity_type', 'ride_model') + .eq('entity_id', modelData.id); + + setStatistics({ + rideCount: ridesData?.length || 0, + photoCount: photoCount || 0 + }); + } + } + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }, [manufacturerSlug, modelSlug]); + + useEffect(() => { + if (manufacturerSlug && modelSlug) { + fetchData(); + } + }, [manufacturerSlug, modelSlug, fetchData]); + const handleEditSubmit = async (data: any) => { try { if (!user || !model) return; @@ -76,6 +138,7 @@ export default function RideModelDetail() { }); setIsEditModalOpen(false); + fetchData(); } catch (error) { const errorMsg = getErrorMessage(error); toast({ diff --git a/src/pages/admin/AdminContact.tsx b/src/pages/admin/AdminContact.tsx index 1c6ccbfd..e4deeca9 100644 --- a/src/pages/admin/AdminContact.tsx +++ b/src/pages/admin/AdminContact.tsx @@ -80,7 +80,6 @@ import { logger } from '@/lib/logger'; import { contactCategories } from '@/lib/contactValidation'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { AdminLayout } from '@/components/layout/AdminLayout'; -import { queryKeys } from '@/lib/queryKeys'; interface ContactSubmission { id: string; @@ -160,7 +159,7 @@ export default function AdminContact() { // Fetch contact submissions const { data: submissions, isLoading } = useQuery({ - queryKey: queryKeys.admin.contactSubmissions(statusFilter, categoryFilter, searchQuery, showArchived), + queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived], queryFn: async () => { let query = supabase .from('contact_submissions') @@ -283,10 +282,7 @@ export default function AdminContact() { .order('created_at', { ascending: true }) .then(({ data }) => setEmailThreads((data as EmailThread[]) || [])); } - queryClient.invalidateQueries({ - queryKey: ['admin-contact-submissions'], - exact: false - }); + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); }, onError: (error: Error) => { handleError(error, { action: 'Send Email Reply' }); @@ -324,10 +320,7 @@ export default function AdminContact() { if (error) throw error; }, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['admin-contact-submissions'], - exact: false - }); + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Status Updated', 'Contact submission status has been updated'); setSelectedSubmission(null); setAdminNotes(''); @@ -352,10 +345,7 @@ export default function AdminContact() { if (error) throw error; }, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['admin-contact-submissions'], - exact: false - }); + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Archived', 'Contact submission has been archived'); setSelectedSubmission(null); }, @@ -378,10 +368,7 @@ export default function AdminContact() { if (error) throw error; }, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['admin-contact-submissions'], - exact: false - }); + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Restored', 'Contact submission has been restored from archive'); setSelectedSubmission(null); }, @@ -401,10 +388,7 @@ export default function AdminContact() { if (error) throw error; }, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['admin-contact-submissions'], - exact: false - }); + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Deleted', 'Contact submission has been permanently deleted'); setSelectedSubmission(null); }, @@ -444,10 +428,7 @@ export default function AdminContact() { }; const handleRefreshSubmissions = () => { - queryClient.invalidateQueries({ - queryKey: ['admin-contact-submissions'], - exact: false - }); + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); }; const handleCopyTicket = (ticketNumber: string) => { diff --git a/src/types/auth0.ts b/src/types/auth0.ts deleted file mode 100644 index e727e8d9..00000000 --- a/src/types/auth0.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Auth0 Type Definitions - */ - -import type { User } from '@auth0/auth0-react'; - -/** - * Extended Auth0 user with app-specific metadata - */ -export interface Auth0User extends User { - app_metadata?: { - roles?: string[]; - supabase_id?: string; - migration_status?: 'pending' | 'completed' | 'failed'; - }; - user_metadata?: { - username?: string; - display_name?: string; - avatar_url?: string; - }; -} - -/** - * Auth0 MFA enrollment status - */ -export interface Auth0MFAStatus { - enrolled: boolean; - methods: Array<{ - id: string; - type: 'totp' | 'sms' | 'push' | 'email'; - name?: string; - confirmed: boolean; - }>; -} - -/** - * Auth0 role information - */ -export interface Auth0RoleInfo { - id: string; - name: string; - description?: string; -} - -/** - * Auth0 sync status for migration tracking - */ -export interface Auth0SyncStatus { - auth0_sub: string; - supabase_id?: string; - last_sync: string; - sync_status: 'success' | 'pending' | 'failed'; - error_message?: string; -} - -/** - * Auth0 authentication state - */ -export interface Auth0State { - isAuthenticated: boolean; - isLoading: boolean; - user: Auth0User | null; - accessToken: string | null; - idToken: string | null; - needsMigration: boolean; - isAuth0User: boolean; -} - -/** - * Auth0 Management API token response - */ -export interface ManagementTokenResponse { - access_token: string; - token_type: string; - expires_in: number; -} - -/** - * Auth0 user sync request - */ -export interface UserSyncRequest { - auth0_sub: string; - email: string; - name?: string; - picture?: string; - email_verified: boolean; -} - -/** - * Auth0 user sync response - */ -export interface UserSyncResponse { - success: boolean; - profile?: { - id: string; - auth0_sub: string; - username: string; - email: string; - }; - error?: string; -} diff --git a/supabase/config.toml b/supabase/config.toml index 3199783b..54d81fd5 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,14 +1,5 @@ project_id = "ydvtmnrszybqnbcqbdcy" -[functions.auth-with-mfa-check] -verify_jwt = false - -[functions.verify-mfa-and-login] -verify_jwt = false - -[functions.check-mfa-enrollment] -verify_jwt = true - [functions.send-password-added-email] verify_jwt = true @@ -73,16 +64,4 @@ verify_jwt = true verify_jwt = false [functions.process-expired-bans] -verify_jwt = false - -[functions.auth0-sync-user] -verify_jwt = true - -[functions.auth0-get-roles] -verify_jwt = true - -[functions.auth0-webhook] -verify_jwt = false - -[functions.auth0-get-management-token] -verify_jwt = true \ No newline at end of file +verify_jwt = false \ No newline at end of file diff --git a/supabase/functions/_shared/auth0Jwt.ts b/supabase/functions/_shared/auth0Jwt.ts deleted file mode 100644 index 5ef1347d..00000000 --- a/supabase/functions/_shared/auth0Jwt.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Auth0 JWT Verification Utility - * - * Provides JWT verification using Auth0 JWKS - */ - -import { jwtVerify, createRemoteJWKSet, type JWTPayload } from 'https://esm.sh/jose@5.2.0'; - -const AUTH0_DOMAIN = Deno.env.get('AUTH0_DOMAIN') || ''; -const AUTH0_CLIENT_ID = Deno.env.get('AUTH0_CLIENT_ID') || ''; - -/** - * Extended JWT payload with Auth0-specific claims - */ -export interface Auth0JWTPayload extends JWTPayload { - sub: string; - email?: string; - email_verified?: boolean; - name?: string; - picture?: string; - 'https://thrillwiki.com/roles'?: string[]; - amr?: string[]; // Authentication Methods Reference (includes 'mfa' if MFA verified) -} - -/** - * Verify Auth0 JWT token using JWKS - */ -export async function verifyAuth0Token(token: string): Promise { - try { - if (!AUTH0_DOMAIN || !AUTH0_CLIENT_ID) { - throw new Error('Auth0 configuration missing'); - } - - // Create JWKS fetcher - const JWKS = createRemoteJWKSet( - new URL(`https://${AUTH0_DOMAIN}/.well-known/jwks.json`) - ); - - // Verify token - const { payload } = await jwtVerify(token, JWKS, { - issuer: `https://${AUTH0_DOMAIN}/`, - audience: AUTH0_CLIENT_ID, - }); - - return payload as Auth0JWTPayload; - } catch (error) { - console.error('[Auth0JWT] Verification failed:', error); - throw new Error('Invalid Auth0 token'); - } -} - -/** - * Extract roles from Auth0 JWT payload - */ -export function extractRoles(payload: Auth0JWTPayload): string[] { - return payload['https://thrillwiki.com/roles'] || []; -} - -/** - * Check if user has verified MFA - */ -export function hasMFA(payload: Auth0JWTPayload): boolean { - return payload.amr?.includes('mfa') || false; -} - -/** - * Extract user ID (sub) from payload - */ -export function getUserId(payload: Auth0JWTPayload): string { - return payload.sub; -} diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts deleted file mode 100644 index 14d9864d..00000000 --- a/supabase/functions/_shared/cors.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; diff --git a/supabase/functions/auth-with-mfa-check/index.ts b/supabase/functions/auth-with-mfa-check/index.ts deleted file mode 100644 index f347a446..00000000 --- a/supabase/functions/auth-with-mfa-check/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -const supabaseUrl = Deno.env.get('SUPABASE_URL')!; -const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - -Deno.serve(async (req) => { - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - try { - const { email, password } = await req.json(); - - if (!email || !password) { - return new Response( - JSON.stringify({ error: 'Email and password are required' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Create admin client - const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }); - - // Verify credentials using signInWithPassword (doesn't create session with admin client) - const { data: authData, error: authError } = await supabaseAdmin.auth.signInWithPassword({ - email, - password, - }); - - if (authError || !authData.user) { - console.error('Auth error:', authError); - return new Response( - JSON.stringify({ error: authError?.message || 'Invalid credentials' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - const userId = authData.user.id; - - // Check if user is banned - const { data: profile, error: profileError } = await supabaseAdmin - .from('profiles') - .select('banned, ban_reason') - .eq('user_id', userId) - .single(); - - if (profileError) { - console.error('Profile check error:', profileError); - } - - if (profile?.banned) { - return new Response( - JSON.stringify({ - error: 'Account suspended', - banned: true, - banReason: profile.ban_reason - }), - { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Check for MFA enrollment - const { data: factors, error: factorsError } = await supabaseAdmin.auth.admin.mfa.listFactors({ - userId, - }); - - if (factorsError) { - console.error('MFA factors check error:', factorsError); - // Continue - assume no MFA if check fails - } - - const verifiedFactors = factors?.totp?.filter((f) => f.status === 'verified') || []; - - if (verifiedFactors.length > 0) { - // User has MFA - create a challenge but don't create session - const factorId = verifiedFactors[0].id; - - // Create MFA challenge - const { data: challengeData, error: challengeError } = await supabaseAdmin.auth.mfa.challenge({ - factorId, - }); - - if (challengeError) { - console.error('Challenge creation error:', challengeError); - return new Response( - JSON.stringify({ error: 'Failed to create MFA challenge' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - return new Response( - JSON.stringify({ - mfaRequired: true, - factorId, - challengeId: challengeData.id, - userId, // Needed for verification step - }), - { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // No MFA required - return session - return new Response( - JSON.stringify({ - mfaRequired: false, - session: authData.session, - user: authData.user, - }), - { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } catch (error) { - console.error('Unexpected error:', error); - return new Response( - JSON.stringify({ error: 'Internal server error' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } -}); diff --git a/supabase/functions/auth0-get-management-token/index.ts b/supabase/functions/auth0-get-management-token/index.ts deleted file mode 100644 index 1900436d..00000000 --- a/supabase/functions/auth0-get-management-token/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Auth0 Get Management Token Edge Function - * - * Obtains Auth0 Management API access tokens for admin operations - */ - -import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; -import { verifyAuth0Token, extractRoles } from '../_shared/auth0Jwt.ts'; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -// In-memory cache for management token -let cachedToken: string | null = null; -let tokenExpiry: number = 0; - -serve(async (req) => { - // Handle CORS preflight - if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }); - } - - try { - // Verify user is authenticated - const authHeader = req.headers.get('authorization'); - if (!authHeader) { - throw new Error('Missing authorization header'); - } - - const token = authHeader.replace('Bearer ', ''); - const payload = await verifyAuth0Token(token); - - // Check if user has admin/moderator role - const roles = extractRoles(payload); - const isAuthorized = roles.some(role => - ['admin', 'moderator', 'superuser'].includes(role) - ); - - if (!isAuthorized) { - return new Response( - JSON.stringify({ error: 'Unauthorized - admin role required' }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 403, - } - ); - } - - // Check if cached token is still valid (5 min buffer) - const now = Date.now() / 1000; - if (cachedToken && tokenExpiry > now + 300) { - return new Response( - JSON.stringify({ - access_token: cachedToken, - token_type: 'Bearer', - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - } - ); - } - - // Get new management token - const AUTH0_DOMAIN = Deno.env.get('AUTH0_DOMAIN')!; - const M2M_CLIENT_ID = Deno.env.get('AUTH0_M2M_CLIENT_ID')!; - const M2M_CLIENT_SECRET = Deno.env.get('AUTH0_M2M_CLIENT_SECRET')!; - - const response = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: M2M_CLIENT_ID, - client_secret: M2M_CLIENT_SECRET, - audience: `https://${AUTH0_DOMAIN}/api/v2/`, - grant_type: 'client_credentials', - }), - }); - - if (!response.ok) { - throw new Error('Failed to get management token'); - } - - const data = await response.json(); - - // Cache the token - cachedToken = data.access_token; - tokenExpiry = now + data.expires_in; - - return new Response( - JSON.stringify({ - access_token: data.access_token, - token_type: data.token_type, - expires_in: data.expires_in, - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - } - ); - } catch (error) { - console.error('[Auth0ManagementToken] Error:', error); - - return new Response( - JSON.stringify({ error: error.message }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 400, - } - ); - } -}); diff --git a/supabase/functions/auth0-get-roles/index.ts b/supabase/functions/auth0-get-roles/index.ts deleted file mode 100644 index 9f5a8d2e..00000000 --- a/supabase/functions/auth0-get-roles/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Auth0 Get Roles Edge Function - * - * Fetches user roles for authorization checks - */ - -import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { verifyAuth0Token, getUserId, extractRoles } from '../_shared/auth0Jwt.ts'; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -serve(async (req) => { - // Handle CORS preflight - if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }); - } - - try { - // Get Auth0 token from Authorization header - const authHeader = req.headers.get('authorization'); - if (!authHeader) { - throw new Error('Missing authorization header'); - } - - const token = authHeader.replace('Bearer ', ''); - const payload = await verifyAuth0Token(token); - const auth0Sub = getUserId(payload); - - // Try to get roles from JWT first - const jwtRoles = extractRoles(payload); - - // Create Supabase client - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - // Get profile by auth0_sub - const { data: profile, error: profileError } = await supabase - .from('profiles') - .select('id') - .eq('auth0_sub', auth0Sub) - .single(); - - if (profileError || !profile) { - // Return JWT roles if profile not found - return new Response( - JSON.stringify({ - success: true, - roles: jwtRoles, - source: 'jwt', - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - } - ); - } - - // Fetch roles from database - const { data: dbRoles, error: rolesError } = await supabase - .from('user_roles') - .select('role') - .eq('user_id', profile.id); - - if (rolesError) { - throw rolesError; - } - - const roles = dbRoles?.map(r => r.role) || []; - - // Also fetch permissions - const { data: permissions } = await supabase - .rpc('get_user_management_permissions', { _user_id: profile.id }); - - return new Response( - JSON.stringify({ - success: true, - roles, - permissions, - source: 'database', - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - } - ); - } catch (error) { - console.error('[Auth0GetRoles] Error:', error); - - return new Response( - JSON.stringify({ - success: false, - error: error.message, - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 400, - } - ); - } -}); diff --git a/supabase/functions/auth0-sync-user/index.ts b/supabase/functions/auth0-sync-user/index.ts deleted file mode 100644 index a8b793cf..00000000 --- a/supabase/functions/auth0-sync-user/index.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Auth0 User Sync Edge Function - * - * Syncs Auth0 user data to Supabase profiles table after authentication - */ - -import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { verifyAuth0Token, getUserId } from '../_shared/auth0Jwt.ts'; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -serve(async (req) => { - // Handle CORS preflight - if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }); - } - - try { - // Get Auth0 token from Authorization header - const authHeader = req.headers.get('authorization'); - if (!authHeader) { - throw new Error('Missing authorization header'); - } - - const token = authHeader.replace('Bearer ', ''); - const payload = await verifyAuth0Token(token); - const auth0Sub = getUserId(payload); - - // Parse request body - const { email, name, picture, email_verified } = await req.json(); - - // Create Supabase admin client - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - // Check if profile exists by auth0_sub - const { data: existingProfile } = await supabase - .from('profiles') - .select('id, username, email, auth0_sub') - .eq('auth0_sub', auth0Sub) - .single(); - - let profile; - - if (existingProfile) { - // Update existing profile - const { data, error } = await supabase - .from('profiles') - .update({ - email: email || existingProfile.email, - avatar_url: picture || existingProfile.avatar_url, - updated_at: new Date().toISOString(), - }) - .eq('auth0_sub', auth0Sub) - .select() - .single(); - - if (error) throw error; - profile = data; - } else { - // Check if profile exists by email (migration case) - const { data: emailProfile } = await supabase - .from('profiles') - .select('id, username, email') - .eq('email', email) - .is('auth0_sub', null) - .single(); - - if (emailProfile) { - // Link existing Supabase account to Auth0 - const { data, error } = await supabase - .from('profiles') - .update({ - auth0_sub: auth0Sub, - avatar_url: picture || emailProfile.avatar_url, - updated_at: new Date().toISOString(), - }) - .eq('id', emailProfile.id) - .select() - .single(); - - if (error) throw error; - profile = data; - } else { - // Create new profile - const username = email.split('@')[0] + '_' + Math.random().toString(36).substring(7); - - const { data, error } = await supabase - .from('profiles') - .insert({ - auth0_sub: auth0Sub, - email: email, - username: username, - display_name: name || username, - avatar_url: picture, - email_verified: email_verified || false, - }) - .select() - .single(); - - if (error) throw error; - profile = data; - } - } - - // Log sync to auth0_sync_log - await supabase - .from('auth0_sync_log') - .insert({ - auth0_sub: auth0Sub, - user_id: profile.id, - sync_status: 'success', - user_data: { email, name, picture }, - }); - - // Fetch user roles - const { data: roles } = await supabase - .from('user_roles') - .select('role') - .eq('user_id', profile.id); - - return new Response( - JSON.stringify({ - success: true, - profile: { - id: profile.id, - auth0_sub: profile.auth0_sub, - username: profile.username, - email: profile.email, - avatar_url: profile.avatar_url, - roles: roles?.map(r => r.role) || [], - }, - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - } - ); - } catch (error) { - console.error('[Auth0Sync] Error:', error); - - return new Response( - JSON.stringify({ - success: false, - error: error.message, - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 400, - } - ); - } -}); diff --git a/supabase/functions/auth0-webhook/index.ts b/supabase/functions/auth0-webhook/index.ts deleted file mode 100644 index 7cd31872..00000000 --- a/supabase/functions/auth0-webhook/index.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Auth0 Webhook Edge Function - * - * Handles Auth0 webhook events for real-time sync - */ - -import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { createHmac } from 'https://deno.land/std@0.168.0/node/crypto.ts'; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-auth0-signature', -}; - -/** - * Verify Auth0 webhook signature - */ -function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean { - const hmac = createHmac('sha256', secret); - hmac.update(payload); - const expectedSignature = hmac.digest('hex'); - return signature === expectedSignature; -} - -serve(async (req) => { - // Handle CORS preflight - if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }); - } - - try { - // Get webhook secret - const WEBHOOK_SECRET = Deno.env.get('AUTH0_WEBHOOK_SECRET'); - if (!WEBHOOK_SECRET) { - throw new Error('Webhook secret not configured'); - } - - // Verify signature - const signature = req.headers.get('x-auth0-signature'); - const body = await req.text(); - - if (signature && !verifyWebhookSignature(body, signature, WEBHOOK_SECRET)) { - return new Response( - JSON.stringify({ error: 'Invalid signature' }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 401, - } - ); - } - - const event = JSON.parse(body); - const { type, data } = event; - - // Create Supabase admin client - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - // Handle different event types - switch (type) { - case 'post-login': { - // Update last login timestamp - const auth0Sub = data.user?.user_id; - if (auth0Sub) { - await supabase - .from('profiles') - .update({ updated_at: new Date().toISOString() }) - .eq('auth0_sub', auth0Sub); - - // Log to audit log - await supabase.rpc('log_admin_action', { - p_user_id: null, - p_action: 'auth0_login', - p_details: { auth0_sub: auth0Sub, event_type: 'post-login' }, - }); - } - break; - } - - case 'post-change-password': { - // Log password change - const auth0Sub = data.user?.user_id; - if (auth0Sub) { - await supabase.rpc('log_admin_action', { - p_user_id: null, - p_action: 'password_changed', - p_details: { auth0_sub: auth0Sub, event_type: 'post-change-password' }, - }); - } - break; - } - - case 'post-user-registration': { - // Create initial profile if doesn't exist - const auth0Sub = data.user?.user_id; - const email = data.user?.email; - - if (auth0Sub && email) { - const { data: existing } = await supabase - .from('profiles') - .select('id') - .eq('auth0_sub', auth0Sub) - .single(); - - if (!existing) { - const username = email.split('@')[0] + '_' + Math.random().toString(36).substring(7); - - await supabase - .from('profiles') - .insert({ - auth0_sub: auth0Sub, - email: email, - username: username, - display_name: data.user?.name || username, - }); - } - } - break; - } - - default: - console.log('[Auth0Webhook] Unhandled event type:', type); - } - - // Log webhook event - await supabase - .from('auth0_sync_log') - .insert({ - auth0_sub: data.user?.user_id || 'unknown', - sync_status: 'success', - user_data: { event_type: type, data }, - }); - - return new Response( - JSON.stringify({ success: true }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - } - ); - } catch (error) { - console.error('[Auth0Webhook] Error:', error); - - return new Response( - JSON.stringify({ error: error.message }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 500, - } - ); - } -}); diff --git a/supabase/functions/check-mfa-enrollment/index.ts b/supabase/functions/check-mfa-enrollment/index.ts deleted file mode 100644 index 301584b3..00000000 --- a/supabase/functions/check-mfa-enrollment/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -const supabaseUrl = Deno.env.get('SUPABASE_URL')!; -const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - -Deno.serve(async (req) => { - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - try { - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - return new Response( - JSON.stringify({ error: 'Missing authorization header' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Create client with user's token to verify session - const supabase = createClient(supabaseUrl, supabaseServiceKey, { - global: { - headers: { Authorization: authHeader }, - }, - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }); - - const { data: { user }, error: userError } = await supabase.auth.getUser(); - - if (userError || !user) { - console.error('User verification error:', userError); - return new Response( - JSON.stringify({ error: 'Unauthorized' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Create admin client to check MFA factors - const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }); - - const { data: factors, error: factorsError } = await supabaseAdmin.auth.admin.mfa.listFactors({ - userId: user.id, - }); - - if (factorsError) { - console.error('MFA factors check error:', factorsError); - return new Response( - JSON.stringify({ error: 'Failed to check MFA enrollment' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - const verifiedFactors = factors?.totp?.filter((f) => f.status === 'verified') || []; - const hasEnrolled = verifiedFactors.length > 0; - const factorId = verifiedFactors.length > 0 ? verifiedFactors[0].id : undefined; - - return new Response( - JSON.stringify({ - hasEnrolled, - factorId, - }), - { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } catch (error) { - console.error('Unexpected error:', error); - return new Response( - JSON.stringify({ error: 'Internal server error' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } -}); diff --git a/supabase/functions/verify-mfa-and-login/index.ts b/supabase/functions/verify-mfa-and-login/index.ts deleted file mode 100644 index c8c84f5a..00000000 --- a/supabase/functions/verify-mfa-and-login/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -const supabaseUrl = Deno.env.get('SUPABASE_URL')!; -const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - -Deno.serve(async (req) => { - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - try { - const { challengeId, factorId, code, userId } = await req.json(); - - if (!challengeId || !factorId || !code || !userId) { - return new Response( - JSON.stringify({ error: 'Missing required fields' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Create admin client - const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }); - - // Verify TOTP code - const { data: verifyData, error: verifyError } = await supabaseAdmin.auth.mfa.verify({ - factorId, - challengeId, - code, - }); - - if (verifyError || !verifyData) { - console.error('MFA verification error:', verifyError); - return new Response( - JSON.stringify({ error: verifyError?.message || 'Invalid verification code' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Verification successful - create AAL2 session using admin API - const { data: sessionData, error: sessionError } = await supabaseAdmin.auth.admin.createSession({ - userId, - // This creates a session with AAL2 - }); - - if (sessionError || !sessionData) { - console.error('Session creation error:', sessionError); - return new Response( - JSON.stringify({ error: 'Failed to create session' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Log successful MFA authentication - try { - await supabaseAdmin.rpc('log_admin_action', { - _admin_user_id: userId, - _target_user_id: userId, - _action: 'mfa_login_success', - _details: { timestamp: new Date().toISOString(), aal: 'aal2' }, - }); - } catch (logError) { - console.error('Audit log error:', logError); - // Don't fail the login if audit logging fails - } - - return new Response( - JSON.stringify({ - success: true, - session: sessionData.session, - user: sessionData.user, - }), - { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } catch (error) { - console.error('Unexpected error:', error); - return new Response( - JSON.stringify({ error: 'Internal server error' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } -}); diff --git a/supabase/migrations/20251031170759_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql b/supabase/migrations/20251031170759_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql deleted file mode 100644 index d848c2dd..00000000 --- a/supabase/migrations/20251031170759_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql +++ /dev/null @@ -1,65 +0,0 @@ --- Create security definer function to block AAL1 sessions when MFA is enrolled -CREATE OR REPLACE FUNCTION public.block_aal1_with_mfa() -RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = public -AS $$ - SELECT - CASE - -- If current session is AAL1 - WHEN (auth.jwt() ->> 'aal') = 'aal1' THEN - -- Check if user has verified MFA factors - NOT EXISTS ( - SELECT 1 FROM auth.mfa_factors - WHERE user_id = auth.uid() - AND status = 'verified' - ) - -- If AAL2 or higher, allow - ELSE true - END; -$$; - --- Apply to profiles table -CREATE POLICY "enforce_aal2_for_mfa_users" ON public.profiles -FOR ALL -USING (public.block_aal1_with_mfa()); - --- Apply to user_roles table -CREATE POLICY "enforce_aal2_for_mfa_users_roles" ON public.user_roles -FOR ALL -USING (public.block_aal1_with_mfa()); - --- Apply to submission tables -CREATE POLICY "enforce_aal2_for_mfa_users_park_sub" ON public.park_submissions -FOR ALL -USING (public.block_aal1_with_mfa()); - -CREATE POLICY "enforce_aal2_for_mfa_users_ride_sub" ON public.ride_submissions -FOR ALL -USING (public.block_aal1_with_mfa()); - -CREATE POLICY "enforce_aal2_for_mfa_users_company_sub" ON public.company_submissions -FOR ALL -USING (public.block_aal1_with_mfa()); - -CREATE POLICY "enforce_aal2_for_mfa_users_content_sub" ON public.content_submissions -FOR ALL -USING (public.block_aal1_with_mfa()); - -CREATE POLICY "enforce_aal2_for_mfa_users_photo_sub" ON public.photo_submissions -FOR ALL -USING (public.block_aal1_with_mfa()); - --- Apply to user content tables -CREATE POLICY "enforce_aal2_for_mfa_users_reviews" ON public.reviews -FOR ALL -USING (public.block_aal1_with_mfa()); - -CREATE POLICY "enforce_aal2_for_mfa_users_reports" ON public.reports -FOR ALL -USING (public.block_aal1_with_mfa()); - --- Grant execute permission -GRANT EXECUTE ON FUNCTION public.block_aal1_with_mfa() TO authenticated; \ No newline at end of file diff --git a/supabase/migrations/20251101010106_13451034-559b-4b45-956c-b2cbeb9dda7f.sql b/supabase/migrations/20251101010106_13451034-559b-4b45-956c-b2cbeb9dda7f.sql deleted file mode 100644 index 727b9345..00000000 --- a/supabase/migrations/20251101010106_13451034-559b-4b45-956c-b2cbeb9dda7f.sql +++ /dev/null @@ -1,154 +0,0 @@ --- Phase 1: Auth0 Migration - Add auth0_sub column and update RLS policies --- This migration prepares the database to support Auth0 authentication alongside Supabase auth - --- Add auth0_sub column to profiles table -ALTER TABLE public.profiles -ADD COLUMN IF NOT EXISTS auth0_sub TEXT UNIQUE; - --- Create index for faster auth0_sub lookups -CREATE INDEX IF NOT EXISTS idx_profiles_auth0_sub ON public.profiles(auth0_sub); - --- Add comment explaining the column -COMMENT ON COLUMN public.profiles.auth0_sub IS 'Auth0 user identifier (sub claim from JWT). Used for Auth0 authentication integration.'; - --- Update RLS policies to support both Supabase and Auth0 authentication --- We'll keep existing policies and add Auth0 support - --- Create helper function to get current user ID (works with both Supabase and Auth0) -CREATE OR REPLACE FUNCTION public.get_current_user_id() -RETURNS uuid -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_user_id uuid; - v_auth0_sub text; -BEGIN - -- Try Supabase auth first - v_user_id := auth.uid(); - - IF v_user_id IS NOT NULL THEN - RETURN v_user_id; - END IF; - - -- Try Auth0 sub from JWT - v_auth0_sub := current_setting('request.jwt.claims', true)::json->>'sub'; - - IF v_auth0_sub IS NOT NULL THEN - -- Look up user_id by auth0_sub - SELECT user_id INTO v_user_id - FROM public.profiles - WHERE auth0_sub = v_auth0_sub; - - RETURN v_user_id; - END IF; - - -- No authenticated user found - RETURN NULL; -END; -$$; - --- Add audit log column for Auth0 events -ALTER TABLE public.admin_audit_log -ADD COLUMN IF NOT EXISTS auth0_event_type TEXT; - -COMMENT ON COLUMN public.admin_audit_log.auth0_event_type IS 'Type of Auth0 event that triggered this audit log entry (e.g., mfa_enrollment, login, role_change)'; - --- Create table for tracking Auth0 sync status -CREATE TABLE IF NOT EXISTS public.auth0_sync_log ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - auth0_sub text NOT NULL, - user_id uuid REFERENCES public.profiles(user_id), - sync_type text NOT NULL, -- 'user_created', 'profile_updated', 'mfa_enrolled', etc. - sync_status text NOT NULL DEFAULT 'pending', -- 'pending', 'completed', 'failed' - error_message text, - metadata jsonb DEFAULT '{}'::jsonb, - created_at timestamp with time zone NOT NULL DEFAULT now(), - completed_at timestamp with time zone -); - --- Create index on auth0_sync_log -CREATE INDEX IF NOT EXISTS idx_auth0_sync_log_auth0_sub ON public.auth0_sync_log(auth0_sub); -CREATE INDEX IF NOT EXISTS idx_auth0_sync_log_user_id ON public.auth0_sync_log(user_id); -CREATE INDEX IF NOT EXISTS idx_auth0_sync_log_status ON public.auth0_sync_log(sync_status); - --- Enable RLS on auth0_sync_log -ALTER TABLE public.auth0_sync_log ENABLE ROW LEVEL SECURITY; - --- RLS policies for auth0_sync_log -CREATE POLICY "Moderators can view all sync logs" - ON public.auth0_sync_log - FOR SELECT - TO authenticated - USING (is_moderator(auth.uid())); - -CREATE POLICY "System can insert sync logs" - ON public.auth0_sync_log - FOR INSERT - TO authenticated - WITH CHECK (true); - -CREATE POLICY "Users can view their own sync logs" - ON public.auth0_sync_log - FOR SELECT - TO authenticated - USING (user_id = auth.uid()); - --- Create function to extract Auth0 sub from JWT -CREATE OR REPLACE FUNCTION public.get_auth0_sub_from_jwt() -RETURNS text -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - RETURN current_setting('request.jwt.claims', true)::json->>'sub'; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$; - --- Create function to check if user is authenticated via Auth0 -CREATE OR REPLACE FUNCTION public.is_auth0_user() -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_iss text; -BEGIN - v_iss := current_setting('request.jwt.claims', true)::json->>'iss'; - -- Auth0 issuer format: https://.auth0.com/ or https://..auth0.com/ - RETURN v_iss LIKE '%auth0.com%'; -EXCEPTION - WHEN OTHERS THEN - RETURN false; -END; -$$; - --- Create function to check Auth0 MFA status from JWT claims -CREATE OR REPLACE FUNCTION public.has_auth0_mfa() -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_amr jsonb; -BEGIN - -- Check if 'mfa' is in the amr (Authentication Methods Reference) array - v_amr := current_setting('request.jwt.claims', true)::json->'amr'; - RETURN v_amr ? 'mfa'; -EXCEPTION - WHEN OTHERS THEN - RETURN false; -END; -$$; - -COMMENT ON FUNCTION public.get_current_user_id IS 'Returns the current user ID, supporting both Supabase auth.uid() and Auth0 sub claim lookup'; -COMMENT ON FUNCTION public.get_auth0_sub_from_jwt IS 'Extracts the Auth0 sub claim from the JWT token'; -COMMENT ON FUNCTION public.is_auth0_user IS 'Checks if the current user is authenticated via Auth0'; -COMMENT ON FUNCTION public.has_auth0_mfa IS 'Checks if the current Auth0 user has completed MFA (checks amr claim for mfa)'; \ No newline at end of file