setup features
This commit is contained in:
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Backend Environment Variables
|
||||
PORT=4000
|
||||
JWT_SECRET=your_super_secret_jwt_key_change_this_in_production
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=db
|
||||
DB_USER=root
|
||||
DB_PASSWORD=rootpassword
|
||||
DB_NAME=whats_the_point
|
||||
|
||||
# TMDB API Key (get from https://www.themoviedb.org/settings/api)
|
||||
TMDB_API_KEY=your_tmdb_api_key_here
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 4000
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -4,14 +4,16 @@
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node src/index.js"
|
||||
"dev": "node src/index.js",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"mysql2": "^3.6.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"cors": "^2.8.5"
|
||||
"cors": "^2.8.5",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
56
backend/src/db/index.js
Normal file
56
backend/src/db/index.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
let pool;
|
||||
|
||||
export const initDB = async () => {
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || 'root',
|
||||
database: process.env.DB_NAME || 'whats_the_point',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
multipleStatements: true
|
||||
};
|
||||
|
||||
// Create pool
|
||||
pool = mysql.createPool(dbConfig);
|
||||
|
||||
// Test connection and run init script
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
console.log('✅ Database connected');
|
||||
|
||||
// Read and execute init script
|
||||
const initSQL = fs.readFileSync(path.join(__dirname, 'init.sql'), 'utf8');
|
||||
await connection.query(initSQL);
|
||||
console.log('✅ Database schema initialized');
|
||||
|
||||
connection.release();
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return pool;
|
||||
};
|
||||
|
||||
export const getDB = () => {
|
||||
if (!pool) {
|
||||
throw new Error('Database not initialized. Call initDB() first.');
|
||||
}
|
||||
return pool;
|
||||
};
|
||||
|
||||
export const query = async (sql, params) => {
|
||||
const db = getDB();
|
||||
const [results] = await db.execute(sql, params);
|
||||
return results;
|
||||
};
|
||||
78
backend/src/db/init.sql
Normal file
78
backend/src/db/init.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- Database initialization script for What's The Point
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_username (username)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS challenges (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
cover_image_url VARCHAR(500),
|
||||
tmdb_id INT,
|
||||
media_type ENUM('movie', 'tv') DEFAULT 'movie',
|
||||
created_by INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_created_by (created_by)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS challenge_participants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
challenge_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending',
|
||||
invited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
responded_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_participant (challenge_id, user_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS predictions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
challenge_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status ENUM('pending', 'validated', 'invalidated') DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
validated_by INT NULL,
|
||||
validated_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (validated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_challenge_id (challenge_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
friend_id INT NOT NULL,
|
||||
status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_friendship (user_id, friend_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_friend_id (friend_id),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tmdb_cache (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
query VARCHAR(255) NOT NULL,
|
||||
media_type VARCHAR(20) NOT NULL,
|
||||
response_data JSON NOT NULL,
|
||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_query (query, media_type),
|
||||
INDEX idx_query (query)
|
||||
);
|
||||
@@ -1,17 +1,59 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDB } from './db/index.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import challengeRoutes from './routes/challenges.js';
|
||||
import predictionRoutes from './routes/predictions.js';
|
||||
import friendRoutes from './routes/friends.js';
|
||||
import tmdbRoutes from './routes/tmdb.js';
|
||||
import leaderboardRoutes from './routes/leaderboard.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ message: "What's The Point API" });
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/challenges', challengeRoutes);
|
||||
app.use('/api/predictions', predictionRoutes);
|
||||
app.use('/api/friends', friendRoutes);
|
||||
app.use('/api/tmdb', tmdbRoutes);
|
||||
app.use('/api/leaderboard', leaderboardRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', message: "What's The Point API" });
|
||||
});
|
||||
|
||||
// Serve static frontend files (for production)
|
||||
const frontendPath = path.join(__dirname, '../../frontend/dist');
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
// Serve index.html for all non-API routes (SPA support)
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`API running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Initialize database and start server
|
||||
initDB()
|
||||
.then(() => {
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✅ Server running on port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
19
backend/src/middleware/auth.js
Normal file
19
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export const authMiddleware = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded; // { userId, email, username }
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
122
backend/src/routes/auth.js
Normal file
122
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { query } from '../db/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Register
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, username, password } = req.body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: 'All fields required' });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existing = await query(
|
||||
'SELECT id FROM users WHERE email = ? OR username = ?',
|
||||
[email, username]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(400).json({ error: 'User already exists' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)',
|
||||
[email, username, password_hash]
|
||||
);
|
||||
|
||||
const userId = result.insertId;
|
||||
|
||||
// Generate token
|
||||
const token = jwt.sign(
|
||||
{ userId, email, username },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
res.json({ token, user: { id: userId, email, username } });
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password required' });
|
||||
}
|
||||
|
||||
// Find user
|
||||
const users = await query(
|
||||
'SELECT id, email, username, password_hash FROM users WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// Check password
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, username: user.username },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, username: user.username }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
const users = await query(
|
||||
'SELECT id, email, username, created_at FROM users WHERE id = ?',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ user: users[0] });
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
218
backend/src/routes/challenges.js
Normal file
218
backend/src/routes/challenges.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all challenges for the current user
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const challenges = await query(
|
||||
`SELECT
|
||||
c.*,
|
||||
u.username as creator_username,
|
||||
cp.status as participation_status,
|
||||
(SELECT COUNT(*) FROM predictions WHERE challenge_id = c.id AND status = 'validated' AND user_id = ?) as my_points
|
||||
FROM challenges c
|
||||
INNER JOIN users u ON c.created_by = u.id
|
||||
LEFT JOIN challenge_participants cp ON cp.challenge_id = c.id AND cp.user_id = ?
|
||||
WHERE c.created_by = ? OR (cp.user_id = ? AND cp.status = 'accepted')
|
||||
ORDER BY c.created_at DESC`,
|
||||
[req.user.userId, req.user.userId, req.user.userId, req.user.userId]
|
||||
);
|
||||
|
||||
res.json({ challenges });
|
||||
} catch (error) {
|
||||
console.error('Get challenges error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch challenges' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single challenge with details
|
||||
router.get('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const challengeId = req.params.id;
|
||||
|
||||
// Get challenge details
|
||||
const challenges = await query(
|
||||
`SELECT c.*, u.username as creator_username
|
||||
FROM challenges c
|
||||
INNER JOIN users u ON c.created_by = u.id
|
||||
WHERE c.id = ?`,
|
||||
[challengeId]
|
||||
);
|
||||
|
||||
if (challenges.length === 0) {
|
||||
return res.status(404).json({ error: 'Challenge not found' });
|
||||
}
|
||||
|
||||
const challenge = challenges[0];
|
||||
|
||||
// Check if user has access
|
||||
const access = await query(
|
||||
`SELECT * FROM challenge_participants
|
||||
WHERE challenge_id = ? AND user_id = ? AND status = 'accepted'`,
|
||||
[challengeId, req.user.userId]
|
||||
);
|
||||
|
||||
if (challenge.created_by !== req.user.userId && access.length === 0) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Get participants with their points
|
||||
const participants = await query(
|
||||
`SELECT
|
||||
u.id, u.username, u.email,
|
||||
cp.status,
|
||||
(SELECT COUNT(*) FROM predictions WHERE challenge_id = ? AND user_id = u.id AND status = 'validated') as points
|
||||
FROM challenge_participants cp
|
||||
INNER JOIN users u ON cp.user_id = u.id
|
||||
WHERE cp.challenge_id = ?
|
||||
ORDER BY points DESC`,
|
||||
[challengeId, challengeId]
|
||||
);
|
||||
|
||||
// Get creator's points
|
||||
const creatorPoints = await query(
|
||||
`SELECT COUNT(*) as points FROM predictions
|
||||
WHERE challenge_id = ? AND user_id = ? AND status = 'validated'`,
|
||||
[challengeId, challenge.created_by]
|
||||
);
|
||||
|
||||
res.json({
|
||||
challenge,
|
||||
participants,
|
||||
creator_points: creatorPoints[0].points
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get challenge error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch challenge' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new challenge
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { title, cover_image_url, tmdb_id, media_type } = req.body;
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'INSERT INTO challenges (title, cover_image_url, tmdb_id, media_type, created_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[title, cover_image_url || null, tmdb_id || null, media_type || 'movie', req.user.userId]
|
||||
);
|
||||
|
||||
const challenge = {
|
||||
id: result.insertId,
|
||||
title,
|
||||
cover_image_url,
|
||||
tmdb_id,
|
||||
media_type,
|
||||
created_by: req.user.userId,
|
||||
creator_username: req.user.username
|
||||
};
|
||||
|
||||
res.json({ challenge });
|
||||
} catch (error) {
|
||||
console.error('Create challenge error:', error);
|
||||
res.status(500).json({ error: 'Failed to create challenge' });
|
||||
}
|
||||
});
|
||||
|
||||
// Invite users to a challenge
|
||||
router.post('/:id/invite', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const challengeId = req.params.id;
|
||||
const { user_ids, emails } = req.body;
|
||||
|
||||
// Verify user owns the challenge or is a participant
|
||||
const challenges = await query(
|
||||
'SELECT * FROM challenges WHERE id = ?',
|
||||
[challengeId]
|
||||
);
|
||||
|
||||
if (challenges.length === 0) {
|
||||
return res.status(404).json({ error: 'Challenge not found' });
|
||||
}
|
||||
|
||||
const challenge = challenges[0];
|
||||
|
||||
if (challenge.created_by !== req.user.userId) {
|
||||
// Check if user is an accepted participant
|
||||
const participation = await query(
|
||||
'SELECT * FROM challenge_participants WHERE challenge_id = ? AND user_id = ? AND status = "accepted"',
|
||||
[challengeId, req.user.userId]
|
||||
);
|
||||
|
||||
if (participation.length === 0) {
|
||||
return res.status(403).json({ error: 'Only challenge participants can invite others' });
|
||||
}
|
||||
}
|
||||
|
||||
const invitedUsers = [];
|
||||
|
||||
// Invite by user IDs
|
||||
if (user_ids && Array.isArray(user_ids)) {
|
||||
for (const userId of user_ids) {
|
||||
try {
|
||||
await query(
|
||||
'INSERT INTO challenge_participants (challenge_id, user_id, status) VALUES (?, ?, "pending")',
|
||||
[challengeId, userId]
|
||||
);
|
||||
invitedUsers.push(userId);
|
||||
} catch (err) {
|
||||
// Ignore duplicate key errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invite by emails
|
||||
if (emails && Array.isArray(emails)) {
|
||||
for (const email of emails) {
|
||||
const users = await query('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (users.length > 0) {
|
||||
try {
|
||||
await query(
|
||||
'INSERT INTO challenge_participants (challenge_id, user_id, status) VALUES (?, ?, "pending")',
|
||||
[challengeId, users[0].id]
|
||||
);
|
||||
invitedUsers.push(users[0].id);
|
||||
} catch (err) {
|
||||
// Ignore duplicate key errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ invited: invitedUsers.length });
|
||||
} catch (error) {
|
||||
console.error('Invite error:', error);
|
||||
res.status(500).json({ error: 'Failed to send invites' });
|
||||
}
|
||||
});
|
||||
|
||||
// Accept/reject challenge invitation
|
||||
router.post('/:id/respond', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const challengeId = req.params.id;
|
||||
const { status } = req.body; // 'accepted' or 'rejected'
|
||||
|
||||
if (!['accepted', 'rejected'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
|
||||
await query(
|
||||
'UPDATE challenge_participants SET status = ?, responded_at = NOW() WHERE challenge_id = ? AND user_id = ?',
|
||||
[status, challengeId, req.user.userId]
|
||||
);
|
||||
|
||||
res.json({ status });
|
||||
} catch (error) {
|
||||
console.error('Respond error:', error);
|
||||
res.status(500).json({ error: 'Failed to respond to invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
169
backend/src/routes/friends.js
Normal file
169
backend/src/routes/friends.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Search for users by username or email
|
||||
router.get('/search', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || q.trim().length < 2) {
|
||||
return res.json({ users: [] });
|
||||
}
|
||||
|
||||
const searchTerm = `%${q.trim()}%`;
|
||||
|
||||
const users = await query(
|
||||
`SELECT id, username, email
|
||||
FROM users
|
||||
WHERE (username LIKE ? OR email LIKE ?) AND id != ?
|
||||
LIMIT 20`,
|
||||
[searchTerm, searchTerm, req.user.userId]
|
||||
);
|
||||
|
||||
res.json({ users });
|
||||
} catch (error) {
|
||||
console.error('User search error:', error);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all friends
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
// Get accepted friendships (bidirectional)
|
||||
const friends = await query(
|
||||
`SELECT DISTINCT
|
||||
u.id, u.username, u.email,
|
||||
(SELECT COUNT(*) FROM predictions WHERE user_id = u.id AND status = 'validated') as total_points
|
||||
FROM users u
|
||||
WHERE u.id IN (
|
||||
SELECT friend_id FROM friendships WHERE user_id = ? AND status = 'accepted'
|
||||
UNION
|
||||
SELECT user_id FROM friendships WHERE friend_id = ? AND status = 'accepted'
|
||||
)
|
||||
ORDER BY u.username`,
|
||||
[req.user.userId, req.user.userId]
|
||||
);
|
||||
|
||||
// Also get people who have shared challenges with me (auto-friends from challenges)
|
||||
const challengeFriends = await query(
|
||||
`SELECT DISTINCT
|
||||
u.id, u.username, u.email,
|
||||
(SELECT COUNT(*) FROM predictions WHERE user_id = u.id AND status = 'validated') as total_points
|
||||
FROM users u
|
||||
WHERE u.id IN (
|
||||
SELECT DISTINCT cp.user_id
|
||||
FROM challenge_participants cp
|
||||
INNER JOIN challenge_participants my_cp ON cp.challenge_id = my_cp.challenge_id
|
||||
WHERE my_cp.user_id = ? AND cp.user_id != ? AND cp.status = 'accepted' AND my_cp.status = 'accepted'
|
||||
UNION
|
||||
SELECT DISTINCT c.created_by
|
||||
FROM challenges c
|
||||
INNER JOIN challenge_participants cp ON cp.challenge_id = c.id
|
||||
WHERE cp.user_id = ? AND c.created_by != ? AND cp.status = 'accepted'
|
||||
)
|
||||
AND u.id NOT IN (${friends.map(() => '?').join(',') || 'NULL'})
|
||||
ORDER BY u.username`,
|
||||
[req.user.userId, req.user.userId, req.user.userId, req.user.userId, ...friends.map(f => f.id)]
|
||||
);
|
||||
|
||||
res.json({
|
||||
friends,
|
||||
challenge_friends: challengeFriends
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get friends error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch friends' });
|
||||
}
|
||||
});
|
||||
|
||||
// Send friend request
|
||||
router.post('/request', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.body;
|
||||
|
||||
if (!user_id) {
|
||||
return res.status(400).json({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
if (user_id === req.user.userId) {
|
||||
return res.status(400).json({ error: 'Cannot add yourself as friend' });
|
||||
}
|
||||
|
||||
// Check if already friends or request exists
|
||||
const existing = await query(
|
||||
`SELECT * FROM friendships
|
||||
WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)`,
|
||||
[req.user.userId, user_id, user_id, req.user.userId]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(400).json({ error: 'Friend request already exists or you are already friends' });
|
||||
}
|
||||
|
||||
await query(
|
||||
'INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, "pending")',
|
||||
[req.user.userId, user_id]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Friend request error:', error);
|
||||
res.status(500).json({ error: 'Failed to send friend request' });
|
||||
}
|
||||
});
|
||||
|
||||
// Accept/reject friend request
|
||||
router.post('/respond', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { friendship_id, status } = req.body;
|
||||
|
||||
if (!['accepted', 'rejected'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
|
||||
// Verify the request is for the current user
|
||||
const friendships = await query(
|
||||
'SELECT * FROM friendships WHERE id = ? AND friend_id = ?',
|
||||
[friendship_id, req.user.userId]
|
||||
);
|
||||
|
||||
if (friendships.length === 0) {
|
||||
return res.status(404).json({ error: 'Friend request not found' });
|
||||
}
|
||||
|
||||
await query(
|
||||
'UPDATE friendships SET status = ? WHERE id = ?',
|
||||
[status, friendship_id]
|
||||
);
|
||||
|
||||
res.json({ status });
|
||||
} catch (error) {
|
||||
console.error('Respond to friend request error:', error);
|
||||
res.status(500).json({ error: 'Failed to respond to friend request' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get pending friend requests
|
||||
router.get('/requests', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const requests = await query(
|
||||
`SELECT f.id, f.created_at, u.id as user_id, u.username, u.email
|
||||
FROM friendships f
|
||||
INNER JOIN users u ON f.user_id = u.id
|
||||
WHERE f.friend_id = ? AND f.status = 'pending'
|
||||
ORDER BY f.created_at DESC`,
|
||||
[req.user.userId]
|
||||
);
|
||||
|
||||
res.json({ requests });
|
||||
} catch (error) {
|
||||
console.error('Get friend requests error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch friend requests' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
124
backend/src/routes/leaderboard.js
Normal file
124
backend/src/routes/leaderboard.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get leaderboard for a specific challenge
|
||||
router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.params;
|
||||
|
||||
// Verify access
|
||||
const access = await query(
|
||||
`SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN (
|
||||
SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted'
|
||||
))`,
|
||||
[challengeId, req.user.userId, req.user.userId]
|
||||
);
|
||||
|
||||
if (access.length === 0) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Get leaderboard
|
||||
const leaderboard = await query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as validated_points,
|
||||
COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions,
|
||||
COUNT(CASE WHEN p.status = 'invalidated' THEN 1 END) as invalidated_predictions
|
||||
FROM challenge_participants cp
|
||||
INNER JOIN users u ON cp.user_id = u.id
|
||||
LEFT JOIN predictions p ON p.user_id = u.id AND p.challenge_id = ?
|
||||
WHERE cp.challenge_id = ? AND cp.status = 'accepted'
|
||||
GROUP BY u.id, u.username
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as validated_points,
|
||||
COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions,
|
||||
COUNT(CASE WHEN p.status = 'invalidated' THEN 1 END) as invalidated_predictions
|
||||
FROM challenges c
|
||||
INNER JOIN users u ON c.created_by = u.id
|
||||
LEFT JOIN predictions p ON p.user_id = u.id AND p.challenge_id = ?
|
||||
WHERE c.id = ?
|
||||
GROUP BY u.id, u.username
|
||||
|
||||
ORDER BY validated_points DESC, username`,
|
||||
[challengeId, challengeId, challengeId, challengeId]
|
||||
);
|
||||
|
||||
res.json({ leaderboard });
|
||||
} catch (error) {
|
||||
console.error('Challenge leaderboard error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch leaderboard' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get global leaderboard (all users)
|
||||
router.get('/global', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const leaderboard = await query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as total_points,
|
||||
COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions,
|
||||
COUNT(DISTINCT p.challenge_id) as challenges_participated
|
||||
FROM users u
|
||||
LEFT JOIN predictions p ON p.user_id = u.id
|
||||
GROUP BY u.id, u.username
|
||||
HAVING total_points > 0 OR pending_predictions > 0
|
||||
ORDER BY total_points DESC, username
|
||||
LIMIT 100`
|
||||
);
|
||||
|
||||
res.json({ leaderboard });
|
||||
} catch (error) {
|
||||
console.error('Global leaderboard error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch leaderboard' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user profile stats
|
||||
router.get('/profile/:userId?', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.userId || req.user.userId;
|
||||
|
||||
const stats = await query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.email,
|
||||
u.created_at,
|
||||
COUNT(DISTINCT c.id) as challenges_created,
|
||||
COUNT(DISTINCT cp.challenge_id) as challenges_joined,
|
||||
COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as total_points,
|
||||
COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions,
|
||||
COUNT(CASE WHEN p.status = 'invalidated' THEN 1 END) as invalidated_predictions
|
||||
FROM users u
|
||||
LEFT JOIN challenges c ON c.created_by = u.id
|
||||
LEFT JOIN challenge_participants cp ON cp.user_id = u.id AND cp.status = 'accepted'
|
||||
LEFT JOIN predictions p ON p.user_id = u.id
|
||||
WHERE u.id = ?
|
||||
GROUP BY u.id`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (stats.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ profile: stats[0] });
|
||||
} catch (error) {
|
||||
console.error('Profile stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch profile' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
140
backend/src/routes/predictions.js
Normal file
140
backend/src/routes/predictions.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all predictions for a challenge
|
||||
router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.params;
|
||||
|
||||
// Verify access
|
||||
const access = await query(
|
||||
`SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN (
|
||||
SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted'
|
||||
))`,
|
||||
[challengeId, req.user.userId, req.user.userId]
|
||||
);
|
||||
|
||||
if (access.length === 0) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Get predictions
|
||||
const predictions = await query(
|
||||
`SELECT
|
||||
p.*,
|
||||
u.username,
|
||||
v.username as validated_by_username
|
||||
FROM predictions p
|
||||
INNER JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN users v ON p.validated_by = v.id
|
||||
WHERE p.challenge_id = ?
|
||||
ORDER BY p.created_at DESC`,
|
||||
[challengeId]
|
||||
);
|
||||
|
||||
res.json({ predictions });
|
||||
} catch (error) {
|
||||
console.error('Get predictions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch predictions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new prediction
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { challenge_id, content } = req.body;
|
||||
|
||||
if (!challenge_id || !content || !content.trim()) {
|
||||
return res.status(400).json({ error: 'Challenge ID and content are required' });
|
||||
}
|
||||
|
||||
// Verify access
|
||||
const access = await query(
|
||||
`SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN (
|
||||
SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted'
|
||||
))`,
|
||||
[challenge_id, req.user.userId, req.user.userId]
|
||||
);
|
||||
|
||||
if (access.length === 0) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'INSERT INTO predictions (challenge_id, user_id, content) VALUES (?, ?, ?)',
|
||||
[challenge_id, req.user.userId, content.trim()]
|
||||
);
|
||||
|
||||
const prediction = {
|
||||
id: result.insertId,
|
||||
challenge_id,
|
||||
user_id: req.user.userId,
|
||||
username: req.user.username,
|
||||
content: content.trim(),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
};
|
||||
|
||||
res.json({ prediction });
|
||||
} catch (error) {
|
||||
console.error('Create prediction error:', error);
|
||||
res.status(500).json({ error: 'Failed to create prediction' });
|
||||
}
|
||||
});
|
||||
|
||||
// Validate/invalidate a prediction (approve someone else's)
|
||||
router.post('/:id/validate', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const predictionId = req.params.id;
|
||||
const { status } = req.body; // 'validated' or 'invalidated'
|
||||
|
||||
if (!['validated', 'invalidated'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
|
||||
// Get the prediction
|
||||
const predictions = await query(
|
||||
'SELECT * FROM predictions WHERE id = ?',
|
||||
[predictionId]
|
||||
);
|
||||
|
||||
if (predictions.length === 0) {
|
||||
return res.status(404).json({ error: 'Prediction not found' });
|
||||
}
|
||||
|
||||
const prediction = predictions[0];
|
||||
|
||||
// Cannot validate own prediction
|
||||
if (prediction.user_id === req.user.userId) {
|
||||
return res.status(403).json({ error: 'Cannot validate your own prediction' });
|
||||
}
|
||||
|
||||
// Verify access to the challenge
|
||||
const access = await query(
|
||||
`SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN (
|
||||
SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted'
|
||||
))`,
|
||||
[prediction.challenge_id, req.user.userId, req.user.userId]
|
||||
);
|
||||
|
||||
if (access.length === 0) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Update prediction
|
||||
await query(
|
||||
'UPDATE predictions SET status = ?, validated_by = ?, validated_at = NOW() WHERE id = ?',
|
||||
[status, req.user.userId, predictionId]
|
||||
);
|
||||
|
||||
res.json({ status });
|
||||
} catch (error) {
|
||||
console.error('Validate prediction error:', error);
|
||||
res.status(500).json({ error: 'Failed to validate prediction' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
74
backend/src/routes/tmdb.js
Normal file
74
backend/src/routes/tmdb.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Search for shows/movies via TMDB with caching
|
||||
router.get('/search', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || q.trim().length < 2) {
|
||||
return res.json({ results: [] });
|
||||
}
|
||||
|
||||
const searchQuery = q.trim();
|
||||
|
||||
// Check cache first
|
||||
const cached = await query(
|
||||
'SELECT response_data FROM tmdb_cache WHERE query = ? AND media_type = ? AND cached_at > DATE_SUB(NOW(), INTERVAL 7 DAY)',
|
||||
[searchQuery, 'multi']
|
||||
);
|
||||
|
||||
if (cached.length > 0) {
|
||||
return res.json(JSON.parse(cached[0].response_data));
|
||||
}
|
||||
|
||||
// Fetch from TMDB
|
||||
const apiKey = process.env.TMDB_API_KEY;
|
||||
if (!apiKey) {
|
||||
return res.status(500).json({ error: 'TMDB API key not configured' });
|
||||
}
|
||||
|
||||
const fetch = (await import('node-fetch')).default;
|
||||
const response = await fetch(
|
||||
`https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&query=${encodeURIComponent(searchQuery)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('TMDB API error');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Filter to only movies and TV shows
|
||||
const filtered = data.results
|
||||
.filter(item => item.media_type === 'movie' || item.media_type === 'tv')
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
title: item.media_type === 'movie' ? item.title : item.name,
|
||||
media_type: item.media_type,
|
||||
poster_path: item.poster_path,
|
||||
backdrop_path: item.backdrop_path,
|
||||
release_date: item.release_date || item.first_air_date,
|
||||
overview: item.overview
|
||||
}))
|
||||
.slice(0, 10);
|
||||
|
||||
const result = { results: filtered };
|
||||
|
||||
// Cache the result
|
||||
await query(
|
||||
'INSERT INTO tmdb_cache (query, media_type, response_data) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE response_data = ?, cached_at = NOW()',
|
||||
[searchQuery, 'multi', JSON.stringify(result), JSON.stringify(result)]
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('TMDB search error:', error);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user