From 9222d3a48377c46b23516a68c172ddaab7ce94e8 Mon Sep 17 00:00:00 2001 From: RoadtripDJ Dev Date: Tue, 19 May 2026 01:27:04 +0100 Subject: [PATCH] Fix: Spotify OAuth RLS integration, profile mapping, and robust AI playlist generation bypass --- .env | 4 +- src/auth/authRedirect.ts | 12 +- src/contexts/AuthContext.tsx | 25 +++- src/screens/auth/LoginScreen.tsx | 222 ++++++++++++++++++++++------- src/screens/main/HomeScreen.tsx | 4 +- src/screens/trip/NewTripScreen.tsx | 102 ++++++++++--- 6 files changed, 286 insertions(+), 83 deletions(-) diff --git a/.env b/.env index 588d5ab..6002946 100644 --- a/.env +++ b/.env @@ -2,7 +2,7 @@ EXPO_PUBLIC_SUPABASE_URL=https://qyvnryhskgmvgjajqqru.supabase.co EXPO_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_fazCCLmO7XjtryY28ePR-A_CS7aU6fF # GOOGLE MAPS (Fase 2) -EXPO_PUBLIC_GOOGLE_MAPS_API_KEY=CAIzaSyDBXsQiWnLehBpTCW7Xg--MNQ3wTfkexXA +EXPO_PUBLIC_GOOGLE_MAPS_API_KEY=AIzaSyDocu-PEHAyrdV8OUEPMXye9A_rpYzOA34 # SPOTIFY (Fase 3) -EXPO_PUBLIC_SPOTIFY_CLIENT_ID=C7fa1e7acbf7e44f18bf28d74f14fb9cb \ No newline at end of file +EXPO_PUBLIC_SPOTIFY_CLIENT_ID=7fa1e7acbf7e44f18bf28d74f14fb9cb \ No newline at end of file diff --git a/src/auth/authRedirect.ts b/src/auth/authRedirect.ts index fa248ee..1aeda85 100644 --- a/src/auth/authRedirect.ts +++ b/src/auth/authRedirect.ts @@ -47,13 +47,10 @@ export function parseOAuthParams(url: string) { } export async function handleAuthRedirectUrl(url: string | null) { - await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_called', has_url: Boolean(url) }); - if (!url) return null; if (processedSuccessUrls.has(url)) { console.log('[AuthRedirect] Ignoring already processed URL:', url); - await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_skipped_duplicate' }); return null; } @@ -62,10 +59,11 @@ export async function handleAuthRedirectUrl(url: string | null) { return null; } - console.log('[AuthRedirect] Processing URL:', url); isHandlingUrl = true; + console.log('[AuthRedirect] Processing URL:', url); try { + await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_called', has_url: Boolean(url) }); await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_processing' }); const { params, errorCode } = parseOAuthParams(url); const paramKeys = Object.keys(params || {}); @@ -106,6 +104,12 @@ export async function handleAuthRedirectUrl(url: string | null) { return null; } + // Ignore direct Spotify OAuth callbacks (handled by useAuthRequest in LoginScreen) + if (params.code && params.state) { + console.log("[AuthRedirect] Ignoring direct Spotify OAuth callback"); + return null; + } + // Save Provider Tokens if present if (params.provider_token) { console.log('[AuthRedirect] Provider token found! Saving...'); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index d81cf1e..fa42e6f 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -6,12 +6,20 @@ interface AuthContextType { user: User | null; session: Session | null; loading: boolean; + isDemoMode: boolean; + isSpotifyAuthenticated: boolean; + enableDemoMode: () => void; + enableSpotifyMode: () => void; } const AuthContext = createContext({ user: null, session: null, loading: true, + isDemoMode: false, + isSpotifyAuthenticated: false, + enableDemoMode: () => {}, + enableSpotifyMode: () => {}, }); export const useAuth = () => useContext(AuthContext); @@ -20,6 +28,21 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [user, setUser] = useState(null); const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); + const [isDemoMode, setIsDemoMode] = useState(false); + const [isSpotifyAuthenticated, setIsSpotifyAuthenticated] = useState(false); + + const enableDemoMode = () => { + setIsDemoMode(true); + setIsSpotifyAuthenticated(false); + setUser({ id: '00000000-0000-4000-8000-000000000002', email: 'demo@roadtripdj.com' } as User); + setLoading(false); + }; + + const enableSpotifyMode = () => { + setIsDemoMode(false); + setIsSpotifyAuthenticated(true); + setLoading(false); + }; useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { @@ -38,7 +61,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { }, []); return ( - + {children} ); diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 37475d6..ad99ee9 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -3,20 +3,149 @@ import { View, Text, TextInput, TouchableOpacity, StyleSheet, SafeAreaView, Keyb import { Car, Music } from 'lucide-react-native'; import { colors } from '../../utils/colors'; import { supabase } from '../../services/supabase'; -import { makeRedirectUri } from 'expo-auth-session'; -import * as QueryParams from 'expo-auth-session/build/QueryParams'; -import * as Linking from 'expo-linking'; import * as WebBrowser from 'expo-web-browser'; -import { handleAuthRedirectUrl } from '../../auth/authRedirect'; -import { addAuthDebugEvent, getAuthDebugEvents, clearAuthDebugEvents } from '../../debug/authDebug'; -import { clearSpotifyTokens } from '../../auth/spotifyToken'; +import * as Linking from 'expo-linking'; +import { makeRedirectUri, useAuthRequest, exchangeCodeAsync, DiscoveryDocument } from 'expo-auth-session'; +import { clearSpotifyTokens, setSpotifyToken, setSpotifyRefreshToken } from '../../auth/spotifyToken'; +import { useAuth } from '../../contexts/AuthContext'; + +WebBrowser.maybeCompleteAuthSession(); + +// Direct Spotify OAuth Endpoints +const discovery: DiscoveryDocument = { + authorizationEndpoint: 'https://accounts.spotify.com/authorize', + tokenEndpoint: 'https://accounts.spotify.com/api/token', +}; + // @ts-ignore export default function LoginScreen({ navigation }) { + const { enableDemoMode, enableSpotifyMode } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); + // Temporary Emergency Fix: Hardcoded Client ID + const SPOTIFY_CLIENT_ID = "7fa1e7acbf7e44f18bf28d74f14fb9cb"; + // Configure Direct Spotify OAuth + const redirectUri = "exp://192.168.1.7:8081/--/auth/callback"; + + const [request, response, promptAsync] = useAuthRequest( + { + clientId: SPOTIFY_CLIENT_ID, + scopes: ['user-read-email', 'user-read-private', 'playlist-modify-public', 'playlist-modify-private'], + usePKCE: true, + redirectUri, + }, + discovery + ); + + // Handle Spotify OAuth Response + useEffect(() => { + if (response) { + console.log("SPOTIFY_AUTH_RESULT:", response); + if (response.type === 'success') { + const { code } = response.params; + console.log("SPOTIFY_CODE_EXISTS:", !!code); + exchangeCodeForTokens(code); + } else if (response.type === 'error') { + Alert.alert('Erro de Autenticação', response.error?.message || 'Falha ao logar com o Spotify'); + } + } + }, [response]); + + const exchangeCodeForTokens = async (code: string) => { + if (!request?.codeVerifier) return; + + try { + setLoading(true); + const tokenResult = await exchangeCodeAsync( + { + clientId: SPOTIFY_CLIENT_ID, + code, + redirectUri, + extraParams: { + code_verifier: request.codeVerifier, + }, + }, + discovery + ); + + console.log("SPOTIFY_TOKEN_RECEIVED:", !!tokenResult.accessToken); + + if (tokenResult.accessToken) { + await setSpotifyToken(tokenResult.accessToken); + if (tokenResult.refreshToken) { + await setSpotifyRefreshToken(tokenResult.refreshToken); + } + + // 1 & 2. Check for Supabase session or create an anonymous one + const { data: sessionData } = await supabase.auth.getSession(); + let supabaseUserId = sessionData?.session?.user?.id; + + console.log("SUPABASE_SESSION_EXISTS:", !!sessionData?.session); + + if (!sessionData?.session) { + const { data, error } = await supabase.auth.signInAnonymously(); + if (error) { + console.error("Error creating anonymous session:", error); + throw error; + } + supabaseUserId = data.user?.id; + } + + console.log("SUPABASE_USER_ID:", supabaseUserId); + console.log("TRIP_INSERT_USER_ID:", supabaseUserId); + + // 7 & 8. Fetch Spotify Profile and update Supabase User Metadata + try { + console.log("SPOTIFY_PROFILE_FETCH_START"); + const profileRes = await fetch('https://api.spotify.com/v1/me', { + headers: { Authorization: `Bearer ${tokenResult.accessToken}` } + }); + + if (profileRes.ok) { + const profile = await profileRes.json(); + console.log("SPOTIFY_PROFILE_NAME:", profile.display_name); + console.log("SPOTIFY_PROFILE_EMAIL_EXISTS:", !!profile.email); + console.log("SPOTIFY_USER_ID:", profile.id); + console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!tokenResult.accessToken); + + // Force session refresh to ensure AuthContext picks up the new metadata instantly + const { data: updatedUser } = await supabase.auth.updateUser({ + data: { + display_name: profile.display_name, + name: profile.display_name, + spotify_id: profile.id, + email: profile.email, + avatar_url: profile.images?.[0]?.url + } + }); + + // Explicitly refresh session if needed + if (updatedUser) { + await supabase.auth.refreshSession(); + } + } + } catch (profileError) { + console.error("Error fetching Spotify profile:", profileError); + } + + console.log("LOGIN_MODE:", "spotify"); + console.log("ENTERING_SPOTIFY_MODE"); + + // Trigger app main flow as Spotify authenticated user + enableSpotifyMode(); + } else { + Alert.alert('Erro de Autenticação', 'Não foi possível obter o token de acesso do Spotify.'); + } + } catch (e: any) { + console.error('🚀 [LoginScreen] Token Exchange Error:', e); + Alert.alert('Erro de Autenticação', 'Não foi possível trocar o código pelo token.'); + } finally { + setLoading(false); + } + }; const handleLogin = async () => { if (!email || !password) { @@ -37,61 +166,36 @@ export default function LoginScreen({ navigation }) { }; const handleAuthDebug = async () => { - const events = await getAuthDebugEvents(); - Alert.alert('Auth Debug Events', JSON.stringify(events, null, 2)); + // Placeholder function for debug logic }; const handleResetAuth = async () => { await supabase.auth.signOut(); await clearSpotifyTokens(); - await clearAuthDebugEvents(); Alert.alert('Reset', 'Auth state cleared.'); }; const handleSpotifyLogin = async () => { try { - const redirectTo = "roadtripdj://auth/callback"; - await addAuthDebugEvent({ event: 'login_button_pressed', redirectTo }); - - const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'spotify', - options: { - redirectTo, - skipBrowserRedirect: true, - scopes: "user-read-email user-read-private playlist-modify-public playlist-modify-private", - queryParams: { - show_dialog: "true" - } - } - }); - - await addAuthDebugEvent({ - event: 'oauth_url_created', - success: !!data?.url, - host: data?.url ? data.url.split('?')[0] : null - }); - - if (error) { - throw error; + console.log("SPOTIFY_CLIENT_ID_EXISTS:", !!SPOTIFY_CLIENT_ID); + console.log("SPOTIFY_CLIENT_ID_LENGTH:", SPOTIFY_CLIENT_ID.length); + console.log("SPOTIFY_CLIENT_ID_FIRST_6:", SPOTIFY_CLIENT_ID.slice(0, 6)); + console.log("SPOTIFY_REDIRECT_URI:", redirectUri); + console.log("FULL_SPOTIFY_AUTH_URL:", request?.url); + + if ( + !SPOTIFY_CLIENT_ID || + SPOTIFY_CLIENT_ID.includes("PASTE_") || + SPOTIFY_CLIENT_ID.includes("HERE") + ) { + Alert.alert( + 'Aviso', + 'Cole o Client ID real no arquivo LoginScreen.tsx.' + ); + return; } - if (data?.url) { - const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo); - await addAuthDebugEvent({ event: 'web_browser_closed', type: result.type }); - if (result.type === 'success' && result.url) { - await addAuthDebugEvent({ event: 'web_browser_success_url_received', success: true }); - await addAuthDebugEvent({ event: 'processing_web_browser_success_url' }); - - await handleAuthRedirectUrl(result.url); - - const { data: sessionData } = await supabase.auth.getSession(); - await addAuthDebugEvent({ - event: 'post_browser_getSession', - user_exists: Boolean(sessionData.session?.user), - provider_token_exists: Boolean((sessionData.session as any)?.provider_token) - }); - } - } + await promptAsync(); } catch (e: any) { console.error('🚀 [LoginScreen] OAuth Error:', e); Alert.alert('Erro de Autenticação', e.message); @@ -100,7 +204,7 @@ export default function LoginScreen({ navigation }) { return ( - @@ -139,7 +243,7 @@ export default function LoginScreen({ navigation }) { onChangeText={setPassword} /> - - {/* Note: In a real app we would use an actual Spotify SVG logo. Using Music icon for now as a placeholder for the Spotify logo. */} Entrar com Spotify + {__DEV__ && ( + { + console.log("DEMO_BYPASS_PRESSED"); + console.log("LOGIN_MODE:", "demo"); + console.log("ENTERING_DEMO_MODE"); + console.log("AVAILABLE_ROUTE_NAMES", navigation.getState()?.routeNames); + enableDemoMode(); + }} + > + Continuar sem Spotify (demo) + + )} + Auth Debug diff --git a/src/screens/main/HomeScreen.tsx b/src/screens/main/HomeScreen.tsx index 73621b1..328cf09 100644 --- a/src/screens/main/HomeScreen.tsx +++ b/src/screens/main/HomeScreen.tsx @@ -13,7 +13,7 @@ interface Props { export default function HomeScreen({ navigation }: Props) { const { user } = useAuth(); - const userName = user?.user_metadata?.name || 'Viajante'; + const userName = user?.user_metadata?.display_name || user?.user_metadata?.name || user?.email || user?.user_metadata?.email || 'Viajante'; const initial = userName.charAt(0).toUpperCase(); const [trips, setTrips] = useState([]); @@ -26,7 +26,7 @@ export default function HomeScreen({ navigation }: Props) { let query = supabase.from('trips').select('*').order('created_at', { ascending: false }); if (user) { - query = query.or(`user_id.eq.${user.id},user_id.is.null`); + query = query.eq('user_id', user.id); } else { query = query.is('user_id', null); } diff --git a/src/screens/trip/NewTripScreen.tsx b/src/screens/trip/NewTripScreen.tsx index 9d24123..c85dc7e 100644 --- a/src/screens/trip/NewTripScreen.tsx +++ b/src/screens/trip/NewTripScreen.tsx @@ -46,18 +46,39 @@ export default function NewTripScreen({ navigation }) { let generatedPlaylistUrl = null; try { + console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName); + console.log("PLAYLIST_CREATE_START"); + + // Helper for robust parsing + const safeParseJson = async (res: Response, label: string) => { + const rawText = await res.text(); + console.log(`PLAYLIST_API_STATUS [${label}]:`, res.status); + console.log(`PLAYLIST_API_CONTENT_TYPE [${label}]:`, res.headers.get("content-type")); + console.log(`PLAYLIST_API_RAW_RESPONSE [${label}]:`, rawText.substring(0, 300) + (rawText.length > 300 ? "..." : "")); + + try { + return JSON.parse(rawText); + } catch (e) { + throw new Error(`Playlist API returned non-JSON response [${label}]: ${rawText}`); + } + }; + // A. Get provider token const providerToken = await getSpotifyAccessToken(); + console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!providerToken); + if (!providerToken) { - throw new Error("Spotify token missing. Please log in with Spotify again."); - } - - if (providerToken) { + console.log("Spotify token missing, skipping playlist generation."); + Alert.alert('Aviso', 'Spotify token missing, please login again'); + } else { // B. Fetch Spotify User ID const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', { - headers: { 'Authorization': `Bearer ${providerToken}` } + headers: { + 'Authorization': `Bearer ${providerToken}`, + 'Content-Type': 'application/json' + } }); - const spotifyUserData = await spotifyUserRes.json(); + const spotifyUserData = await safeParseJson(spotifyUserRes, 'SpotifyUser'); if (!spotifyUserData.id) throw new Error('Could not fetch Spotify User ID'); const spotifyUserId = spotifyUserData.id; @@ -71,8 +92,25 @@ export default function NewTripScreen({ navigation }) { stream: false }) }); - const ollamaData = await ollamaRes.json(); - const seed_genres = ollamaData.message.content.trim().replace(/\s+/g, '').toLowerCase(); + + let seed_genres = "pop,road-trip,happy"; // Fallback genres + try { + const ollamaData = await safeParseJson(ollamaRes, 'Ollama'); + let rawAiText = ollamaData?.message?.content || ""; + + // Clean AI text + rawAiText = rawAiText.replace(/```json/g, '').replace(/```/g, '').trim(); + + if (rawAiText.length > 0 && !rawAiText.toLowerCase().startsWith("a ")) { + seed_genres = rawAiText.replace(/\s+/g, '').toLowerCase(); + // Spotify limits to 5 seed genres, let's keep it clean + seed_genres = seed_genres.split(',').slice(0, 5).join(','); + } else { + console.log("AI returned plain text/error, using fallback genres:", rawAiText); + } + } catch (aiError) { + console.log("AI parsing failed, using fallback genres.", aiError); + } // D. Create empty playlist const createPlaylistRes = await fetch(`https://api.spotify.com/v1/users/${spotifyUserId}/playlists`, { @@ -87,24 +125,45 @@ export default function NewTripScreen({ navigation }) { public: false }) }); - const playlistData = await createPlaylistRes.json(); + const playlistData = await safeParseJson(createPlaylistRes, 'CreatePlaylist'); if (!playlistData.id) throw new Error('Could not create playlist'); const playlistId = playlistData.id; generatedPlaylistUrl = playlistData.external_urls.spotify; - // E. Fetch Spotify track recommendations + // E. Fetch Spotify track recommendations via Search (does not require Premium) let accumulatedDurationMs = 0; let trackUris: string[] = []; let attempts = 0; + const genresList = seed_genres.split(','); + let genreIndex = 0; + while (accumulatedDurationMs < tripDurationMs && attempts < 10) { - const recommendationsRes = await fetch(`https://api.spotify.com/v1/recommendations?seed_genres=${encodeURIComponent(seed_genres)}&limit=50`, { - headers: { 'Authorization': `Bearer ${providerToken}` } + const currentGenre = genresList[genreIndex % genresList.length] || 'pop'; + const query = encodeURIComponent(`genre:${currentGenre}`); + const searchRes = await fetch(`https://api.spotify.com/v1/search?type=track&q=${query}&limit=50&offset=${attempts * 50}`, { + headers: { + 'Authorization': `Bearer ${providerToken}`, + 'Content-Type': 'application/json' + } }); - if (!recommendationsRes.ok) break; - const recommendationsData = await recommendationsRes.json(); - if (!recommendationsData.tracks || recommendationsData.tracks.length === 0) break; + + if (!searchRes.ok) { + const errText = await searchRes.text(); + console.error("Search failed:", errText); + Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`); + break; + } + + const searchData = await safeParseJson(searchRes, 'SearchTracks'); + const tracks = searchData?.tracks?.items; + + if (!tracks || tracks.length === 0) { + genreIndex++; + attempts++; + continue; + } - for (const track of recommendationsData.tracks) { + for (const track of tracks) { if (!trackUris.includes(track.uri)) { trackUris.push(track.uri); accumulatedDurationMs += track.duration_ms; @@ -112,6 +171,7 @@ export default function NewTripScreen({ navigation }) { } } attempts++; + genreIndex++; } if (trackUris.length > 0) { @@ -128,16 +188,14 @@ export default function NewTripScreen({ navigation }) { body: JSON.stringify({ uris: chunk }) }); } + console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl); } else { console.error("No tracks found for genres:", seed_genres); } - } else { - console.error("Spotify token missing, skipping playlist generation."); - Alert.alert('Aviso', 'Sessão Spotify não encontrada. A viagem será guardada sem playlist.'); } - } catch (playlistError) { - console.error("Error generating playlist:", playlistError); - Alert.alert('Erro Playlist', 'A viagem foi calculada, mas ocorreu um erro a criar a playlist.'); + } catch (playlistError: any) { + console.warn("Expected failure generating playlist:", playlistError.message || playlistError); + Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistError.message?.substring(0, 50) || 'Erro Desconhecido'}`); } // G. Save to Supabase unconditionally if route is valid