Fix: Spotify OAuth RLS integration, profile mapping, and robust AI playlist generation bypass

This commit is contained in:
RoadtripDJ Dev
2026-05-19 01:27:04 +01:00
parent a0f11f73e8
commit 9222d3a483
6 changed files with 286 additions and 83 deletions

4
.env
View File

@@ -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
EXPO_PUBLIC_SPOTIFY_CLIENT_ID=7fa1e7acbf7e44f18bf28d74f14fb9cb

View File

@@ -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...');

View File

@@ -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<AuthContextType>({
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<User | null>(null);
const [session, setSession] = useState<Session | null>(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 (
<AuthContext.Provider value={{ user, session, loading }}>
<AuthContext.Provider value={{ user, session, loading, isDemoMode, isSpotifyAuthenticated, enableDemoMode, enableSpotifyMode }}>
{children}
</AuthContext.Provider>
);

View File

@@ -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 });
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);
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;
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);
@@ -152,11 +256,25 @@ export default function LoginScreen({ navigation }) {
</TouchableOpacity>
<TouchableOpacity style={styles.spotifyButton} onPress={handleSpotifyLogin}>
{/* 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. */}
<Music color={colors.white} size={20} style={styles.spotifyIcon} />
<Text style={styles.spotifyButtonText}>Entrar com Spotify</Text>
</TouchableOpacity>
{__DEV__ && (
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: '#333', marginBottom: 24 }]}
onPress={() => {
console.log("DEMO_BYPASS_PRESSED");
console.log("LOGIN_MODE:", "demo");
console.log("ENTERING_DEMO_MODE");
console.log("AVAILABLE_ROUTE_NAMES", navigation.getState()?.routeNames);
enableDemoMode();
}}
>
<Text style={styles.primaryButtonText}>Continuar sem Spotify (demo)</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={{ padding: 10, alignItems: 'center' }} onPress={handleAuthDebug}>
<Text style={{ color: colors.textSecondary }}>Auth Debug</Text>
</TouchableOpacity>

View File

@@ -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<any[]>([]);
@@ -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);
}

View File

@@ -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();
if (!providerToken) {
throw new Error("Spotify token missing. Please log in with Spotify again.");
}
console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!providerToken);
if (providerToken) {
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;
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}` }
});
if (!recommendationsRes.ok) break;
const recommendationsData = await recommendationsRes.json();
if (!recommendationsData.tracks || recommendationsData.tracks.length === 0) break;
const genresList = seed_genres.split(',');
let genreIndex = 0;
for (const track of recommendationsData.tracks) {
while (accumulatedDurationMs < tripDurationMs && attempts < 10) {
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 (!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 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