diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6c0c5ff..db48442 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "Bash(ren page.tsx page-original.tsx)", "Bash(ren:*)", "Bash(npx supabase:*)", - "Bash(libs/service/src/types/supabase.ts)" + "Bash(libs/service/src/types/supabase.ts)", + "Bash(npx nx build:*)" ], "deny": [], "ask": [] diff --git a/apps/hackathon/src/app/auth/callback/page.tsx b/apps/hackathon/src/app/auth/callback/page.tsx index 1144c72..1346cc8 100644 --- a/apps/hackathon/src/app/auth/callback/page.tsx +++ b/apps/hackathon/src/app/auth/callback/page.tsx @@ -1,11 +1,11 @@ import { FC, ReactElement, useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router'; -import { useAuthStore, supabase } from '@imphnen-frontend-service/service'; +import { useGitHubCallback } from '@imphnen-frontend-service/service'; import { toast } from 'sonner'; const CallbackPage: FC = (): ReactElement => { const navigate = useNavigate(); - const { setSession } = useAuthStore(); + const { mutateAsync: exchangeGitHubCode } = useGitHubCallback(); const [isProcessing, setIsProcessing] = useState(true); const [error, setError] = useState(null); const hasRunRef = useRef(false); @@ -13,134 +13,33 @@ const CallbackPage: FC = (): ReactElement => { useEffect(() => { const handleCallback = async () => { if (hasRunRef.current) { - // console.log('[Callback] Already processed, skipping...'); return; } hasRunRef.current = true; + try { - // console.log('[Callback] Processing OAuth callback...'); - // console.log('[Callback] Current URL:', globalThis.location.href); + // Get the code from URL query params + const urlParams = new URLSearchParams(globalThis.location.search); + const code = urlParams.get('code'); - // Supabase client is configured with detectSessionInUrl: true - // This means Supabase automatically detects and processes OAuth tokens from the URL hash - // We just need to wait a moment for it to complete, then check for the session - - // console.log('[Callback] Waiting for Supabase to process OAuth callback...'); - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Get the session that Supabase automatically created from the URL hash - const { - data: { session: sessionData }, - error: sessionError, - } = await supabase.auth.getSession(); - - if (sessionError) { - // console.error('[Callback] Session error:', sessionError); - throw new Error(sessionError.message || 'Failed to get session'); + if (!code) { + throw new Error('No authorization code received from GitHub'); } - if (!sessionData || !sessionData.user) { - throw new Error( - 'No session found after OAuth callback. Please try logging in again.' - ); - } + // Exchange the code for tokens using backend API + const result = await exchangeGitHubCode({ code }); - // console.log('[Callback] Supabase session established:', { - // userId: sessionData.user.id, - // email: sessionData.user.email, - // }); - - // Create/update user in the users table (for foreign key constraints) - // console.log('[Callback] Creating/updating user record...'); - const { data: userData, error: upsertError } = await supabase - .from('users') - .upsert( - { - id: sessionData.user.id, - email: sessionData.user.email || '', - fullname: - sessionData.user.user_metadata?.full_name || - sessionData.user.user_metadata?.name || - sessionData.user.email?.split('@')[0] || - '', - avatar: sessionData.user.user_metadata?.avatar_url || '', - is_active: true, - updated_at: new Date().toISOString(), - }, - { - onConflict: 'id', - } - ) - .select() - .single(); - - if (upsertError) { - // console.warn('[Callback] Failed to create user record:', upsertError); - // Don't throw - continue with login even if user record creation fails - } else { - // console.log('[Callback] User record created/updated successfully'); - } - - // Store user-friendly data in Zustand for UI purposes - // Supabase now manages the actual auth session - // Use data from database if available, otherwise use OAuth metadata - const userRecord = userData || { - id: sessionData.user.id, - email: sessionData.user.email || '', - fullname: - sessionData.user.user_metadata?.full_name || - sessionData.user.user_metadata?.name || - sessionData.user.email?.split('@')[0] || - '', - avatar: sessionData.user.user_metadata?.avatar_url || '', - phone_number: '', - birthdate: '', - gender: '', - is_active: true, - }; - - setSession({ - token: { - access_token: sessionData.access_token, - refresh_token: sessionData.refresh_token || '', - }, - user: { - id: userRecord.id, - email: userRecord.email, - fullname: userRecord.fullname, - phone_number: userRecord.phone_number || '', - avatar: userRecord.avatar || '', - birthdate: userRecord.birthdate || '', - gender: userRecord.gender || '', - is_active: userRecord.is_active, - location: userRecord.location, - bio: userRecord.bio, - skills: userRecord.skills, - role: { - id: '', - name: 'user', - permissions: [], - created_at: '', - updated_at: '', - }, - }, - }); - - // console.log('[Callback] Session stored successfully'); toast.success('Login successful!'); setIsProcessing(false); // Check if user has completed onboarding (has location) - // Use globalThis.location.replace for hard redirect to prevent history issues - if (userRecord.location) { - // console.log('[Callback] User has completed onboarding, redirecting to dashboard...'); + if (result.user.location) { globalThis.location.replace('/dashboard'); } else { - // console.log('[Callback] User needs onboarding, redirecting...'); globalThis.location.replace('/onboarding/user'); } } catch (err) { - // console.error('[Callback] Error:', err); + console.error('[Callback] Error:', err); setError((err as Error).message); setIsProcessing(false); toast.error('An error occurred during login'); @@ -153,21 +52,21 @@ const CallbackPage: FC = (): ReactElement => { handleCallback(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Run only once on mount + }, []); if (error) { return ( -
-
+
+
⚠️
-

+

GitHub Login Failed

-

{error}

+

{error}

-

+

Redirecting to login page in 3 seconds...

diff --git a/apps/hackathon/src/app/auth/login/page.tsx b/apps/hackathon/src/app/auth/login/page.tsx index 87f4ca3..59a872c 100644 --- a/apps/hackathon/src/app/auth/login/page.tsx +++ b/apps/hackathon/src/app/auth/login/page.tsx @@ -1,9 +1,7 @@ import { useState } from 'react'; import { useGitHubAuth, - useEmailAuth, - supabase, - useAuthStore, + useLogin, } from '@imphnen-frontend-service/service'; import { GithubOutlined } from '@ant-design/icons'; import { useNavigate, Link } from 'react-router'; @@ -12,14 +10,10 @@ import { Icon } from '@iconify/react'; import { ThemeToggle } from '../../../components/theme-toggle'; export default function LoginPage() { - // console.log('[LoginPage] Rendering...'); - const navigate = useNavigate(); - const { setSession } = useAuthStore(); const { signInWithGitHub } = useGitHubAuth(); - const { signInWithEmail } = useEmailAuth(); + const loginMutation = useLogin(); const [isGithubLoading, setIsGithubLoading] = useState(false); - const [isEmailLoading, setIsEmailLoading] = useState(false); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); @@ -34,52 +28,12 @@ export default function LoginPage() { } try { - setIsEmailLoading(true); - // console.log('[Login] Attempting email login...'); - - const result = await signInWithEmail(email, password); - // console.log('[Login] Email login successful:', result); - - // Get user data from database - const { data: userData } = await supabase - .from('users') - .select('*') - .eq('id', result.user.id) - .single(); - - // Store session in Zustand - setSession({ - token: { - access_token: result.session.access_token, - refresh_token: result.session.refresh_token || '', - }, - user: { - id: result.user.id, - email: result.user.email || '', - fullname: - userData?.fullname || result.user.user_metadata?.full_name || '', - phone_number: userData?.phone_number || '', - avatar: userData?.avatar || '', - birthdate: userData?.birthdate || '', - gender: userData?.gender || '', - is_active: userData?.is_active || true, - location: userData?.location, - bio: userData?.bio, - skills: userData?.skills, - role: { - id: '', - name: 'user', - permissions: [], - created_at: '', - updated_at: '', - }, - }, - }); + const result = await loginMutation.mutateAsync({ email, password }); toast.success('Login successful!'); // Redirect based on onboarding status - if (userData?.location) { + if (result.user.location) { navigate('/dashboard'); } else { navigate('/onboarding/user'); @@ -87,29 +41,25 @@ export default function LoginPage() { } catch (err) { console.error('[Login] Email login failed:', err); setError((err as Error).message || 'Login failed'); - setIsEmailLoading(false); } }; const handleGithubLogin = async () => { try { setIsGithubLoading(true); - // console.log('[Login] Initiating GitHub OAuth...'); const result = await signInWithGitHub(); - // console.log('[Login] OAuth result:', result); // Check if we got a redirect URL if (result?.url) { - // console.log('[Login] Redirecting to GitHub OAuth:', result.url); - // Manually redirect immediately globalThis.location.href = result.url; } else { - // console.error('[Login] No OAuth URL returned'); setIsGithubLoading(false); + setError('Failed to get GitHub OAuth URL'); } - } catch (error) { - // console.error('[Login] GitHub login failed'); + } catch (err) { + console.error('[Login] GitHub login failed:', err); + setError((err as Error).message || 'GitHub login failed'); setIsGithubLoading(false); } }; @@ -157,7 +107,7 @@ export default function LoginPage() { value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" - disabled={isEmailLoading} + disabled={loginMutation.isPending} className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" required /> @@ -184,7 +134,7 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" - disabled={isEmailLoading} + disabled={loginMutation.isPending} className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" required /> @@ -192,10 +142,10 @@ export default function LoginPage() { diff --git a/apps/hackathon/src/app/auth/signup/page.tsx b/apps/hackathon/src/app/auth/signup/page.tsx index 4fcee88..7b25ae5 100644 --- a/apps/hackathon/src/app/auth/signup/page.tsx +++ b/apps/hackathon/src/app/auth/signup/page.tsx @@ -1,9 +1,7 @@ import { useState } from 'react'; import { useGitHubAuth, - useEmailAuth, - supabase, - useAuthStore, + useSignup, } from '@imphnen-frontend-service/service'; import { GithubOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router'; @@ -13,11 +11,9 @@ import { ThemeToggle } from '../../../components/theme-toggle'; export default function SignupPage() { const navigate = useNavigate(); - const { setSession } = useAuthStore(); const { signInWithGitHub } = useGitHubAuth(); - const { signUpWithEmail } = useEmailAuth(); + const signupMutation = useSignup(); const [isGithubLoading, setIsGithubLoading] = useState(false); - const [isEmailLoading, setIsEmailLoading] = useState(false); const [fullname, setFullname] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -44,95 +40,31 @@ export default function SignupPage() { } try { - setIsEmailLoading(true); - // console.log('[Signup] Attempting email signup...'); + await signupMutation.mutateAsync({ email, password, fullname }); - const result = await signUpWithEmail(email, password, fullname); - // console.log('[Signup] Email signup successful:', result); - - if (!result.user) { - throw new Error('Signup failed - no user returned'); - } - - // Create user record in database - const { error: upsertError } = await supabase.from('users').upsert( - { - id: result.user.id, - email: result.user.email || '', - fullname: fullname, - is_active: true, - updated_at: new Date().toISOString(), - }, - { - onConflict: 'id', - } - ); - - if (upsertError) { - console.warn('[Signup] Failed to create user record'); - } - - // If session is available (email confirmation disabled), store it - if (result.session) { - setSession({ - token: { - access_token: result.session.access_token, - refresh_token: result.session.refresh_token || '', - }, - user: { - id: result.user.id, - email: result.user.email || '', - fullname: fullname, - phone_number: '', - avatar: '', - birthdate: '', - gender: '', - is_active: true, - role: { - id: '', - name: 'user', - permissions: [], - created_at: '', - updated_at: '', - }, - }, - }); - - toast.success('Account created successfully!'); - navigate('/onboarding/user'); - } else { - // Email confirmation is enabled - toast.success( - 'Account created! Please check your email to verify your account.' - ); - setTimeout(() => { - navigate('/auth/login'); - }, 2000); - } + toast.success('Account created successfully!'); + navigate('/onboarding/user'); } catch (err) { - // console.error('[Signup] Email signup failed:', err); + console.error('[Signup] Email signup failed:', err); setError((err as Error).message || 'Signup failed'); - setIsEmailLoading(false); } }; const handleGithubLogin = async () => { try { setIsGithubLoading(true); - // console.log('[Signup] Initiating GitHub OAuth...'); const result = await signInWithGitHub(); - // console.log('[Signup] OAuth result:', result); if (result?.url) { - // console.log('[Signup] Redirecting to GitHub OAuth:', result.url); globalThis.location.href = result.url; } else { - // console.error('[Signup] No OAuth URL returned'); setIsGithubLoading(false); + setError('Failed to get GitHub OAuth URL'); } - } catch (error) { - // console.error('[Signup] GitHub login failed:', error); + } catch (err) { + console.error('[Signup] GitHub login failed:', err); + setError((err as Error).message || 'GitHub login failed'); setIsGithubLoading(false); } }; @@ -180,7 +112,7 @@ export default function SignupPage() { value={fullname} onChange={(e) => setFullname(e.target.value)} placeholder="John Doe" - disabled={isEmailLoading} + disabled={signupMutation.isPending} className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" required /> @@ -199,7 +131,7 @@ export default function SignupPage() { value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" - disabled={isEmailLoading} + disabled={signupMutation.isPending} className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" required /> @@ -218,7 +150,7 @@ export default function SignupPage() { value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" - disabled={isEmailLoading} + disabled={signupMutation.isPending} className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" required /> @@ -237,7 +169,7 @@ export default function SignupPage() { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="••••••••" - disabled={isEmailLoading} + disabled={signupMutation.isPending} className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" required /> @@ -245,10 +177,10 @@ export default function SignupPage() { diff --git a/libs/service/src/api/hackathon.ts b/libs/service/src/api/hackathon.ts new file mode 100644 index 0000000..263ae31 --- /dev/null +++ b/libs/service/src/api/hackathon.ts @@ -0,0 +1,55 @@ +import axios from 'axios'; +import { useAuthStore } from '../hooks/auth'; + +// Hackathon Backend API Base URL +const HACKATHON_API_URL = 'https://api.hackathon.imphnen.dev/api/v1'; + +// Create axios instance for hackathon backend +export const hackathonApi = axios.create({ + baseURL: HACKATHON_API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Add auth token interceptor +hackathonApi.interceptors.request.use( + (config) => { + const { session } = useAuthStore.getState(); + if (session?.token) { + config.headers.Authorization = `Bearer ${session.token}`; + } + return config; + }, + (error) => { + return Promise.reject(new Error(error.message || 'Request failed')); + } +); + +// Error handling interceptor +hackathonApi.interceptors.response.use( + (response) => response, + (error) => { + // Handle 401 - clear session and redirect to login + if (error.response?.status === 401) { + useAuthStore.getState().clearSession(); + if (typeof window !== 'undefined') { + window.location.href = '/auth/login'; + } + } + + // If backend sends a message, use it + const backendMsg = error?.response?.data?.message; + if (backendMsg && typeof backendMsg === 'string') { + return Promise.reject(new Error(backendMsg)); + } + + return Promise.reject(new Error(error.message || 'Request failed')); + } +); + +// API Response wrapper type +export interface HackathonApiResponse { + data: T; + message?: string; +} diff --git a/libs/service/src/api/index.ts b/libs/service/src/api/index.ts index bc22a24..b6f6c8c 100644 --- a/libs/service/src/api/index.ts +++ b/libs/service/src/api/index.ts @@ -5,6 +5,7 @@ export * from './gacha'; export * from './users'; export * from './mentors'; export * from './upload'; +export * from './hackathon'; // Common API response wrapper interface export interface ApiResponse { diff --git a/libs/service/src/hooks/auth/index.ts b/libs/service/src/hooks/auth/index.ts index b37cbf7..fe4a832 100644 --- a/libs/service/src/hooks/auth/index.ts +++ b/libs/service/src/hooks/auth/index.ts @@ -1,57 +1,278 @@ -import { supabase } from '../../supabase'; -import { useMutation } from '@tanstack/react-query'; -import * as authApi from '../../api/auth'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; +import { useAuthStore } from './use-auth-store'; export * from './use-auth-store'; -// React Query hooks for auth API -export const usePostLogin = () => { +// Types matching backend response +interface TokenInfo { + access_token: string; + refresh_token: string; +} + +interface User { + id: string; + email: string; + fullname: string; + phone_number?: string; + avatar?: string; + birthdate?: string; + gender?: string; + is_active: boolean; + location?: string; + bio?: string; + skills?: string[]; + role_id?: string; + created_at: string; + updated_at?: string; +} + +interface AuthResponse { + token: TokenInfo; + user: User; +} + +interface SessionResponse { + user: User; +} + +interface MessageResponse { + message: string; +} + +// Login request type +interface LoginRequest { + email: string; + password: string; +} + +// Signup request type +interface SignupRequest { + email: string; + password: string; + fullname: string; +} + +// GitHub auth request type +interface GitHubAuthRequest { + code: string; +} + +// Forgot password request type +interface ForgotPasswordRequest { + email: string; +} + +// Reset password request type +interface ResetPasswordRequest { + access_token: string; + new_password: string; +} + +// Backend API-based auth hooks + +// Email/Password Login +export const useLogin = () => { + const { setSession } = useAuthStore(); + return useMutation({ - mutationFn: authApi.postLogin, + mutationFn: async (data: LoginRequest) => { + const response = await hackathonApi.post>( + '/auth/login', + data + ); + return response.data.data; + }, + onSuccess: (data) => { + setSession({ + token: data.token, + user: { + id: data.user.id, + email: data.user.email, + fullname: data.user.fullname, + phone_number: data.user.phone_number || '', + avatar: data.user.avatar || '', + birthdate: data.user.birthdate || '', + gender: data.user.gender || '', + is_active: data.user.is_active, + location: data.user.location, + bio: data.user.bio, + skills: data.user.skills, + role: { + id: '', + name: 'user', + permissions: [], + created_at: '', + updated_at: '', + }, + }, + }); + }, }); }; -export const usePostRegister = () => { +// Email/Password Signup +export const useSignup = () => { + const { setSession } = useAuthStore(); + return useMutation({ - mutationFn: authApi.postRegister, + mutationFn: async (data: SignupRequest) => { + const response = await hackathonApi.post>( + '/auth/signup', + data + ); + return response.data.data; + }, + onSuccess: (data) => { + setSession({ + token: data.token, + user: { + id: data.user.id, + email: data.user.email, + fullname: data.user.fullname, + phone_number: data.user.phone_number || '', + avatar: data.user.avatar || '', + birthdate: data.user.birthdate || '', + gender: data.user.gender || '', + is_active: data.user.is_active, + location: data.user.location, + bio: data.user.bio, + skills: data.user.skills, + role: { + id: '', + name: 'user', + permissions: [], + created_at: '', + updated_at: '', + }, + }, + }); + }, }); }; -export const usePostVerifyEmail = () => { +// GitHub OAuth - exchange code for token +export const useGitHubCallback = () => { + const { setSession } = useAuthStore(); + return useMutation({ - mutationFn: authApi.postVerifyEmail, + mutationFn: async (data: GitHubAuthRequest) => { + const response = await hackathonApi.post>( + '/auth/github', + data + ); + return response.data.data; + }, + onSuccess: (data) => { + setSession({ + token: data.token, + user: { + id: data.user.id, + email: data.user.email, + fullname: data.user.fullname, + phone_number: data.user.phone_number || '', + avatar: data.user.avatar || '', + birthdate: data.user.birthdate || '', + gender: data.user.gender || '', + is_active: data.user.is_active, + location: data.user.location, + bio: data.user.bio, + skills: data.user.skills, + role: { + id: '', + name: 'user', + permissions: [], + created_at: '', + updated_at: '', + }, + }, + }); + }, }); }; -export const usePostSendOtp = () => { - return useMutation({ - mutationFn: authApi.postSendOtp, +// Get current session (protected) +export const useSession = () => { + const { session } = useAuthStore(); + + return useQuery({ + queryKey: ['auth-session'], + queryFn: async () => { + const response = await hackathonApi.get>( + '/auth/session' + ); + return response.data.data; + }, + enabled: !!session?.token, }); }; -export const useGoogleCallback = () => { +// Forgot password +export const useForgotPassword = () => { return useMutation({ - mutationFn: ({ code, state }: { code: string; state: string }) => - authApi.postGoogleCallback(code, state), + mutationFn: async (data: ForgotPasswordRequest) => { + const response = await hackathonApi.post>( + '/auth/forgot-password', + data + ); + return response.data.data; + }, }); }; -// Supabase GitHub OAuth hook +// Reset password +export const useResetPassword = () => { + return useMutation({ + mutationFn: async (data: ResetPasswordRequest) => { + const response = await hackathonApi.post>( + '/auth/reset-password', + data + ); + return response.data.data; + }, + }); +}; + +// Sign out (clears local session) +export const useSignOut = () => { + const { clearSession } = useAuthStore(); + + return useMutation({ + mutationFn: async () => { + // No backend call needed - just clear local session + clearSession(); + return { success: true }; + }, + }); +}; + +// GitHub OAuth URL helper +// The frontend needs to redirect to GitHub with the client_id +// After GitHub redirects back with a code, use useGitHubCallback +export const getGitHubOAuthUrl = (clientId: string, redirectUri: string) => { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: 'read:user user:email', + }); + return `https://github.com/login/oauth/authorize?${params.toString()}`; +}; + +// Backward compatibility hooks - these wrap the new backend API + +// GitHub OAuth hook (backward compatible) export const useGitHubAuth = () => { const signInWithGitHub = async () => { - const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github', - options: { - redirectTo: `${globalThis.location.origin}/auth/callback`, - }, - }); - - if (error) { - throw error; + // Get GitHub client ID from environment + const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || ''; + if (!clientId) { + throw new Error('GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.'); } - // Return the OAuth URL for debugging - return data; + const redirectUri = `${globalThis.location.origin}/auth/callback`; + const url = getGitHubOAuthUrl(clientId, redirectUri); + + return { url }; }; return { @@ -59,45 +280,36 @@ export const useGitHubAuth = () => { }; }; -// Supabase Email/Password authentication hook +// Email/Password auth hook (backward compatible) export const useEmailAuth = () => { + const loginMutation = useLogin(); + const signupMutation = useSignup(); + const { clearSession } = useAuthStore(); + const signInWithEmail = async (email: string, password: string) => { - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error) { - throw error; - } - - return data; + const result = await loginMutation.mutateAsync({ email, password }); + return { + user: result.user, + session: { + access_token: result.token.access_token, + refresh_token: result.token.refresh_token, + }, + }; }; const signUpWithEmail = async (email: string, password: string, fullname: string) => { - const { data, error } = await supabase.auth.signUp({ - email, - password, - options: { - data: { - full_name: fullname, - }, + const result = await signupMutation.mutateAsync({ email, password, fullname }); + return { + user: result.user, + session: { + access_token: result.token.access_token, + refresh_token: result.token.refresh_token, }, - }); - - if (error) { - throw error; - } - - return data; + }; }; const signOut = async () => { - const { error } = await supabase.auth.signOut(); - - if (error) { - throw error; - } + clearSession(); }; return { @@ -106,3 +318,58 @@ export const useEmailAuth = () => { signOut, }; }; + +// Legacy hooks for old API compatibility (deprecated) + +/** @deprecated Use useLogin instead */ +export const usePostLogin = () => { + return useMutation({ + mutationFn: async (data: LoginRequest) => { + const response = await hackathonApi.post>( + '/auth/login', + data + ); + return { data: response.data.data }; + }, + }); +}; + +/** @deprecated Use useSignup instead */ +export const usePostRegister = () => { + return useMutation({ + mutationFn: async (data: SignupRequest) => { + const response = await hackathonApi.post>( + '/auth/signup', + data + ); + return { data: response.data.data }; + }, + }); +}; + +/** @deprecated Not needed with new backend */ +export const usePostVerifyEmail = () => { + return useMutation({ + mutationFn: async () => { + throw new Error('Email verification not required with new backend'); + }, + }); +}; + +/** @deprecated Not needed with new backend */ +export const usePostSendOtp = () => { + return useMutation({ + mutationFn: async () => { + throw new Error('OTP not required with new backend'); + }, + }); +}; + +/** @deprecated Use useGitHubCallback instead */ +export const useGoogleCallback = () => { + return useMutation({ + mutationFn: async () => { + throw new Error('Google OAuth not supported. Use GitHub OAuth instead.'); + }, + }); +}; diff --git a/libs/service/src/hooks/messages/index.ts b/libs/service/src/hooks/messages/index.ts index f9b761c..c207b49 100644 --- a/libs/service/src/hooks/messages/index.ts +++ b/libs/service/src/hooks/messages/index.ts @@ -1,7 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { supabase } from '../../supabase'; +import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; import { useAuthStore } from '../auth'; -import { useEffect } from 'react'; export type Message = { id: string; @@ -24,101 +23,22 @@ export const messageKeys = { team: (teamId: string) => [...messageKeys.all, 'team', teamId] as const, }; -// Fetch messages for a team +// Fetch messages for a team with polling export const useTeamMessages = (teamId: string) => { - const queryClient = useQueryClient(); - - const query = useQuery({ + return useQuery({ queryKey: messageKeys.team(teamId), queryFn: async () => { - const { data: messages, error } = await supabase - .from('team_messages') - .select(` - id, - team_id, - user_id, - message, - created_at, - updated_at, - user:users(id, fullname, avatar, email) - `) - .eq('team_id', teamId) - .order('created_at', { ascending: true }); - - if (error) { - console.error('Failed to fetch messages:', error); - throw new Error(error.message || 'Failed to fetch messages'); - } - - return messages as Message[]; + const response = await hackathonApi.get>( + `/chat/teams/${teamId}` + ); + return response.data.data || []; }, enabled: !!teamId, + // Poll every 3 seconds for new messages + refetchInterval: 3000, + // Keep refetching even when window loses focus + refetchIntervalInBackground: true, }); - - // Subscribe to realtime updates - useEffect(() => { - if (!teamId) return; - - const channel = supabase - .channel(`team_messages:${teamId}`) - .on( - 'postgres_changes', - { - event: 'INSERT', - schema: 'public', - table: 'team_messages', - filter: `team_id=eq.${teamId}`, - }, - async (payload) => { - console.log('[Realtime] New message:', payload); - - // Fetch the full message with user data - const { data: newMessage } = await supabase - .from('team_messages') - .select(` - id, - team_id, - user_id, - message, - created_at, - updated_at, - user:users(id, fullname, avatar, email) - `) - .eq('id', payload.new.id) - .single(); - - if (newMessage) { - queryClient.setQueryData( - messageKeys.team(teamId), - (old) => [...(old || []), newMessage as Message] - ); - } - } - ) - .on( - 'postgres_changes', - { - event: 'DELETE', - schema: 'public', - table: 'team_messages', - filter: `team_id=eq.${teamId}`, - }, - (payload) => { - console.log('[Realtime] Message deleted:', payload); - queryClient.setQueryData( - messageKeys.team(teamId), - (old) => old?.filter((msg) => msg.id !== payload.old.id) || [] - ); - } - ) - .subscribe(); - - return () => { - supabase.removeChannel(channel); - }; - }, [teamId, queryClient]); - - return query; }; // Send a message @@ -132,26 +52,15 @@ export const useSendMessage = (teamId: string) => { throw new Error('You must be logged in to send messages'); } - const { data, error } = await supabase - .from('team_messages') - .insert({ - team_id: teamId, - user_id: session.user.id, - message, - }) - .select() - .single(); + const response = await hackathonApi.post>( + `/chat/teams/${teamId}`, + { message } + ); - if (error) { - console.error('Failed to send message:', error); - throw new Error(error.message || 'Failed to send message'); - } - - return data; + return response.data.data; }, onSuccess: () => { - // Realtime will handle adding the message to the list - // But we can invalidate to ensure consistency + // Invalidate to trigger immediate refetch queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) }); }, }); @@ -163,18 +72,9 @@ export const useDeleteMessage = (teamId: string) => { return useMutation({ mutationFn: async (messageId: string) => { - const { error } = await supabase - .from('team_messages') - .delete() - .eq('id', messageId); - - if (error) { - console.error('Failed to delete message:', error); - throw new Error(error.message || 'Failed to delete message'); - } + await hackathonApi.delete(`/chat/messages/${messageId}`); }, onSuccess: () => { - // Realtime will handle removing the message from the list queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) }); }, }); diff --git a/libs/service/src/hooks/teams/index.ts b/libs/service/src/hooks/teams/index.ts index b6a5e7c..a2d59bc 100644 --- a/libs/service/src/hooks/teams/index.ts +++ b/libs/service/src/hooks/teams/index.ts @@ -1,6 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import * as teamsApi from '../../api/teams'; -import { supabase, getAuthenticatedClient } from '../../supabase'; +import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; import { useAuthStore } from '../auth'; import type { TCreateTeamRequest, @@ -24,6 +23,89 @@ export const teamKeys = { myInvitations: () => [...teamKeys.all, 'my-invitations'] as const, }; +// API response types +interface TeamMember { + id: string; + team_id: string; + user_id: string; + role: string; + status: string; + joined_at: string; + user?: { + id: string; + email: string; + fullname: string; + avatar: string; + }; +} + +interface Team { + id: string; + name: string; + logo?: string; + banner?: string; + description?: string; + city?: string; + visibility: string; + leader_id: string; + created_at: string; + leader?: { + id: string; + email: string; + fullname: string; + avatar: string; + }; + members?: TeamMember[]; + member_count?: number; + has_submission?: boolean; +} + +interface JoinRequest { + id: string; + team_id: string; + user_id: string; + message?: string; + status: string; + created_at: string; + user?: { + id: string; + email: string; + fullname: string; + avatar: string; + }; +} + +interface Invitation { + id: string; + team_id: string; + inviter_id: string; + invitee_email: string; + invitee_id?: string; + status: string; + created_at: string; + team?: Team; + inviter?: { + id: string; + fullname: string; + email: string; + avatar: string; + }; +} + +interface Submission { + id: string; + team_id: string; + project_name: string; + description?: string; + repository_url?: string; + demo_url?: string; + video_url?: string; + presentation_url?: string; + status: string; + submitted_at?: string; + created_at: string; +} + // Team CRUD Hooks export const useTeams = (params?: { page?: number; @@ -35,49 +117,16 @@ export const useTeams = (params?: { return useQuery({ queryKey: teamKeys.list(params), queryFn: async () => { - try { - // Supabase client now has auth context from setSession() - let query = supabase.from('teams').select(` - *, - members:team_members(id) - `); + const queryParams = new URLSearchParams(); + if (params?.search) queryParams.append('search', params.search); + if (params?.city) queryParams.append('city', params.city); + if (params?.visibility) queryParams.append('visibility', params.visibility); - // Filter by visibility - if (params?.visibility) { - query = query.eq('visibility', params.visibility); - } + const response = await hackathonApi.get>( + `/teams/browse${queryParams.toString() ? `?${queryParams.toString()}` : ''}` + ); - // Filter by city - if (params?.city) { - query = query.eq('city', params.city); - } - - // Search by name - if (params?.search) { - query = query.ilike('name', `%${params.search}%`); - } - - // Pagination - if (params?.page && params?.limit) { - const from = (params.page - 1) * params.limit; - const to = from + params.limit - 1; - query = query.range(from, to); - } - - const { data, error } = await query; - - if (error) { - // If error is 401/403, it means RLS policies need to be set up - // Return empty array for now - console.warn('Teams query error (RLS policies may need to be configured):', error); - return { data: [] }; - } - - return { data: data || [] }; - } catch (err) { - console.error('Failed to fetch teams:', err); - return { data: [] }; - } + return { data: response.data.data || [] }; }, }); }; @@ -86,45 +135,8 @@ export const useTeamById = (teamId: string, enabled = true) => { return useQuery({ queryKey: teamKeys.detail(teamId), queryFn: async () => { - // Supabase client now has auth context from setSession() - const { data: team, error } = await supabase - .from('teams') - .select(` - *, - leader:users!leader_id(id, email, fullname, avatar), - members:team_members( - id, - role, - status, - joined_at, - user:users(id, email, fullname, avatar) - ) - `) - .eq('id', teamId) - .single(); - - if (error) { - console.error('Failed to fetch team:', error); - throw new Error(error.message || 'Failed to fetch team'); - } - - // Count active members - const activeMemberCount = team?.members?.filter((m: any) => m.status === 'active').length || 0; - - // Check if team has a submission - const { data: submission } = await supabase - .from('project_submissions') - .select('id') - .eq('team_id', teamId) - .maybeSingle(); - - return { - data: { - ...team, - member_count: activeMemberCount, - has_submission: !!submission, - }, - }; + const response = await hackathonApi.get>(`/teams/${teamId}`); + return { data: response.data.data }; }, enabled: enabled && !!teamId, }); @@ -140,50 +152,16 @@ export const useCreateTeam = () => { throw new Error('You must be logged in to create a team'); } - // Supabase client now has auth context from setSession() - const { data: team, error: teamError } = await supabase - .from('teams') - .insert({ - name: data.name, - logo: data.logo, - banner: data.banner, - description: data.description, - city: data.city, - visibility: data.visibility, - leader_id: session.user.id, - }) - .select() - .single(); + const response = await hackathonApi.post>('/teams', { + name: data.name, + logo: data.logo, + banner: data.banner, + description: data.description, + city: data.city, + visibility: data.visibility, + }); - if (teamError) { - console.error('Failed to create team:', teamError); - throw new Error(teamError.message || 'Failed to create team'); - } - - // Insert team creator as leader in team_members table - const { error: memberError } = await supabase - .from('team_members') - .insert({ - team_id: team.id, - user_id: session.user.id, - role: 'leader', - status: 'active', - }); - - if (memberError) { - // If duplicate key error (23505), it means leader is already a member (possibly by trigger) - // This is acceptable, so we can ignore it - if (memberError.code === '23505') { - console.log('Team leader already exists in team_members (likely added by trigger)'); - } else { - // For other errors, clean up and throw - console.error('Failed to add team leader as member:', memberError); - await supabase.from('teams').delete().eq('id', team.id); - throw new Error('Failed to set up team membership'); - } - } - - return { data: team }; + return { data: response.data.data }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.lists() }); @@ -202,27 +180,16 @@ export const useUpdateTeam = (teamId: string) => { throw new Error('You must be logged in to update a team'); } - // Supabase client now has auth context from setSession() - const { data: team, error } = await supabase - .from('teams') - .update({ - name: data.name, - logo: data.logo, - banner: data.banner, - description: data.description, - city: data.city, - visibility: data.visibility, - }) - .eq('id', teamId) - .select() - .single(); + const response = await hackathonApi.put>(`/teams/${teamId}`, { + name: data.name, + logo: data.logo, + banner: data.banner, + description: data.description, + city: data.city, + visibility: data.visibility, + }); - if (error) { - console.error('Failed to update team:', error); - throw new Error(error.message || 'Failed to update team'); - } - - return { data: team }; + return { data: response.data.data }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) }); @@ -231,32 +198,13 @@ export const useUpdateTeam = (teamId: string) => { }); }; -// Team Members Hooks +// Team Members Hooks - using team detail endpoint which includes members export const useTeamMembers = (teamId: string, enabled = true) => { return useQuery({ queryKey: teamKeys.members(teamId), queryFn: async () => { - // Supabase client now has auth context from setSession() - const { data: members, error } = await supabase - .from('team_members') - .select(` - id, - team_id, - user_id, - role, - status, - joined_at, - user:users(id, email, fullname, avatar) - `) - .eq('team_id', teamId) - .order('joined_at', { ascending: true }); - - if (error) { - console.error('Failed to fetch team members:', error); - throw new Error(error.message || 'Failed to fetch team members'); - } - - return { data: members || [] }; + const response = await hackathonApi.get>(`/teams/${teamId}`); + return { data: response.data.data?.members || [] }; }, enabled: enabled && !!teamId, }); @@ -272,24 +220,12 @@ export const useInviteMember = (teamId: string) => { throw new Error('You must be logged in to invite a member'); } - // Insert invitation into team_invitations table - const { data: invitation, error } = await supabase - .from('team_invitations') - .insert({ - team_id: teamId, - inviter_id: session.user.id, - invitee_email: data.email, - status: 'pending', - }) - .select() - .single(); + const response = await hackathonApi.post>( + `/teams/${teamId}/invite`, + { invitee_email: data.email } + ); - if (error) { - console.error('Failed to create invitation:', error); - throw new Error(error.message || 'Failed to send invitation'); - } - - return { data: invitation }; + return { data: response.data.data }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) }); @@ -301,8 +237,11 @@ export const useManageMember = (teamId: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ userId, data }: { userId: string; data: any }) => - teamsApi.manageMember(teamId, userId, data), + mutationFn: async ({ userId, data }: { userId: string; data: { role?: string; status?: string } }) => { + // This endpoint may not exist in the backend yet + // For now, we'll throw an error indicating it's not implemented + throw new Error('Manage member functionality not yet implemented in backend'); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) }); queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) }); @@ -320,38 +259,7 @@ export const useRemoveMember = (teamId: string) => { throw new Error('You must be logged in to remove a member'); } - // Verify the current user is the team leader - const { data: team, error: teamError } = await supabase - .from('teams') - .select('leader_id') - .eq('id', teamId) - .single(); - - if (teamError || !team) { - throw new Error('Team not found'); - } - - if (team.leader_id !== session.user.id) { - throw new Error('Only the team leader can remove members'); - } - - // Cannot remove the leader - if (userId === team.leader_id) { - throw new Error('Cannot remove the team leader'); - } - - // Delete the team member record - const { error: deleteError } = await supabase - .from('team_members') - .delete() - .eq('team_id', teamId) - .eq('user_id', userId); - - if (deleteError) { - console.error('Failed to remove member:', deleteError); - throw new Error(deleteError.message || 'Failed to remove member'); - } - + await hackathonApi.delete(`/teams/${teamId}/members/${userId}`); return { success: true }; }, onSuccess: () => { @@ -372,24 +280,12 @@ export const useJoinTeam = () => { throw new Error('You must be logged in to join a team'); } - // Insert join request into team_join_requests table - const { data: joinRequest, error } = await supabase - .from('team_join_requests') - .insert({ - team_id: teamId, - user_id: session.user.id, - message: data.message, - status: 'pending', - }) - .select() - .single(); + const response = await hackathonApi.post>( + `/join-requests/teams/${teamId}`, + { message: data.message } + ); - if (error) { - console.error('Failed to create join request:', error); - throw new Error(error.message || 'Failed to send join request'); - } - - return { data: joinRequest }; + return { data: response.data.data }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.lists() }); @@ -401,28 +297,10 @@ export const useTeamJoinRequests = (teamId: string, enabled = true) => { return useQuery({ queryKey: teamKeys.joinRequests(teamId), queryFn: async () => { - // Fetch join requests with user information - const { data: requests, error } = await supabase - .from('team_join_requests') - .select(` - id, - team_id, - user_id, - message, - status, - created_at, - user:users(id, email, fullname, avatar) - `) - .eq('team_id', teamId) - .eq('status', 'pending') - .order('created_at', { ascending: false }); - - if (error) { - console.error('Failed to fetch join requests:', error); - throw new Error(error.message || 'Failed to fetch join requests'); - } - - return { data: requests || [] }; + const response = await hackathonApi.get>( + `/join-requests/teams/${teamId}/pending` + ); + return { data: response.data.data || [] }; }, enabled: enabled && !!teamId, }); @@ -438,76 +316,14 @@ export const useRespondToJoinRequest = (teamId: string) => { throw new Error('You must be logged in to respond to join requests'); } - // First, get the join request details - const { data: joinRequest, error: fetchError } = await supabase - .from('team_join_requests') - .select('id, team_id, user_id, status') - .eq('id', requestId) - .single(); + // Backend uses 'accept' instead of 'approve' + const backendAction = action === 'approve' ? 'accept' : 'reject'; - if (fetchError || !joinRequest) { - throw new Error('Join request not found'); - } + await hackathonApi.post(`/join-requests/${requestId}/respond`, { + action: backendAction, + }); - if (joinRequest.status !== 'pending') { - throw new Error('Join request has already been processed'); - } - - if (action === 'approve') { - // Update join request status - const { error: updateError } = await supabase - .from('team_join_requests') - .update({ status: 'accepted' }) - .eq('id', requestId); - - if (updateError) { - throw new Error('Failed to update join request: ' + updateError.message); - } - - // Add user as team member - const { error: memberError } = await supabase - .from('team_members') - .insert({ - team_id: joinRequest.team_id, - user_id: joinRequest.user_id, - role: 'member', - status: 'active', - }); - - if (memberError) { - // If member creation fails, rollback join request update - await supabase - .from('team_join_requests') - .update({ status: 'pending' }) - .eq('id', requestId); - - // Parse Supabase error for user-friendly message - let errorMsg = memberError.message; - if (errorMsg.includes('Team already has 5 members') || errorMsg.includes('Team cannot have more than 5 members')) { - errorMsg = 'Team is full! Maximum 5 members allowed.'; - } else if (errorMsg.includes('already in a team') || errorMsg.includes('User is already in a team')) { - errorMsg = 'This user is already in another team.'; - } else if (errorMsg.includes('Bulk insert')) { - errorMsg = 'Invalid operation detected.'; - } - - throw new Error(errorMsg); - } - - return { success: true, action: 'accepted' }; - } else { - // Reject join request - const { error: updateError } = await supabase - .from('team_join_requests') - .update({ status: 'rejected' }) - .eq('id', requestId); - - if (updateError) { - throw new Error('Failed to update join request: ' + updateError.message); - } - - return { success: true, action: 'rejected' }; - } + return { success: true, action }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.joinRequests(teamId) }); @@ -524,49 +340,10 @@ export const useMyInvitations = () => { return useQuery({ queryKey: teamKeys.myInvitations(), queryFn: async () => { - if (!session?.user?.email) { - return { data: [] }; - } - - // Query team_invitations where invitee_email matches current user's email - const { data: invitations, error } = await supabase - .from('team_invitations') - .select(` - id, - team_id, - inviter_id, - invitee_email, - invitee_id, - status, - created_at, - team:teams( - id, - name, - logo, - banner, - description, - city, - visibility, - leader_id - ), - inviter:users!team_invitations_inviter_id_fkey( - id, - fullname, - email, - avatar - ) - `) - .eq('invitee_email', session.user.email) - .eq('status', 'pending'); - - if (error) { - console.error('Failed to fetch invitations:', error); - return { data: [] }; - } - - return { data: invitations || [] }; + const response = await hackathonApi.get>('/invitations/my'); + return { data: response.data.data || [] }; }, - enabled: !!session?.user?.email, + enabled: !!session?.user?.id, }); }; @@ -580,75 +357,9 @@ export const useRespondToInvitation = () => { throw new Error('User not authenticated'); } - // First, get the invitation details - const { data: invitation, error: fetchError } = await supabase - .from('team_invitations') - .select('id, team_id, invitee_email, status') - .eq('id', invitationId) - .single(); + await hackathonApi.post(`/invitations/${invitationId}/respond`, { action }); - if (fetchError || !invitation) { - throw new Error('Invitation not found'); - } - - if (invitation.status !== 'pending') { - throw new Error('Invitation has already been responded to'); - } - - if (action === 'accept') { - // Update invitation status and set invitee_id - const { error: updateError } = await supabase - .from('team_invitations') - .update({ - status: 'accepted', - invitee_id: session.user.id, - }) - .eq('id', invitationId); - - if (updateError) { - throw new Error('Failed to update invitation: ' + updateError.message); - } - - // Create team_members record - const { error: memberError } = await supabase - .from('team_members') - .insert({ - team_id: invitation.team_id, - user_id: session.user.id, - role: 'member', - status: 'active', - }); - - if (memberError) { - // If member creation fails, rollback invitation update - await supabase - .from('team_invitations') - .update({ - status: 'pending', - invitee_id: null, - }) - .eq('id', invitationId); - - throw new Error('Failed to add member to team: ' + memberError.message); - } - - return { success: true, action: 'accepted' }; - } else { - // Reject invitation - const { error: updateError } = await supabase - .from('team_invitations') - .update({ - status: 'rejected', - invitee_id: session.user.id, - }) - .eq('id', invitationId); - - if (updateError) { - throw new Error('Failed to update invitation: ' + updateError.message); - } - - return { success: true, action: 'rejected' }; - } + return { success: true, action }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.myInvitations() }); @@ -665,39 +376,8 @@ export const useMyTeams = () => { return useQuery({ queryKey: teamKeys.myTeams(), queryFn: async () => { - if (!session?.user?.id) { - return { data: [] }; - } - - // Query team_members to find teams where user is a member - const { data: memberships, error: membershipsError } = await supabase - .from('team_members') - .select(` - team_id, - team:teams( - id, - name, - logo, - banner, - description, - city, - visibility, - leader_id, - created_at - ) - `) - .eq('user_id', session.user.id) - .eq('status', 'active'); - - if (membershipsError) { - console.error('Failed to fetch user teams:', membershipsError); - return { data: [] }; - } - - // Extract teams from memberships - const teams = memberships?.map((m: any) => m.team).filter(Boolean) || []; - - return { data: teams }; + const response = await hackathonApi.get>('/teams/my'); + return { data: response.data.data || [] }; }, enabled: !!session?.user?.id, }); @@ -709,7 +389,45 @@ export const useSubmitProject = (teamId: string) => { return useMutation({ mutationFn: async (data: TSubmitProjectRequest) => { - return await teamsApi.submitProject(teamId, data); + // First, check if submission exists + try { + const existingResponse = await hackathonApi.get>( + `/submissions/teams/${teamId}` + ); + + if (existingResponse.data.data?.id) { + // Update existing submission + const response = await hackathonApi.put>( + `/submissions/${existingResponse.data.data.id}`, + { + project_name: data.project_name, + description: data.description, + repository_url: data.repository_url, + demo_url: data.demo_url, + video_url: data.video_url, + presentation_url: data.presentation_url, + } + ); + return { data: response.data.data }; + } + } catch { + // No existing submission, create new one + } + + // Create new submission + const response = await hackathonApi.post>( + `/submissions/teams/${teamId}`, + { + project_name: data.project_name, + description: data.description, + repository_url: data.repository_url, + demo_url: data.demo_url, + video_url: data.video_url, + presentation_url: data.presentation_url, + } + ); + + return { data: response.data.data }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.submission(teamId) }); @@ -722,8 +440,10 @@ export const useTeamSubmission = (teamId: string, enabled = true) => { return useQuery({ queryKey: teamKeys.submission(teamId), queryFn: async () => { - const submission = await teamsApi.getTeamSubmission(teamId); - return { data: submission }; + const response = await hackathonApi.get>( + `/submissions/teams/${teamId}` + ); + return { data: response.data.data }; }, enabled: enabled && !!teamId, }); @@ -732,10 +452,17 @@ export const useTeamSubmission = (teamId: string, enabled = true) => { // Leave Team Hook export const useLeaveTeam = () => { const queryClient = useQueryClient(); + const { session } = useAuthStore(); return useMutation({ mutationFn: async (teamId: string) => { - return await teamsApi.leaveTeam(teamId); + if (!session?.user?.id) { + throw new Error('You must be logged in to leave a team'); + } + + // Use the remove member endpoint with current user's ID + await hackathonApi.delete(`/teams/${teamId}/members/${session.user.id}`); + return { success: true }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() }); @@ -744,44 +471,13 @@ export const useLeaveTeam = () => { }); }; -// Get Teams by User ID +// Get Teams by User ID - uses /users/{user_id}/teams export const useTeamsByUserId = (userId: string) => { return useQuery({ queryKey: ['teams-by-user', userId], queryFn: async () => { - if (!userId) { - return { data: [] }; - } - - // Query team_members to find teams where user is a member - const { data: memberships, error: membershipsError } = await supabase - .from('team_members') - .select(` - team_id, - team:teams( - id, - name, - logo, - banner, - description, - city, - visibility, - leader_id, - created_at - ) - `) - .eq('user_id', userId) - .eq('status', 'active'); - - if (membershipsError) { - console.error('Failed to fetch user teams:', membershipsError); - return { data: [] }; - } - - // Extract teams from memberships - const teams = memberships?.map((m: any) => m.team).filter(Boolean) || []; - - return { data: teams }; + const response = await hackathonApi.get>(`/users/${userId}/teams`); + return { data: response.data.data || [] }; }, enabled: !!userId, }); diff --git a/libs/service/src/hooks/upload/index.ts b/libs/service/src/hooks/upload/index.ts index 9b02054..10887b0 100644 --- a/libs/service/src/hooks/upload/index.ts +++ b/libs/service/src/hooks/upload/index.ts @@ -1,8 +1,27 @@ import { useMutation } from '@tanstack/react-query'; -import { supabase, getAuthenticatedClient } from '../../supabase'; +import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; import { useAuthStore } from '../auth'; -// Supabase Storage-based upload hooks +// Upload response type from backend +interface UploadResponse { + url: string; +} + +// Helper function to convert File to base64 +const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + // Remove the data:image/xxx;base64, prefix + const base64 = (reader.result as string).split(',')[1]; + resolve(base64); + }; + reader.onerror = (error) => reject(error); + }); +}; + +// Backend API-based upload hooks export const useUploadFile = () => { const { session } = useAuthStore(); @@ -14,30 +33,18 @@ export const useUploadFile = () => { throw new Error('You must be logged in to upload files'); } - // Generate a unique file name - const fileExt = file.name.split('.').pop(); - const fileName = `${session.user.id}-${Date.now()}.${fileExt}`; - const filePath = `teams/${fileName}`; + const base64Data = await fileToBase64(file); - // Supabase client now has auth context from setSession() - const { error } = await supabase.storage - .from('hackathon-uploads') - .upload(filePath, file, { - cacheControl: '3600', - upsert: true, - }); + const response = await hackathonApi.post>( + '/upload/team', + { + filename: file.name, + content_type: file.type, + data: base64Data, + } + ); - if (error) { - console.error('Failed to upload file:', error); - throw new Error(error.message || 'Failed to upload file'); - } - - // Get public URL - const { data: publicUrlData } = supabase.storage - .from('hackathon-uploads') - .getPublicUrl(filePath); - - return { data: { url: publicUrlData.publicUrl } }; + return { data: { url: response.data.data.url } }; }, }); }; @@ -52,34 +59,131 @@ export const useUploadAvatar = () => { throw new Error('You must be logged in to upload avatar'); } - // Generate a unique file name - const fileExt = file.name.split('.').pop(); - const fileName = `${session.user.id}-${Date.now()}.${fileExt}`; - const filePath = `avatars/${fileName}`; - - // Supabase client now has auth context from setSession() - const { error } = await supabase.storage - .from('hackathon-uploads') - .upload(filePath, file, { - cacheControl: '3600', - upsert: true, - }); - - if (error) { - console.error('Failed to upload avatar:', error); - throw new Error(error.message || 'Failed to upload avatar'); + // Validate file type + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF'); } - // Get public URL - const { data: publicUrlData } = supabase.storage - .from('hackathon-uploads') - .getPublicUrl(filePath); + // Validate file size (max 5MB) + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + throw new Error('File too large. Maximum size: 5MB'); + } - return { data: { url: publicUrlData.publicUrl } }; + const base64Data = await fileToBase64(file); + + const response = await hackathonApi.post>( + '/upload/avatar', + { + filename: file.name, + content_type: file.type, + data: base64Data, + } + ); + + return { data: { url: response.data.data.url } }; }, }); }; +export const useUploadTeamFile = () => { + const { session } = useAuthStore(); + + return useMutation({ + mutationKey: ['upload-team-file'], + mutationFn: async (file: File) => { + if (!session?.user?.id) { + throw new Error('You must be logged in to upload files'); + } + + // Validate file type + const allowedTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + ]; + if (!allowedTypes.includes(file.type)) { + throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF, PDF'); + } + + // Validate file size (max 20MB) + const maxSize = 20 * 1024 * 1024; + if (file.size > maxSize) { + throw new Error('File too large. Maximum size: 20MB'); + } + + const base64Data = await fileToBase64(file); + + const response = await hackathonApi.post>( + '/upload/team', + { + filename: file.name, + content_type: file.type, + data: base64Data, + } + ); + + return { data: { url: response.data.data.url } }; + }, + }); +}; + +export const useUploadSubmission = () => { + const { session } = useAuthStore(); + + return useMutation({ + mutationKey: ['upload-submission'], + mutationFn: async (file: File) => { + if (!session?.user?.id) { + throw new Error('You must be logged in to upload submissions'); + } + + // Validate file type + const allowedTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + 'application/zip', + 'application/x-zip-compressed', + 'video/mp4', + 'video/webm', + ]; + if (!allowedTypes.includes(file.type)) { + throw new Error( + 'Invalid file type. Allowed types: Images, PDF, ZIP, MP4, WebM' + ); + } + + // Validate file size (max 50MB) + const maxSize = 50 * 1024 * 1024; + if (file.size > maxSize) { + throw new Error('File too large. Maximum size: 50MB'); + } + + const base64Data = await fileToBase64(file); + + const response = await hackathonApi.post>( + '/upload/submission', + { + filename: file.name, + content_type: file.type, + data: base64Data, + } + ); + + return { data: { url: response.data.data.url } }; + }, + }); +}; + +// Keep useUploadCV for compatibility, using team upload endpoint export const useUploadCV = () => { const { session } = useAuthStore(); @@ -90,30 +194,29 @@ export const useUploadCV = () => { throw new Error('You must be logged in to upload CV'); } - // Generate a unique file name - const fileExt = file.name.split('.').pop(); - const fileName = `${session.user.id}-${Date.now()}.${fileExt}`; - const filePath = `cvs/${fileName}`; - - // Supabase client now has auth context from setSession() - const { error } = await supabase.storage - .from('hackathon-uploads') - .upload(filePath, file, { - cacheControl: '3600', - upsert: true, - }); - - if (error) { - console.error('Failed to upload CV:', error); - throw new Error(error.message || 'Failed to upload CV'); + // Validate file type + if (file.type !== 'application/pdf') { + throw new Error('CV must be a PDF file'); } - // Get public URL - const { data: publicUrlData } = supabase.storage - .from('hackathon-uploads') - .getPublicUrl(filePath); + // Validate file size (max 20MB) + const maxSize = 20 * 1024 * 1024; + if (file.size > maxSize) { + throw new Error('File too large. Maximum size: 20MB'); + } - return { data: { url: publicUrlData.publicUrl } }; + const base64Data = await fileToBase64(file); + + const response = await hackathonApi.post>( + '/upload/team', + { + filename: file.name, + content_type: file.type, + data: base64Data, + } + ); + + return { data: { url: response.data.data.url } }; }, }); }; diff --git a/libs/service/src/hooks/users/index.ts b/libs/service/src/hooks/users/index.ts index 75ab7c4..fe501e9 100644 --- a/libs/service/src/hooks/users/index.ts +++ b/libs/service/src/hooks/users/index.ts @@ -1,17 +1,41 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { userService } from '../../api/users'; -import { supabase, getAuthenticatedClient } from '../../supabase'; +import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; import { useAuthStore } from '../auth'; -// Supabase-based user hooks +// User type +interface User { + id: string; + email: string; + fullname: string; + bio?: string; + location?: string; + avatar?: string; + skills?: string[]; + created_at: string; + updated_at?: string; +} + +// Update user request type +interface UpdateUserRequest { + fullname?: string; + bio?: string; + location?: string; + avatar?: string; + skills?: string[]; +} + +// Backend API-based user hooks export const useUserMe = () => { + const { session } = useAuthStore(); + return useQuery({ queryKey: ['user-me'], queryFn: async () => { - const user = await userService.getUserMe(); - return { data: user }; + const response = await hackathonApi.get>('/users/me'); + return { data: response.data.data }; }, + enabled: !!session?.user?.id, }); }; @@ -19,8 +43,8 @@ export const useUserById = (id: string) => { return useQuery({ queryKey: ['user-by-id', id], queryFn: async () => { - const user = await userService.getUserById(id); - return { data: user }; + const response = await hackathonApi.get>(`/users/${id}`); + return { data: response.data.data }; }, enabled: !!id, }); @@ -32,35 +56,24 @@ export const useUpdateUserMe = () => { return useMutation({ mutationKey: ['update-user-me'], - mutationFn: async (data: any) => { + mutationFn: async (data: UpdateUserRequest) => { if (!session?.user?.id) { throw new Error('You must be logged in to update profile'); } - // Supabase client now has auth context from setSession() - const { data: updatedUser, error } = await supabase - .from('users') - .update({ - fullname: data.fullname, - bio: data.bio, - location: data.location, - avatar: data.avatar, - skills: data.skills, - updated_at: new Date().toISOString(), - }) - .eq('id', session.user.id) - .select() - .single(); + const response = await hackathonApi.put>('/users/me', { + fullname: data.fullname, + bio: data.bio, + location: data.location, + avatar: data.avatar, + skills: data.skills, + }); - if (error) { - console.error('Failed to update user profile:', error); - throw new Error(error.message || 'Failed to update profile'); - } - return { data: updatedUser }; + return { data: response.data.data }; }, onSuccess: (result) => { // Update Zustand session store with new user data - if (session && result.data) { + if (session?.user && result.data) { setSession({ token: session.token, user: { @@ -83,9 +96,11 @@ export const useUpdateUserById = () => { return useMutation({ mutationKey: ['update-user-by-id'], - mutationFn: async ({ id, data }: { id: string; data: any }) => { - const updated = await userService.updateUserById(id, data); - return { data: updated }; + mutationFn: async ({ id, data }: { id: string; data: UpdateUserRequest }) => { + // Note: This might not be supported by backend (only /users/me for updates) + // Keeping for API compatibility but it will likely fail + const response = await hackathonApi.put>(`/users/${id}`, data); + return { data: response.data.data }; }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['user-by-id', variables.id] }); @@ -95,19 +110,10 @@ export const useUpdateUserById = () => { export const useUserDetailsById = (userId: string) => { return useQuery({ - queryKey: ['user-supabase', userId], + queryKey: ['user-details', userId], queryFn: async () => { - const { data, error } = await supabase - .from('users') - .select('*') - .eq('id', userId) - .single(); - - if (error) { - throw new Error(error.message || 'Failed to fetch user'); - } - - return { data }; + const response = await hackathonApi.get>(`/users/${userId}`); + return { data: response.data.data }; }, enabled: !!userId, }); diff --git a/libs/service/src/types/teams/index.ts b/libs/service/src/types/teams/index.ts index 1c8aa11..7409f15 100644 --- a/libs/service/src/types/teams/index.ts +++ b/libs/service/src/types/teams/index.ts @@ -125,6 +125,7 @@ export type TSubmitProjectRequest = { description: string; repository_url: string; demo_url?: string; + video_url?: string; presentation_url?: string; screenshots?: string[]; };