require('dotenv').config(); const express = require('express'); const cors = require('cors'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const path = require('path'); const db = require('./database'); const app = express(); app.use(cors()); app.use(express.json()); const JWT_SECRET = 'cinematch-super-secret-key'; const TMDB_API_KEY = process.env.TMDB_API_KEY || '3fd2be6f0c70a2a598f084ddfb75487c'; // Fallback to a public tutorial key if not set // Helper to fetch from TMDB const fetchTMDB = async (endpoint, params = {}) => { const url = new URL(`https://api.themoviedb.org/3${endpoint}`); url.searchParams.append('api_key', TMDB_API_KEY); for (const [key, value] of Object.entries(params)) { url.searchParams.append(key, value); } const res = await fetch(url); if (!res.ok) throw new Error('TMDB API error'); return res.json(); }; // Map TMDB movie to our Movie format const mapMovie = (m) => ({ id: m.id, title: m.title, posterUrl: m.poster_path ? `https://image.tmdb.org/t/p/w500${m.poster_path}` : 'https://via.placeholder.com/500x750?text=No+Poster', rating: m.vote_average, synopsis: m.overview, year: m.release_date ? parseInt(m.release_date.substring(0, 4)) : null, genre: m.genre_ids.map(String) }); // Helper to generate random code const generateCode = () => Math.random().toString(36).substring(2, 8).toUpperCase(); // Middleware to authenticate const authenticate = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); try { const decoded = jwt.verify(token, JWT_SECRET); req.userId = decoded.userId; next(); } catch (err) { res.status(401).json({ error: 'Invalid token' }); } }; // Register app.post('/api/auth/register', async (req, res) => { const { username, password } = req.body; if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); const hashedPassword = await bcrypt.hash(password, 10); const code = generateCode(); db.run('INSERT INTO users (username, password, code) VALUES (?, ?, ?)', [username, hashedPassword, code], function (err) { if (err) return res.status(400).json({ error: 'Username already exists' }); const token = jwt.sign({ userId: this.lastID }, JWT_SECRET); res.json({ token, user: { id: this.lastID, username, code, partner_code: null, genres: null } }); }); }); // Login app.post('/api/auth/login', (req, res) => { const { username, password } = req.body; db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => { if (err || !user) return res.status(400).json({ error: 'Invalid credentials' }); const match = await bcrypt.compare(password, user.password); if (!match) return res.status(400).json({ error: 'Invalid credentials' }); const token = jwt.sign({ userId: user.id }, JWT_SECRET); res.json({ token, user: { id: user.id, username: user.username, code: user.code, partner_code: user.partner_code, genres: user.genres ? JSON.parse(user.genres) : null } }); }); }); // Anonymous Login app.post('/api/auth/anonymous', (req, res) => { const code = generateCode(); db.run('INSERT INTO users (code) VALUES (?)', [code], function (err) { if (err) return res.status(500).json({ error: 'Failed to create anonymous user' }); const token = jwt.sign({ userId: this.lastID }, JWT_SECRET); res.json({ token, user: { id: this.lastID, username: null, code, partner_code: null, genres: null } }); }); }); // Get Me app.get('/api/auth/me', authenticate, (req, res) => { db.get('SELECT id, username, code, partner_code, genres FROM users WHERE id = ?', [req.userId], (err, user) => { if (err || !user) return res.status(404).json({ error: 'User not found' }); res.json({ user: { ...user, genres: user.genres ? JSON.parse(user.genres) : null } }); }); }); // Update Genres app.post('/api/user/genres', authenticate, (req, res) => { const { genres } = req.body; db.run('UPDATE users SET genres = ? WHERE id = ?', [JSON.stringify(genres), req.userId], (err) => { if (err) return res.status(500).json({ error: 'Failed to update genres' }); res.json({ success: true }); }); }); // Link Partner app.post('/api/link', authenticate, (req, res) => { const { partnerCode } = req.body; db.get('SELECT id, code FROM users WHERE code = ?', [partnerCode], (err, partner) => { if (err || !partner) return res.status(404).json({ error: 'Partner code not found' }); if (partner.id === req.userId) return res.status(400).json({ error: 'Cannot link with yourself' }); db.get('SELECT code FROM users WHERE id = ?', [req.userId], (err, user) => { if (err || !user) return res.status(500).json({ error: 'User not found' }); db.run('UPDATE users SET partner_code = ? WHERE id = ?', [partnerCode, req.userId], (err) => { if (err) return res.status(500).json({ error: 'Failed to link partner' }); db.run('UPDATE users SET partner_code = ? WHERE id = ?', [user.code, partner.id], (err) => { if (err) return res.status(500).json({ error: 'Failed to link partner on the other side' }); res.json({ success: true, partner_code: partnerCode }); }); }); }); }); }); // Unlink Partner app.post('/api/unlink', authenticate, (req, res) => { db.get('SELECT partner_code FROM users WHERE id = ?', [req.userId], (err, user) => { if (err || !user) return res.status(500).json({ error: 'User not found' }); db.run('UPDATE users SET partner_code = NULL WHERE id = ?', [req.userId], (err) => { if (err) return res.status(500).json({ error: 'Failed to unlink partner' }); if (user.partner_code) { db.run('UPDATE users SET partner_code = NULL WHERE code = ?', [user.partner_code], (err) => { res.json({ success: true }); }); } else { res.json({ success: true }); } }); }); }); // Swipe app.post('/api/swipe', authenticate, (req, res) => { const { movieId, direction } = req.body; db.run('INSERT OR REPLACE INTO swipes (user_id, movie_id, direction) VALUES (?, ?, ?)', [req.userId, movieId, direction], function (err) { if (err) return res.status(500).json({ error: 'Failed to record swipe' }); res.json({ success: true }); }); }); // Get Swipes app.get('/api/swipes', authenticate, (req, res) => { db.all('SELECT movie_id, direction FROM swipes WHERE user_id = ?', [req.userId], (err, rows) => { if (err) return res.status(500).json({ error: 'Failed to get swipes' }); res.json({ swipes: rows }); }); }); // Get Matches app.get('/api/matches', authenticate, (req, res) => { db.get('SELECT partner_code FROM users WHERE id = ?', [req.userId], (err, user) => { if (err || !user || !user.partner_code) return res.json({ matches: [] }); db.get('SELECT id FROM users WHERE code = ?', [user.partner_code], (err, partner) => { if (err || !partner) return res.json({ matches: [] }); const query = ` SELECT s1.movie_id FROM swipes s1 JOIN swipes s2 ON s1.movie_id = s2.movie_id WHERE s1.user_id = ? AND s2.user_id = ? AND s1.direction = 'right' AND s2.direction = 'right' `; db.all(query, [req.userId, partner.id], async (err, rows) => { if (err) return res.status(500).json({ error: 'Failed to get matches' }); const matchIds = rows.map(r => r.movie_id); const matchDetails = []; for (const id of matchIds) { try { const movieData = await fetchTMDB(`/movie/${id}`); if (movieData && !movieData.error) { matchDetails.push({ id: movieData.id, title: movieData.title, poster_path: movieData.poster_path, overview: movieData.overview, release_date: movieData.release_date, vote_average: movieData.vote_average }); } } catch (e) { console.error('Error fetching match details:', e); } } res.json({ matches: matchDetails }); }); }); }); }); // Mark as watched app.post('/api/watched', authenticate, (req, res) => { const { movieId } = req.body; db.run('INSERT OR IGNORE INTO watched (user_id, movie_id) VALUES (?, ?)', [req.userId, movieId], function (err) { if (err) return res.status(500).json({ error: 'Failed to mark as watched' }); res.json({ success: true }); }); }); // Get watched app.get('/api/watched', authenticate, (req, res) => { db.all('SELECT movie_id FROM watched WHERE user_id = ?', [req.userId], (err, rows) => { if (err) return res.status(500).json({ error: 'Failed to get watched movies' }); res.json({ watched: rows.map(r => r.movie_id) }); }); }); // Get Movie Feed app.get('/api/movies/feed', authenticate, async (req, res) => { console.log('Fetching feed for user:', req.userId); try { // 1. Get user info (genres, partner_code) const user = await new Promise((resolve, reject) => { db.get('SELECT * FROM users WHERE id = ?', [req.userId], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (!user) return res.status(404).json({ error: 'User not found' }); const userGenres = user.genres ? JSON.parse(user.genres) : []; // 2. Get user's swipes to filter out already seen movies const userSwipes = await new Promise((resolve, reject) => { db.all('SELECT movie_id FROM swipes WHERE user_id = ?', [req.userId], (err, rows) => { if (err) reject(err); else resolve(rows.map(r => r.movie_id)); }); }); let feedMovies = []; // 3. If user has a partner, get partner's liked movies that user hasn't swiped on if (user.partner_code) { const partner = await new Promise((resolve, reject) => { db.get('SELECT id FROM users WHERE code = ?', [user.partner_code], (err, row) => { if (err) reject(err); else resolve(row); }); }); if (partner) { const partnerLikes = await new Promise((resolve, reject) => { db.all('SELECT movie_id FROM swipes WHERE user_id = ? AND direction = "right"', [partner.id], (err, rows) => { if (err) reject(err); else resolve(rows.map(r => r.movie_id)); }); }); const unseenPartnerLikes = partnerLikes.filter(id => !userSwipes.includes(id)); // Fetch details for these movies from TMDB for (const movieId of unseenPartnerLikes.slice(0, 5)) { // Limit to 5 partner likes per feed request try { const movieData = await fetchTMDB(`/movie/${movieId}`, { append_to_response: 'credits' }); if (movieData && !movieData.error) { const director = movieData.credits?.crew?.find(c => c.job === 'Director')?.name || 'Unknown'; const genres = movieData.genres?.map(g => g.name) || []; feedMovies.push({ id: movieData.id, title: movieData.title, poster_path: movieData.poster_path, overview: movieData.overview, release_date: movieData.release_date, vote_average: movieData.vote_average, director: director, genres: genres, imdb_id: movieData.imdb_id, isPartnerLike: true // Flag to show it's a partner like }); } } catch (e) { console.error('Error fetching partner movie:', e); } } } } // 4. Fetch recommendations from TMDB based on genres let tmdbEndpoint = '/movie/popular'; let queryParams = new URLSearchParams(); if (userGenres && userGenres.length > 0) { tmdbEndpoint = '/discover/movie'; // Use pipe '|' for OR condition (movies with ANY of the selected genres) queryParams.append('with_genres', userGenres.join('|')); queryParams.append('sort_by', 'popularity.desc'); } // Try to fetch movies until we have enough for the feed (at least 10) let attempts = 0; while (feedMovies.length < 10 && attempts < 5) { // Limit to top 50 pages to ensure we get relatively popular/good movies const randomPage = Math.floor(Math.random() * 50) + 1; queryParams.set('page', randomPage.toString()); const tmdbUrl = `${tmdbEndpoint}?${queryParams.toString()}`; const tmdbResponse = await fetchTMDB(tmdbUrl); if (tmdbResponse && tmdbResponse.results) { const recommendedMovies = tmdbResponse.results .filter(m => !userSwipes.includes(m.id) && !feedMovies.some(fm => fm.id === m.id)); for (const m of recommendedMovies) { if (feedMovies.length >= 10) break; // Limit feed to 10 movies per request try { const details = await fetchTMDB(`/movie/${m.id}`, { append_to_response: 'credits' }); if (details && !details.error) { const director = details.credits?.crew?.find(c => c.job === 'Director')?.name || 'Unknown'; const genres = details.genres?.map(g => g.name) || []; feedMovies.push({ id: details.id, title: details.title, poster_path: details.poster_path, overview: details.overview, release_date: details.release_date, vote_average: details.vote_average, director: director, genres: genres, imdb_id: details.imdb_id, isPartnerLike: false }); } } catch (e) { console.error('Error fetching recommended movie details:', e); } } } attempts++; } res.setHeader('Cache-Control', 'no-store'); res.json({ movies: feedMovies }); } catch (error) { console.error('Feed error:', error); res.status(500).json({ error: 'Failed to generate feed' }); } }); // Serve frontend static files in production app.use(express.static(path.join(__dirname, '../frontend/dist'), { setHeaders: (res, path) => { if (path.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } } })); app.use((req, res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); }); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });