Compare commits
48 Commits
8b628822ec
...
stage
| Author | SHA1 | Date | |
|---|---|---|---|
| a0931df2f4 | |||
| 33026a6ecd | |||
| 352a23f008 | |||
| b7b32b4fe6 | |||
| a725e7a0d1 | |||
| c2a6e1d41f | |||
| be91e1a078 | |||
| a5b0d3352b | |||
| bb3a2e0b65 | |||
| 854b7b76a3 | |||
| f47ea8efaa | |||
| 75d6eb8bbc | |||
| 1070f9b3d2 | |||
| 94c9e28393 | |||
| ad6e686c74 | |||
| 0a9acd8442 | |||
| 88f7b0678b | |||
| fabf1d9de9 | |||
| 3aba9f28ff | |||
| a2d5454780 | |||
| 480ad66376 | |||
| 7c674b07c6 | |||
| dbd9f89fbd | |||
| 754badc60f | |||
| 8ad834d778 | |||
| 78785aee42 | |||
| ada42d08ce | |||
| b018664e83 | |||
| 790357c56a | |||
| d8e7de4107 | |||
| 300a1387da | |||
| 45c98f9fc7 | |||
| 9c0fd35804 | |||
| 7706234a7b | |||
| 432dc48c9a | |||
| b6527b97cd | |||
| c83b16de3f | |||
| 1081f921d4 | |||
| 172ac4a48d | |||
| 6f971d1b50 | |||
| 1ee9b686a2 | |||
| f262abff16 | |||
| b567fda0ae | |||
| efa1ea3b45 | |||
| 864cbaece9 | |||
| 3e3f37a570 | |||
| 31c37d9bdd | |||
| 73df0fc764 |
@@ -12,7 +12,7 @@ on:
|
||||
env:
|
||||
STACK_NAME: wtp-prod
|
||||
DOT_ENV: ${{ secrets.PROD_ENV }}
|
||||
PORTAINER_TOKEN: ${{ secrets.PORTAINER_TOKEN }}
|
||||
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
|
||||
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
|
||||
ENDPOINT_NAME: "mini" #sometimes "primary"
|
||||
IMAGE_TAG: "reg.dev.nervesocket.com/wtp-prod:release"
|
||||
@@ -33,8 +33,7 @@ jobs:
|
||||
- name: Build and push PROD Docker image
|
||||
run: |
|
||||
echo $DOT_ENV | base64 -d > .env
|
||||
echo .env
|
||||
docker buildx build --push -t $IMAGE_TAG .
|
||||
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
|
||||
|
||||
- name: Get the endpoint ID
|
||||
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
|
||||
@@ -50,14 +49,28 @@ jobs:
|
||||
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
|
||||
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
|
||||
|
||||
- name: Fetch Stack
|
||||
run: |
|
||||
# Get the stack details (including env vars)
|
||||
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
|
||||
|
||||
# Extract environment variables from the stack
|
||||
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
|
||||
|
||||
echo "Existing stack environment variables:"
|
||||
cat stack_env.json
|
||||
|
||||
- name: Redeploy stack in Portainer
|
||||
run: |
|
||||
# Read stack file content
|
||||
STACK_FILE_CONTENT=$(echo "$(<prod-compose.yml )")
|
||||
|
||||
# Prepare JSON payload
|
||||
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true \
|
||||
'{stackFileContent: $stackFileContent, pullImage: $pullImage}')
|
||||
# Read existing environment variables from the fetched stack
|
||||
ENV_VARS=$(cat stack_env.json)
|
||||
|
||||
# Prepare JSON payload with environment variables
|
||||
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
|
||||
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
|
||||
|
||||
echo "About to push the following JSON payload:"
|
||||
echo $JSON_PAYLOAD
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"node-fetch": "^3.3.2"
|
||||
"node-fetch": "^3.3.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"socket.io": "^4.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
60
backend/src/config.js
Normal file
60
backend/src/config.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Environment configuration and validation
|
||||
*/
|
||||
|
||||
const requiredEnvVars = [
|
||||
'JWT_SECRET',
|
||||
'DB_HOST',
|
||||
'DB_USER',
|
||||
'DB_PASSWORD',
|
||||
'DB_NAME',
|
||||
'TMDB_API_KEY'
|
||||
];
|
||||
|
||||
export function validateConfig() {
|
||||
const missing = [];
|
||||
|
||||
for (const varName of requiredEnvVars) {
|
||||
if (!process.env[varName]) {
|
||||
missing.push(varName);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('❌ Missing required environment variables:');
|
||||
missing.forEach(varName => {
|
||||
console.error(` - ${varName}`);
|
||||
});
|
||||
console.error('\nPlease set these in your .env file or environment.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate JWT_SECRET is strong enough
|
||||
if (process.env.JWT_SECRET.length < 32) {
|
||||
console.error('❌ JWT_SECRET must be at least 32 characters long for security.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Environment configuration validated');
|
||||
}
|
||||
|
||||
export const config = {
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET,
|
||||
expiresIn: '7d'
|
||||
},
|
||||
db: {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10)
|
||||
},
|
||||
tmdb: {
|
||||
apiKey: process.env.TMDB_API_KEY,
|
||||
baseUrl: 'https://api.themoviedb.org/3'
|
||||
},
|
||||
server: {
|
||||
port: parseInt(process.env.PORT || '3000', 10)
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_username (username)
|
||||
@@ -76,3 +77,17 @@ CREATE TABLE IF NOT EXISTS tmdb_cache (
|
||||
UNIQUE KEY unique_query (query, media_type),
|
||||
INDEX idx_query (query)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token VARCHAR(255) UNIQUE NOT NULL,
|
||||
created_by INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_user_id (user_id)
|
||||
);
|
||||
|
||||
@@ -4,32 +4,71 @@ import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createServer } from 'http';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { initDB } from './db/index.js';
|
||||
import { validateConfig, config } from './config.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { initializeSocket } from './sockets/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';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Validate environment configuration
|
||||
validateConfig();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
|
||||
// Initialize Socket.io
|
||||
initializeSocket(httpServer);
|
||||
|
||||
// Rate limiting
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // 5 requests per window
|
||||
message: { error: 'Too many authentication attempts, please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
max: 100, // 100 requests per window
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const tmdbLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 20, // 20 requests per minute
|
||||
message: { error: 'Too many search requests, please slow down.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 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);
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/challenges', apiLimiter, challengeRoutes);
|
||||
app.use('/api/predictions', apiLimiter, predictionRoutes);
|
||||
app.use('/api/friends', apiLimiter, friendRoutes);
|
||||
app.use('/api/tmdb', tmdbLimiter, tmdbRoutes);
|
||||
app.use('/api/leaderboard', apiLimiter, leaderboardRoutes);
|
||||
app.use('/api/admin', apiLimiter, adminRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
@@ -37,7 +76,7 @@ app.get('/api/health', (req, res) => {
|
||||
});
|
||||
|
||||
// Serve static frontend files (for production)
|
||||
const frontendPath = path.join(__dirname, '../../frontend/dist');
|
||||
const frontendPath = path.join(__dirname, '../frontend/dist');
|
||||
const frontendExists = fs.existsSync(frontendPath);
|
||||
|
||||
if (frontendExists) {
|
||||
@@ -57,13 +96,17 @@ if (frontendExists) {
|
||||
});
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
const PORT = config.server.port;
|
||||
|
||||
// Initialize database and start server
|
||||
initDB()
|
||||
.then(() => {
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✅ Server running on port ${PORT}`);
|
||||
console.log(`🔌 Socket.io ready for real-time updates`);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { query } from '../db/index.js';
|
||||
import { asyncHandler } from './errorHandler.js';
|
||||
|
||||
export const authMiddleware = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -17,3 +19,35 @@ export const authMiddleware = (req, res, next) => {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced version that fetches user data including admin status
|
||||
export const verifyToken = asyncHandler(async (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);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Fetch full user data including admin status
|
||||
const users = await query(
|
||||
'SELECT id, email, username, is_admin FROM users WHERE id = ?',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
req.user = {
|
||||
userId: users[0].id,
|
||||
email: users[0].email,
|
||||
username: users[0].username,
|
||||
is_admin: users[0].is_admin
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
58
backend/src/middleware/errorHandler.js
Normal file
58
backend/src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Centralized error handling middleware
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(message, statusCode = 500) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.isOperational = true;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(err, req, res, next) {
|
||||
let { statusCode = 500, message } = err;
|
||||
|
||||
// Log error for debugging
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Error:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
statusCode,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
} else {
|
||||
console.error('Error:', err.message);
|
||||
}
|
||||
|
||||
// Handle specific error types
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
statusCode = 409;
|
||||
message = 'A record with this information already exists';
|
||||
}
|
||||
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
statusCode = 401;
|
||||
message = 'Invalid token';
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
statusCode = 401;
|
||||
message = 'Token expired';
|
||||
}
|
||||
|
||||
// Send error response
|
||||
res.status(statusCode).json({
|
||||
error: message,
|
||||
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
|
||||
});
|
||||
}
|
||||
|
||||
// Async handler wrapper to catch errors in async routes
|
||||
export function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
88
backend/src/routes/admin.js
Normal file
88
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { query } from '../db/index.js';
|
||||
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
|
||||
import { verifyToken } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Middleware to check if user is admin
|
||||
const requireAdmin = asyncHandler(async (req, res, next) => {
|
||||
if (!req.user || !req.user.is_admin) {
|
||||
throw new AppError('Admin access required', 403);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Get all users (admin only)
|
||||
router.get('/users', verifyToken, requireAdmin, asyncHandler(async (req, res) => {
|
||||
const users = await query(
|
||||
'SELECT id, email, username, is_admin, created_at FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
res.json({ users });
|
||||
}));
|
||||
|
||||
// Generate password reset token (admin only)
|
||||
router.post('/generate-reset-token', verifyToken, requireAdmin, asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
throw new AppError('User ID required', 400);
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const users = await query('SELECT id, username, email FROM users WHERE id = ?', [userId]);
|
||||
if (users.length === 0) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// Generate secure token
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Token expires in 1 hour
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// Store token
|
||||
await query(
|
||||
'INSERT INTO password_reset_tokens (user_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[userId, token, req.user.userId, expiresAt]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
},
|
||||
expiresAt
|
||||
});
|
||||
}));
|
||||
|
||||
// Get reset token history (admin only)
|
||||
router.get('/reset-tokens', verifyToken, requireAdmin, asyncHandler(async (req, res) => {
|
||||
const tokens = await query(`
|
||||
SELECT
|
||||
prt.id,
|
||||
prt.token,
|
||||
prt.created_at,
|
||||
prt.expires_at,
|
||||
prt.used_at,
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
u.email,
|
||||
admin.username as created_by_username
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON prt.user_id = u.id
|
||||
JOIN users admin ON prt.created_by = admin.id
|
||||
ORDER BY prt.created_at DESC
|
||||
LIMIT 100
|
||||
`);
|
||||
|
||||
res.json({ tokens });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -2,121 +2,160 @@ import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { query } from '../db/index.js';
|
||||
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Register
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, username, password } = req.body;
|
||||
router.post('/register', asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
if (!email || !username || !password) {
|
||||
throw new AppError('All fields required', 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user exists
|
||||
const existing = await query(
|
||||
'SELECT id FROM users WHERE email = ? OR username = ?',
|
||||
[email, username]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new AppError('User already exists', 400);
|
||||
}
|
||||
|
||||
// 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 } });
|
||||
}));
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
router.post('/login', asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
if (!email || !password) {
|
||||
throw new AppError('Email and password required', 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Find user
|
||||
const users = await query(
|
||||
'SELECT id, email, username, password_hash FROM users WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new AppError('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// Check password
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) {
|
||||
throw new AppError('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
// 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 }
|
||||
});
|
||||
}));
|
||||
|
||||
// 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' });
|
||||
router.get('/me', asyncHandler(async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError('No token provided', 401);
|
||||
}
|
||||
});
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
const users = await query(
|
||||
'SELECT id, email, username, is_admin, created_at FROM users WHERE id = ?',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
|
||||
res.json({ user: users[0] });
|
||||
}));
|
||||
|
||||
// Reset password with token (public route)
|
||||
router.post('/reset-password', asyncHandler(async (req, res) => {
|
||||
const { token, newPassword } = req.body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
throw new AppError('Token and new password required', 400);
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new AppError('Password must be at least 6 characters', 400);
|
||||
}
|
||||
|
||||
// Find valid token
|
||||
const tokens = await query(
|
||||
`SELECT prt.*, u.email, u.username
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON prt.user_id = u.id
|
||||
WHERE prt.token = ? AND prt.used_at IS NULL AND prt.expires_at > NOW()`,
|
||||
[token]
|
||||
);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
throw new AppError('Invalid or expired reset token', 400);
|
||||
}
|
||||
|
||||
const resetToken = tokens[0];
|
||||
|
||||
// Hash new password
|
||||
const password_hash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await query(
|
||||
'UPDATE users SET password_hash = ? WHERE id = ?',
|
||||
[password_hash, resetToken.user_id]
|
||||
);
|
||||
|
||||
// Mark token as used
|
||||
await query(
|
||||
'UPDATE password_reset_tokens SET used_at = NOW() WHERE id = ?',
|
||||
[resetToken.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password reset successfully',
|
||||
user: {
|
||||
email: resetToken.email,
|
||||
username: resetToken.username
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,63 +1,59 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
|
||||
import { socketEvents } from '../sockets/index.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]
|
||||
);
|
||||
router.get('/', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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 IS NOT NULL
|
||||
ORDER BY c.created_at DESC`,
|
||||
[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' });
|
||||
}
|
||||
});
|
||||
res.json({ challenges });
|
||||
}));
|
||||
|
||||
// Get a single challenge with details
|
||||
router.get('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const challengeId = req.params.id;
|
||||
router.get('/:id', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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]
|
||||
);
|
||||
// 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' });
|
||||
}
|
||||
if (challenges.length === 0) {
|
||||
throw new AppError('Challenge not found', 404);
|
||||
}
|
||||
|
||||
const challenge = challenges[0];
|
||||
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]
|
||||
);
|
||||
// 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' });
|
||||
}
|
||||
if (challenge.created_by !== req.user.userId && access.length === 0) {
|
||||
throw new AppError('Access denied', 403);
|
||||
}
|
||||
|
||||
// Get participants with their points
|
||||
const participants = await query(
|
||||
@@ -79,25 +75,20 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
||||
[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' });
|
||||
}
|
||||
});
|
||||
res.json({
|
||||
challenge,
|
||||
participants,
|
||||
creator_points: creatorPoints[0].points
|
||||
});
|
||||
}));
|
||||
|
||||
// Create a new challenge
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { title, cover_image_url, tmdb_id, media_type } = req.body;
|
||||
router.post('/', authMiddleware, asyncHandler(async (req, res) => {
|
||||
const { title, cover_image_url, tmdb_id, media_type } = req.body;
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
if (!title) {
|
||||
throw new AppError('Title is required', 400);
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'INSERT INTO challenges (title, cover_image_url, tmdb_id, media_type, created_by) VALUES (?, ?, ?, ?, ?)',
|
||||
@@ -114,42 +105,37 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
res.json({ 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;
|
||||
router.post('/:id/invite', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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]
|
||||
// Verify user owns the challenge or is a participant
|
||||
const challenges = await query(
|
||||
'SELECT * FROM challenges WHERE id = ?',
|
||||
[challengeId]
|
||||
);
|
||||
|
||||
if (challenges.length === 0) {
|
||||
throw new AppError('Challenge not found', 404);
|
||||
}
|
||||
|
||||
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 (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' });
|
||||
}
|
||||
if (participation.length === 0) {
|
||||
throw new AppError('Only challenge participants can invite others', 403);
|
||||
}
|
||||
}
|
||||
|
||||
const invitedUsers = [];
|
||||
|
||||
@@ -162,57 +148,134 @@ router.post('/:id/invite', authMiddleware, async (req, res) => {
|
||||
[challengeId, userId]
|
||||
);
|
||||
invitedUsers.push(userId);
|
||||
|
||||
// Emit real-time notification to invited user
|
||||
socketEvents.challengeInvitation(userId, {
|
||||
challenge_id: challengeId,
|
||||
challenge_title: challenge.title,
|
||||
invited_by: req.user.username
|
||||
});
|
||||
} 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);
|
||||
|
||||
// Emit real-time notification to invited user
|
||||
socketEvents.challengeInvitation(users[0].id, {
|
||||
challenge_id: challengeId,
|
||||
challenge_title: challenge.title,
|
||||
invited_by: req.user.username
|
||||
});
|
||||
} 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' });
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ invited: invitedUsers.length });
|
||||
}));
|
||||
|
||||
// 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'
|
||||
router.post('/:id/respond', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
if (!['accepted', 'rejected'].includes(status)) {
|
||||
throw new AppError('Invalid status', 400);
|
||||
}
|
||||
});
|
||||
|
||||
await query(
|
||||
'UPDATE challenge_participants SET status = ?, responded_at = NOW() WHERE challenge_id = ? AND user_id = ?',
|
||||
[status, challengeId, req.user.userId]
|
||||
);
|
||||
|
||||
// Emit real-time event to challenge participants
|
||||
socketEvents.challengeInvitationResponse(challengeId, {
|
||||
user_id: req.user.userId,
|
||||
username: req.user.username,
|
||||
status
|
||||
});
|
||||
|
||||
res.json({ status });
|
||||
}));
|
||||
|
||||
// Leave a challenge (participants only, not creator)
|
||||
router.post('/:id/leave', authMiddleware, asyncHandler(async (req, res) => {
|
||||
const challengeId = req.params.id;
|
||||
|
||||
// Get challenge to verify it exists and user is not creator
|
||||
const challenges = await query(
|
||||
'SELECT * FROM challenges WHERE id = ?',
|
||||
[challengeId]
|
||||
);
|
||||
|
||||
if (challenges.length === 0) {
|
||||
throw new AppError('Challenge not found', 404);
|
||||
}
|
||||
|
||||
const challenge = challenges[0];
|
||||
|
||||
if (challenge.created_by === req.user.userId) {
|
||||
throw new AppError('Challenge creator cannot leave. Delete the challenge instead.', 403);
|
||||
}
|
||||
|
||||
// Check if user is a participant
|
||||
const participation = await query(
|
||||
'SELECT * FROM challenge_participants WHERE challenge_id = ? AND user_id = ?',
|
||||
[challengeId, req.user.userId]
|
||||
);
|
||||
|
||||
if (participation.length === 0) {
|
||||
throw new AppError('You are not a participant of this challenge', 404);
|
||||
}
|
||||
|
||||
// Delete user's participation and predictions
|
||||
await query('DELETE FROM predictions WHERE challenge_id = ? AND user_id = ?', [challengeId, req.user.userId]);
|
||||
await query('DELETE FROM challenge_participants WHERE challenge_id = ? AND user_id = ?', [challengeId, req.user.userId]);
|
||||
|
||||
res.json({ success: true, message: 'Left challenge successfully' });
|
||||
}));
|
||||
|
||||
// Delete a challenge (only creator can delete)
|
||||
router.delete('/:id', authMiddleware, asyncHandler(async (req, res) => {
|
||||
const challengeId = req.params.id;
|
||||
|
||||
// Get challenge and verify ownership
|
||||
const challenges = await query(
|
||||
'SELECT * FROM challenges WHERE id = ?',
|
||||
[challengeId]
|
||||
);
|
||||
|
||||
if (challenges.length === 0) {
|
||||
throw new AppError('Challenge not found', 404);
|
||||
}
|
||||
|
||||
const challenge = challenges[0];
|
||||
|
||||
if (challenge.created_by !== req.user.userId) {
|
||||
throw new AppError('Only the creator can delete this challenge', 403);
|
||||
}
|
||||
|
||||
// Delete related data (cascade should handle this, but being explicit)
|
||||
await query('DELETE FROM predictions WHERE challenge_id = ?', [challengeId]);
|
||||
await query('DELETE FROM challenge_participants WHERE challenge_id = ?', [challengeId]);
|
||||
await query('DELETE FROM challenges WHERE id = ?', [challengeId]);
|
||||
|
||||
res.json({ success: true, message: 'Challenge deleted successfully' });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,169 +1,216 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
|
||||
import { socketEvents } from '../sockets/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Search for users by username or email
|
||||
router.get('/search', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
router.get('/search', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
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 != ?
|
||||
AND id NOT IN (
|
||||
SELECT friend_id FROM friendships WHERE user_id = ? AND status IN ('accepted', 'pending')
|
||||
UNION
|
||||
SELECT user_id FROM friendships WHERE friend_id = ? AND status IN ('accepted', 'pending')
|
||||
)
|
||||
LIMIT 20`,
|
||||
[searchTerm, searchTerm, req.user.userId, req.user.userId, req.user.userId]
|
||||
);
|
||||
|
||||
res.json({ users });
|
||||
}));
|
||||
|
||||
// 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]
|
||||
);
|
||||
router.get('/', authMiddleware, asyncHandler(async (req, res) => {
|
||||
// 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)]
|
||||
);
|
||||
// Also get people who have shared challenges with me (auto-friends from challenges)
|
||||
// Build the NOT IN clause only if there are friends to exclude
|
||||
const notInClause = friends.length > 0
|
||||
? `AND u.id NOT IN (${friends.map(() => '?').join(',')})`
|
||||
: '';
|
||||
|
||||
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,
|
||||
(SELECT status FROM friendships WHERE (user_id = ? AND friend_id = u.id) OR (user_id = u.id AND friend_id = ?) LIMIT 1) as friendship_status
|
||||
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'
|
||||
UNION
|
||||
SELECT DISTINCT cp.user_id
|
||||
FROM challenge_participants cp
|
||||
INNER JOIN challenges c ON cp.challenge_id = c.id
|
||||
WHERE c.created_by = ? AND cp.user_id != ? AND cp.status = 'accepted'
|
||||
)
|
||||
${notInClause}
|
||||
ORDER BY u.username`,
|
||||
[req.user.userId, req.user.userId, req.user.userId, req.user.userId, 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' });
|
||||
}
|
||||
});
|
||||
res.json({
|
||||
friends,
|
||||
challenge_friends: challengeFriends
|
||||
});
|
||||
}));
|
||||
|
||||
// Send friend request
|
||||
router.post('/request', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.body;
|
||||
router.post('/request', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
if (!user_id) {
|
||||
throw new AppError('User ID required', 400);
|
||||
}
|
||||
});
|
||||
|
||||
if (user_id === req.user.userId) {
|
||||
throw new AppError('Cannot add yourself as friend', 400);
|
||||
}
|
||||
|
||||
// Check if already friends or pending request exists
|
||||
const existing = await query(
|
||||
`SELECT * FROM friendships
|
||||
WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)
|
||||
AND status IN ('accepted', 'pending')`,
|
||||
[req.user.userId, user_id, user_id, req.user.userId]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new AppError('Friend request already exists or you are already friends', 400);
|
||||
}
|
||||
|
||||
// Delete any old rejected requests before creating a new one
|
||||
await query(
|
||||
`DELETE FROM friendships
|
||||
WHERE ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?))
|
||||
AND status = 'rejected'`,
|
||||
[req.user.userId, user_id, user_id, req.user.userId]
|
||||
);
|
||||
|
||||
await query(
|
||||
'INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, "pending")',
|
||||
[req.user.userId, user_id]
|
||||
);
|
||||
|
||||
// Emit real-time notification to the user receiving the friend request
|
||||
socketEvents.friendRequest(user_id, {
|
||||
from_user_id: req.user.userId,
|
||||
from_username: req.user.username
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
// Accept/reject friend request
|
||||
router.post('/respond', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { friendship_id, status } = req.body;
|
||||
router.post('/respond', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
if (!['accepted', 'rejected'].includes(status)) {
|
||||
throw new AppError('Invalid status', 400);
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
throw new AppError('Friend request not found', 404);
|
||||
}
|
||||
|
||||
await query(
|
||||
'UPDATE friendships SET status = ? WHERE id = ?',
|
||||
[status, friendship_id]
|
||||
);
|
||||
|
||||
// Get the friendship to notify the requester
|
||||
const friendship = friendships[0];
|
||||
|
||||
// Emit real-time notification to the user who sent the request
|
||||
socketEvents.friendRequestResponse(friendship.user_id, {
|
||||
friend_id: req.user.userId,
|
||||
friend_username: req.user.username,
|
||||
status
|
||||
});
|
||||
|
||||
res.json({ status });
|
||||
}));
|
||||
|
||||
// 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]
|
||||
);
|
||||
router.get('/requests', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
res.json({ requests });
|
||||
}));
|
||||
|
||||
// Remove a friend
|
||||
router.delete('/:friendId', authMiddleware, asyncHandler(async (req, res) => {
|
||||
const friendId = parseInt(req.params.friendId);
|
||||
|
||||
if (!friendId) {
|
||||
throw new AppError('Friend ID required', 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete friendship in both directions
|
||||
const result = await query(
|
||||
`DELETE FROM friendships
|
||||
WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)`,
|
||||
[req.user.userId, friendId, friendId, req.user.userId]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
throw new AppError('Friendship not found', 404);
|
||||
}
|
||||
|
||||
// Emit real-time notification to the removed friend
|
||||
socketEvents.friendRemoved(friendId, {
|
||||
removed_by_user_id: req.user.userId,
|
||||
removed_by_username: req.user.username
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Friend removed successfully' });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get leaderboard for a specific challenge
|
||||
router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.params;
|
||||
router.get('/challenge/:challengeId', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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]
|
||||
);
|
||||
// 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' });
|
||||
}
|
||||
if (access.length === 0) {
|
||||
throw new AppError('Access denied', 403);
|
||||
}
|
||||
|
||||
// Get leaderboard
|
||||
const leaderboard = await query(
|
||||
@@ -53,72 +53,58 @@ router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
|
||||
[challengeId, challengeId, challengeId, challengeId]
|
||||
);
|
||||
|
||||
res.json({ leaderboard });
|
||||
} catch (error) {
|
||||
console.error('Challenge leaderboard error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch leaderboard' });
|
||||
}
|
||||
});
|
||||
res.json({ 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`
|
||||
);
|
||||
router.get('/global', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
res.json({ leaderboard });
|
||||
}));
|
||||
|
||||
// Get user profile stats
|
||||
router.get('/profile/:userId?', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.userId || req.user.userId;
|
||||
router.get('/profile/:userId?', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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]
|
||||
);
|
||||
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' });
|
||||
if (stats.length === 0) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ profile: stats[0] });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,140 +1,143 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
|
||||
import { socketEvents } from '../sockets/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all predictions for a challenge
|
||||
router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.params;
|
||||
router.get('/challenge/:challengeId', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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]
|
||||
);
|
||||
// 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' });
|
||||
if (access.length === 0) {
|
||||
throw new AppError('Access denied', 403);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}));
|
||||
|
||||
// Create a new prediction
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { challenge_id, content } = req.body;
|
||||
router.post('/', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
if (!challenge_id || !content || !content.trim()) {
|
||||
throw new AppError('Challenge ID and content are required', 400);
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
throw new AppError('Access denied', 403);
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
// Emit real-time event to all users in the challenge
|
||||
socketEvents.predictionCreated(challenge_id, prediction);
|
||||
|
||||
res.json({ 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'
|
||||
router.post('/:id/validate', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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' });
|
||||
if (!['validated', 'invalidated'].includes(status)) {
|
||||
throw new AppError('Invalid status', 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Get the prediction
|
||||
const predictions = await query(
|
||||
'SELECT * FROM predictions WHERE id = ?',
|
||||
[predictionId]
|
||||
);
|
||||
|
||||
if (predictions.length === 0) {
|
||||
throw new AppError('Prediction not found', 404);
|
||||
}
|
||||
|
||||
const prediction = predictions[0];
|
||||
|
||||
// Cannot validate own prediction
|
||||
if (prediction.user_id === req.user.userId) {
|
||||
throw new AppError('Cannot validate your own prediction', 403);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
throw new AppError('Access denied', 403);
|
||||
}
|
||||
|
||||
// Update prediction
|
||||
await query(
|
||||
'UPDATE predictions SET status = ?, validated_by = ?, validated_at = NOW() WHERE id = ?',
|
||||
[status, req.user.userId, predictionId]
|
||||
);
|
||||
|
||||
// Get updated prediction with usernames
|
||||
const updatedPrediction = 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.id = ?`,
|
||||
[predictionId]
|
||||
);
|
||||
|
||||
// Emit real-time event to all users in the challenge
|
||||
socketEvents.predictionValidated(prediction.challenge_id, updatedPrediction[0]);
|
||||
|
||||
res.json({ status });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,79 +1,72 @@
|
||||
import express from 'express';
|
||||
import { query } from '../db/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { asyncHandler, AppError } from '../middleware/errorHandler.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;
|
||||
router.get('/search', authMiddleware, asyncHandler(async (req, res) => {
|
||||
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) {
|
||||
const errorText = await response.text();
|
||||
console.error('TMDB API error:', response.status, errorText);
|
||||
return res.status(500).json({
|
||||
error: `TMDB API error: ${response.status}`,
|
||||
details: errorText
|
||||
});
|
||||
}
|
||||
|
||||
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' });
|
||||
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) {
|
||||
throw new AppError('TMDB API key not configured', 500);
|
||||
}
|
||||
|
||||
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) {
|
||||
const errorText = await response.text();
|
||||
console.error('TMDB API error:', response.status, errorText);
|
||||
throw new AppError(`TMDB API error: ${response.status}`, 500);
|
||||
}
|
||||
|
||||
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);
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
123
backend/src/sockets/index.js
Normal file
123
backend/src/sockets/index.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Socket.io setup and event handlers
|
||||
*/
|
||||
import { Server } from 'socket.io';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
let io;
|
||||
|
||||
export function initializeSocket(server) {
|
||||
io = new Server(server, {
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
// Authentication middleware for socket connections
|
||||
io.use((socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth.token;
|
||||
|
||||
if (!token) {
|
||||
return next(new Error('Authentication error: No token provided'));
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
socket.userId = decoded.userId;
|
||||
socket.username = decoded.username;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(new Error('Authentication error: Invalid token'));
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`✅ Socket connected: ${socket.username} (${socket.userId})`);
|
||||
|
||||
// Join personal room for user-specific notifications
|
||||
socket.join(`user:${socket.userId}`);
|
||||
console.log(`📍 ${socket.username} joined personal room: user:${socket.userId}`);
|
||||
|
||||
// Handle joining challenge rooms
|
||||
socket.on('join:challenge', (challengeId) => {
|
||||
socket.join(`challenge:${challengeId}`);
|
||||
console.log(`👥 ${socket.username} joined challenge:${challengeId}`);
|
||||
|
||||
// Debug: Show all rooms this socket is in
|
||||
console.log(` Current rooms:`, Array.from(socket.rooms));
|
||||
});
|
||||
|
||||
// Handle leaving challenge rooms
|
||||
socket.on('leave:challenge', (challengeId) => {
|
||||
socket.leave(`challenge:${challengeId}`);
|
||||
console.log(`👋 ${socket.username} left challenge:${challengeId}`);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`❌ Socket disconnected: ${socket.username}`);
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
||||
export function getIO() {
|
||||
if (!io) {
|
||||
throw new Error('Socket.io not initialized');
|
||||
}
|
||||
return io;
|
||||
}
|
||||
|
||||
// Event emitters for different actions
|
||||
export const socketEvents = {
|
||||
// Emit to specific user
|
||||
emitToUser(userId, event, data) {
|
||||
console.log(`🔔 Emitting ${event} to user:${userId}`);
|
||||
io.to(`user:${userId}`).emit(event, data);
|
||||
},
|
||||
|
||||
// Emit to all users in a challenge
|
||||
emitToChallenge(challengeId, event, data) {
|
||||
console.log(`🔔 Emitting ${event} to challenge:${challengeId}`);
|
||||
io.to(`challenge:${challengeId}`).emit(event, data);
|
||||
},
|
||||
|
||||
// Prediction events
|
||||
predictionCreated(challengeId, prediction) {
|
||||
console.log(`📤 Emitting prediction:created for challenge ${challengeId}`, prediction);
|
||||
this.emitToChallenge(challengeId, 'prediction:created', prediction);
|
||||
},
|
||||
|
||||
predictionValidated(challengeId, prediction) {
|
||||
console.log(`📤 Emitting prediction:validated for challenge ${challengeId}`, prediction);
|
||||
this.emitToChallenge(challengeId, 'prediction:validated', prediction);
|
||||
},
|
||||
|
||||
// Challenge events
|
||||
challengeInvitation(userId, challenge) {
|
||||
this.emitToUser(userId, 'challenge:invitation', challenge);
|
||||
},
|
||||
|
||||
challengeInvitationResponse(challengeId, response) {
|
||||
this.emitToChallenge(challengeId, 'challenge:invitation_response', response);
|
||||
},
|
||||
|
||||
// Friend events
|
||||
friendRequest(userId, request) {
|
||||
this.emitToUser(userId, 'friend:request', request);
|
||||
},
|
||||
|
||||
friendRequestResponse(userId, response) {
|
||||
this.emitToUser(userId, 'friend:response', response);
|
||||
},
|
||||
|
||||
friendRemoved(userId, data) {
|
||||
this.emitToUser(userId, 'friend:removed', data);
|
||||
},
|
||||
|
||||
// Leaderboard updates
|
||||
leaderboardUpdate(challengeId, leaderboard) {
|
||||
this.emitToChallenge(challengeId, 'leaderboard:update', leaderboard);
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"socket.io-client": "^4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
||||
@@ -134,14 +134,56 @@ body {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-brand h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.hamburger span {
|
||||
width: 25px;
|
||||
height: 3px;
|
||||
background: var(--text);
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
@@ -150,12 +192,17 @@ body {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
background: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -207,19 +254,93 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav {
|
||||
.nav-brand {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hamburger.active span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(5px, 5px);
|
||||
}
|
||||
|
||||
.hamburger.active span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hamburger.active span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(7px, -7px);
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -100%;
|
||||
width: 70%;
|
||||
height: 100vh;
|
||||
background: var(--bg-light);
|
||||
border-left: 1px solid var(--border);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 5rem 2rem 2rem;
|
||||
gap: 2rem;
|
||||
transition: right 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav-menu.active {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 0.25rem;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.nav-links li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
font-size: 1.125rem;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: var(--bg-lighter);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
@@ -6,10 +6,12 @@ const AuthContext = createContext(null);
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const storedToken = localStorage.getItem('token');
|
||||
if (storedToken) {
|
||||
setToken(storedToken);
|
||||
api.getMe()
|
||||
.then(data => setUser(data.user))
|
||||
.catch(() => {
|
||||
@@ -25,12 +27,14 @@ export const AuthProvider = ({ children }) => {
|
||||
const login = async (email, password) => {
|
||||
const data = await api.login(email, password);
|
||||
setUser(data.user);
|
||||
setToken(data.token);
|
||||
return data;
|
||||
};
|
||||
|
||||
const register = async (email, username, password) => {
|
||||
const data = await api.register(email, username, password);
|
||||
setUser(data.user);
|
||||
setToken(data.token);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -38,10 +42,11 @@ export const AuthProvider = ({ children }) => {
|
||||
localStorage.removeItem('token');
|
||||
api.setToken(null);
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
|
||||
<AuthContext.Provider value={{ user, token, login, register, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
104
frontend/src/SocketContext.jsx
Normal file
104
frontend/src/SocketContext.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const SocketContext = createContext();
|
||||
|
||||
export function useSocket() {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useSocket must be used within SocketProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function SocketProvider({ children }) {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const { user, token } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Only connect if user is authenticated
|
||||
if (!user || !token) {
|
||||
console.log('⏳ Waiting for authentication...', { user: !!user, token: !!token });
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setSocket(null);
|
||||
setConnected(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 Initializing socket connection...');
|
||||
|
||||
// Determine backend URL (strip /api if present since socket.io is at root)
|
||||
let backendUrl = import.meta.env.VITE_API_URL ||
|
||||
(import.meta.env.DEV ? 'http://localhost:4000' : window.location.origin);
|
||||
|
||||
// Remove /api suffix if present
|
||||
backendUrl = backendUrl.replace(/\/api$/, '');
|
||||
|
||||
console.log('🌐 Connecting to:', backendUrl);
|
||||
|
||||
// Create socket connection with JWT authentication
|
||||
const newSocket = io(backendUrl, {
|
||||
auth: {
|
||||
token
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('🔌 Socket connected');
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
console.log('❌ Socket disconnected');
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
console.error('❌ Socket connection error:', error.message);
|
||||
console.error(' Error details:', error);
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
newSocket.disconnect();
|
||||
};
|
||||
}, [user, token]);
|
||||
|
||||
const joinChallenge = (challengeId) => {
|
||||
if (socket && connected) {
|
||||
console.log(`📍 Joining challenge room: ${challengeId}`);
|
||||
socket.emit('join:challenge', challengeId);
|
||||
} else {
|
||||
console.warn(`⚠️ Cannot join challenge ${challengeId} - socket:`, !!socket, 'connected:', connected);
|
||||
}
|
||||
};
|
||||
|
||||
const leaveChallenge = (challengeId) => {
|
||||
if (socket && connected) {
|
||||
console.log(`👋 Leaving challenge room: ${challengeId}`);
|
||||
socket.emit('leave:challenge', challengeId);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
socket,
|
||||
connected,
|
||||
joinChallenge,
|
||||
leaveChallenge
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +136,24 @@ class API {
|
||||
return this.request('/friends/requests');
|
||||
}
|
||||
|
||||
async removeFriend(friendId) {
|
||||
return this.request(`/friends/${friendId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChallenge(challengeId) {
|
||||
return this.request(`/challenges/${challengeId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async leaveChallenge(challengeId) {
|
||||
return this.request(`/challenges/${challengeId}/leave`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
// TMDB
|
||||
async searchShows(query) {
|
||||
return this.request(`/tmdb/search?q=${encodeURIComponent(query)}`);
|
||||
@@ -153,6 +171,29 @@ class API {
|
||||
async getProfile(userId) {
|
||||
return this.request(`/leaderboard/profile${userId ? `/${userId}` : ''}`);
|
||||
}
|
||||
|
||||
// Admin
|
||||
async getUsers() {
|
||||
return this.request('/admin/users');
|
||||
}
|
||||
|
||||
async generateResetToken(userId) {
|
||||
return this.request('/admin/generate-reset-token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId })
|
||||
});
|
||||
}
|
||||
|
||||
async getResetTokens() {
|
||||
return this.request('/admin/reset-tokens');
|
||||
}
|
||||
|
||||
async resetPassword(token, newPassword) {
|
||||
return this.request('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, newPassword })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new API();
|
||||
|
||||
62
frontend/src/components/ErrorBoundary.jsx
Normal file
62
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}>
|
||||
<div className="card" style={{ maxWidth: '500px', textAlign: 'center' }}>
|
||||
<h1 style={{ marginBottom: '1rem', color: 'var(--danger)' }}>Oops! Something went wrong</h1>
|
||||
<p style={{ marginBottom: '1.5rem', color: 'var(--text-muted)' }}>
|
||||
We're sorry, but something unexpected happened. Please refresh the page to try again.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.location.href = '/'}
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details style={{ marginTop: '2rem', textAlign: 'left' }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--text-muted)' }}>Error Details</summary>
|
||||
<pre style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
background: 'var(--bg)',
|
||||
borderRadius: '0.5rem',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
20
frontend/src/hooks/useClickOutside.js
Normal file
20
frontend/src/hooks/useClickOutside.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useClickOutside(ref, handler) {
|
||||
useEffect(() => {
|
||||
const listener = (event) => {
|
||||
if (!ref.current || ref.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
handler(event);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', listener);
|
||||
document.addEventListener('touchstart', listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', listener);
|
||||
document.removeEventListener('touchstart', listener);
|
||||
};
|
||||
}, [ref, handler]);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { SocketProvider } from './SocketContext';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import ChallengeList from './pages/ChallengeList';
|
||||
@@ -9,6 +11,9 @@ import ChallengeDetail from './pages/ChallengeDetail';
|
||||
import Profile from './pages/Profile';
|
||||
import Friends from './pages/Friends';
|
||||
import Leaderboard from './pages/Leaderboard';
|
||||
import Admin from './pages/Admin';
|
||||
import PasswordReset from './pages/PasswordReset';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './App.css';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
@@ -23,25 +28,54 @@ function ProtectedRoute({ children }) {
|
||||
|
||||
function Header() {
|
||||
const { user, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleLogout = () => {
|
||||
setMobileMenuOpen(false);
|
||||
logout();
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => setMobileMenuOpen(false);
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<nav className="nav">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<h2 style={{ margin: 0 }}>WTP</h2>
|
||||
<ul className="nav-links">
|
||||
<li><Link to="/challenges">Challenges</Link></li>
|
||||
<li><Link to="/leaderboard">Leaderboard</Link></li>
|
||||
<li><Link to="/friends">Friends</Link></li>
|
||||
<li><Link to="/profile">Profile</Link></li>
|
||||
</ul>
|
||||
<div className="nav-brand">
|
||||
<Link to="/challenges" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<h2>WTP</h2>
|
||||
</Link>
|
||||
<button
|
||||
className={`hamburger ${mobileMenuOpen ? 'active' : ''}`}
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="nav-backdrop"
|
||||
onClick={closeMobileMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className={`nav-menu ${mobileMenuOpen ? 'active' : ''}`}>
|
||||
<ul className="nav-links">
|
||||
<li><Link to="/challenges" onClick={closeMobileMenu}>Challenges</Link></li>
|
||||
<li><Link to="/leaderboard" onClick={closeMobileMenu}>Leaderboard</Link></li>
|
||||
<li><Link to="/friends" onClick={closeMobileMenu}>Friends</Link></li>
|
||||
<li><Link to="/profile" onClick={closeMobileMenu}>Profile</Link></li>
|
||||
{!!user.is_admin && <li><Link to="/admin" onClick={closeMobileMenu}>Admin</Link></li>}
|
||||
</ul>
|
||||
<button onClick={handleLogout} className="btn btn-secondary btn-sm logout-btn">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={logout} className="btn btn-secondary btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -50,21 +84,50 @@ function Header() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
||||
<Route path="/challenges/:id" element={<ProtectedRoute><ChallengeDetail /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="/friends" element={<ProtectedRoute><Friends /></ProtectedRoute>} />
|
||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||
<Route path="/" element={<Navigate to="/challenges" />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#1e293b',
|
||||
color: '#f1f5f9',
|
||||
border: '1px solid #334155',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#f1f5f9',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#f1f5f9',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/reset-password/:token" element={<PasswordReset />} />
|
||||
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
||||
<Route path="/challenges/:id" element={<ProtectedRoute><ChallengeDetail /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="/friends" element={<ProtectedRoute><Friends /></ProtectedRoute>} />
|
||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
|
||||
<Route path="/" element={<Navigate to="/challenges" />} />
|
||||
</Routes>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
301
frontend/src/pages/Admin.jsx
Normal file
301
frontend/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import api from '../api';
|
||||
import '../App.css';
|
||||
|
||||
function Admin() {
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [resetTokens, setResetTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [generatedUrl, setGeneratedUrl] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) {
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [user, activeTab]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (activeTab === 'users') {
|
||||
const data = await api.getUsers();
|
||||
setUsers(data.users);
|
||||
} else {
|
||||
const data = await api.getResetTokens();
|
||||
setResetTokens(data.tokens);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateResetToken = async (userId) => {
|
||||
try {
|
||||
const data = await api.generateResetToken(userId);
|
||||
// Build URL using current domain (works in dev and prod)
|
||||
const resetUrl = `${window.location.origin}/reset-password/${data.token}`;
|
||||
setGeneratedUrl({
|
||||
url: resetUrl,
|
||||
user: data.user,
|
||||
expiresAt: data.expiresAt
|
||||
});
|
||||
// Refresh the list if on tokens tab
|
||||
if (activeTab === 'tokens') {
|
||||
loadData();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
alert('Copied to clipboard!');
|
||||
};
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<h1>Access Denied</h1>
|
||||
<p>Admin privileges required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', borderBottom: '2px solid #333' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: activeTab === 'users' ? '#007bff' : 'transparent',
|
||||
color: activeTab === 'users' ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'users' ? '3px solid #007bff' : 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: activeTab === 'users' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('tokens')}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: activeTab === 'tokens' ? '#007bff' : 'transparent',
|
||||
color: activeTab === 'tokens' ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'tokens' ? '3px solid #007bff' : 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: activeTab === 'tokens' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
Reset Tokens
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generatedUrl && (
|
||||
<div style={{
|
||||
padding: '1.5rem',
|
||||
background: '#1a3a1a',
|
||||
border: '2px solid #28a745',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<h3 style={{ color: '#28a745', marginTop: 0 }}>✓ Reset Link Generated</h3>
|
||||
<p><strong>User:</strong> {generatedUrl.user.username} ({generatedUrl.user.email})</p>
|
||||
<p><strong>Expires:</strong> {new Date(generatedUrl.expiresAt).toLocaleString()}</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginTop: '1rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={generatedUrl.url}
|
||||
readOnly
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
background: '#222',
|
||||
color: '#fff',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyToClipboard(generatedUrl.url)}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setGeneratedUrl(null)}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: '#3a1a1a',
|
||||
border: '2px solid #dc3545',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1rem',
|
||||
color: '#dc3545'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : activeTab === 'users' ? (
|
||||
<div>
|
||||
<h2>Users ({users.length})</h2>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #444' }}>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>ID</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Username</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Email</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Admin</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Created</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} style={{ borderBottom: '1px solid #333' }}>
|
||||
<td style={{ padding: '1rem' }}>{u.id}</td>
|
||||
<td style={{ padding: '1rem' }}>{u.username}</td>
|
||||
<td style={{ padding: '1rem' }}>{u.email}</td>
|
||||
<td style={{ padding: '1rem' }}>{u.is_admin ? '✓' : ''}</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<button
|
||||
onClick={() => handleGenerateResetToken(u.id)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Generate Reset Link
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2>Password Reset Tokens</h2>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #444' }}>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>User</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Created By</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Created At</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Expires At</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resetTokens.map(token => {
|
||||
const now = new Date();
|
||||
const expired = new Date(token.expires_at) < now;
|
||||
const used = !!token.used_at;
|
||||
let status = 'Active';
|
||||
let statusColor = '#28a745';
|
||||
|
||||
if (used) {
|
||||
status = 'Used';
|
||||
statusColor = '#6c757d';
|
||||
} else if (expired) {
|
||||
status = 'Expired';
|
||||
statusColor = '#dc3545';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={token.id} style={{ borderBottom: '1px solid #333' }}>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{token.username}
|
||||
<br />
|
||||
<small style={{ color: '#999' }}>{token.email}</small>
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>{token.created_by_username}</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{new Date(token.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
{new Date(token.expires_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: '1rem' }}>
|
||||
<span style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: statusColor,
|
||||
borderRadius: '12px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Admin;
|
||||
@@ -1,11 +1,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { useSocket } from '../SocketContext';
|
||||
import api from '../api';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
|
||||
export default function ChallengeDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { socket, joinChallenge, leaveChallenge } = useSocket();
|
||||
const [challenge, setChallenge] = useState(null);
|
||||
const [predictions, setPredictions] = useState([]);
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
@@ -15,14 +20,17 @@ export default function ChallengeDetail() {
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTimeout, setSearchTimeout] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [validating, setValidating] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [inviting, setInviting] = useState(null);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenge();
|
||||
loadPredictions();
|
||||
loadLeaderboard();
|
||||
}, [id]);
|
||||
useClickOutside(searchRef, () => setSearchResults([]));
|
||||
|
||||
const loadChallenge = async () => {
|
||||
const loadChallenge = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getChallenge(id);
|
||||
setChallenge(data);
|
||||
@@ -31,49 +39,143 @@ export default function ChallengeDetail() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const loadPredictions = async () => {
|
||||
const loadPredictions = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getPredictions(id);
|
||||
setPredictions(data.predictions);
|
||||
} catch (err) {
|
||||
console.error('Failed to load predictions:', err);
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const loadLeaderboard = async () => {
|
||||
const loadLeaderboard = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getChallengeLeaderboard(id);
|
||||
setLeaderboard(data.leaderboard);
|
||||
} catch (err) {
|
||||
console.error('Failed to load leaderboard:', err);
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const loadFriends = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getFriends();
|
||||
// Combine regular friends and challenge friends
|
||||
const allFriends = [...(data.friends || []), ...(data.challenge_friends || [])];
|
||||
setFriends(allFriends);
|
||||
} catch (err) {
|
||||
console.error('Failed to load friends:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenge();
|
||||
loadPredictions();
|
||||
loadLeaderboard();
|
||||
loadFriends();
|
||||
}, [loadChallenge, loadPredictions, loadLeaderboard, loadFriends]);
|
||||
|
||||
// Join challenge room for real-time updates
|
||||
useEffect(() => {
|
||||
if (socket && id) {
|
||||
joinChallenge(id);
|
||||
|
||||
return () => {
|
||||
leaveChallenge(id);
|
||||
};
|
||||
}
|
||||
}, [socket, id]);
|
||||
|
||||
// Listen for real-time prediction events
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handlePredictionCreated = (prediction) => {
|
||||
console.log('📥 Received prediction:created event', prediction);
|
||||
setPredictions(prev => {
|
||||
// Avoid duplicates
|
||||
if (prev.some(p => p.id === prediction.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [prediction, ...prev];
|
||||
});
|
||||
|
||||
// Don't show toast for your own predictions
|
||||
if (prediction.user_id !== user.id) {
|
||||
toast.success(`New prediction from ${prediction.username}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePredictionValidated = (prediction) => {
|
||||
console.log('📥 Received prediction:validated event', prediction);
|
||||
setPredictions(prev =>
|
||||
prev.map(p => p.id === prediction.id ? prediction : p)
|
||||
);
|
||||
loadLeaderboard(); // Refresh leaderboard when points change
|
||||
|
||||
if (prediction.user_id === user.id) {
|
||||
toast.success(
|
||||
prediction.status === 'validated'
|
||||
? '🎉 Your prediction was validated!'
|
||||
: '❌ Your prediction was invalidated'
|
||||
);
|
||||
} else {
|
||||
toast(`${prediction.username}'s prediction ${prediction.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvitationResponse = (response) => {
|
||||
console.log('📥 Received invitation response', response);
|
||||
if (response.status === 'accepted') {
|
||||
toast.success(`${response.username} joined the challenge!`);
|
||||
loadChallenge(); // Refresh participant list
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('prediction:created', handlePredictionCreated);
|
||||
socket.on('prediction:validated', handlePredictionValidated);
|
||||
socket.on('challenge:invitation_response', handleInvitationResponse);
|
||||
|
||||
return () => {
|
||||
socket.off('prediction:created', handlePredictionCreated);
|
||||
socket.off('prediction:validated', handlePredictionValidated);
|
||||
socket.off('challenge:invitation_response', handleInvitationResponse);
|
||||
};
|
||||
}, [socket, user.id, loadLeaderboard, loadChallenge]);
|
||||
|
||||
const handleCreatePrediction = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newPrediction.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.createPrediction({
|
||||
challenge_id: id,
|
||||
content: newPrediction
|
||||
});
|
||||
toast.success('Prediction submitted!');
|
||||
setNewPrediction('');
|
||||
await loadPredictions();
|
||||
} catch (err) {
|
||||
alert('Failed to create prediction: ' + err.message);
|
||||
toast.error('Failed to create prediction: ' + err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async (predictionId, status) => {
|
||||
setValidating(predictionId);
|
||||
try {
|
||||
await api.validatePrediction(predictionId, status);
|
||||
toast.success(status === 'validated' ? 'Prediction validated!' : 'Prediction invalidated');
|
||||
await loadPredictions();
|
||||
await loadLeaderboard();
|
||||
} catch (err) {
|
||||
alert('Failed to validate: ' + err.message);
|
||||
toast.error('Failed to validate: ' + err.message);
|
||||
} finally {
|
||||
setValidating(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,7 +192,7 @@ export default function ChallengeDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search by 1 second
|
||||
// Debounce search by 0.5 second
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const data = await api.searchUsers(query);
|
||||
@@ -98,19 +200,55 @@ export default function ChallengeDetail() {
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
}, 1000);
|
||||
}, 500);
|
||||
|
||||
setSearchTimeout(timeout);
|
||||
};
|
||||
|
||||
const handleInvite = async (userId) => {
|
||||
const handleInvite = async (userId, userName) => {
|
||||
setInviting(userId);
|
||||
try {
|
||||
await api.inviteToChallenge(id, { user_ids: [userId] });
|
||||
toast.success(`Invitation sent to ${userName || 'user'}!`);
|
||||
setInviteQuery('');
|
||||
setSearchResults([]);
|
||||
alert('Invitation sent!');
|
||||
await loadChallenge(); // Refresh to update participant list
|
||||
} catch (err) {
|
||||
alert('Failed to send invite: ' + err.message);
|
||||
toast.error('Failed to send invite: ' + err.message);
|
||||
} finally {
|
||||
setInviting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this challenge? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteChallenge(id);
|
||||
toast.success('Challenge deleted successfully');
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete challenge: ' + err.message);
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!confirm('Are you sure you want to leave this challenge? Your predictions will be removed.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLeaving(true);
|
||||
try {
|
||||
await api.leaveChallenge(id);
|
||||
toast.success('Left challenge successfully');
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
toast.error('Failed to leave challenge: ' + err.message);
|
||||
setLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,32 +281,98 @@ export default function ChallengeDetail() {
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Created by {challenge.challenge.creator_username}
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowInvite(!showInvite)}
|
||||
>
|
||||
{showInvite ? 'Cancel' : 'Invite Friends'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowInvite(!showInvite)}
|
||||
>
|
||||
{showInvite ? 'Cancel' : 'Invite Friends'}
|
||||
</button>
|
||||
{challenge.challenge.created_by === user.id ? (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Challenge'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleLeave}
|
||||
disabled={leaving}
|
||||
>
|
||||
{leaving ? 'Leaving...' : 'Leave Challenge'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Section */}
|
||||
{showInvite && (
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Invite Someone</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by username or email..."
|
||||
value={inviteQuery}
|
||||
onChange={(e) => handleSearchUsers(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Friends List */}
|
||||
{friends.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h4 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>Your Friends</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{friends.filter(friend => {
|
||||
// Filter out friends who are already participants
|
||||
const isParticipant = challenge.participants?.some(p => p.id === friend.id);
|
||||
const isCreator = challenge.challenge.created_by === friend.id;
|
||||
return !isParticipant && !isCreator;
|
||||
}).map(friend => (
|
||||
<div
|
||||
key={friend.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleInvite(friend.id, friend.username)}
|
||||
disabled={inviting === friend.id}
|
||||
style={{ flexShrink: 0, width: 'auto' }}
|
||||
>
|
||||
{inviting === friend.id ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search for others */}
|
||||
<div>
|
||||
<h4 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>Search for Others</h4>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by username or email..."
|
||||
value={inviteQuery}
|
||||
onChange={(e) => handleSearchUsers(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: 'translateY(100%)',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
@@ -180,15 +384,27 @@ export default function ChallengeDetail() {
|
||||
{searchResults.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleInvite(user.id)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer'
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '1rem'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleInvite(user.id, user.username)}
|
||||
disabled={inviting === user.id}
|
||||
style={{ flexShrink: 0, width: 'auto' }}
|
||||
>
|
||||
{inviting === user.id ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -232,8 +448,8 @@ export default function ChallengeDetail() {
|
||||
onChange={(e) => setNewPrediction(e.target.value)}
|
||||
style={{ minHeight: '80px' }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }}>
|
||||
Submit Prediction
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }} disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit Prediction'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -261,14 +477,16 @@ export default function ChallengeDetail() {
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'validated')}
|
||||
disabled={validating === pred.id}
|
||||
>
|
||||
✓ Validate
|
||||
{validating === pred.id ? '...' : '✓ Validate'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'invalidated')}
|
||||
disabled={validating === pred.id}
|
||||
>
|
||||
✗ Invalidate
|
||||
{validating === pred.id ? '...' : '✗ Invalidate'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import { useSocket } from '../SocketContext';
|
||||
|
||||
export default function ChallengeList() {
|
||||
const [challenges, setChallenges] = useState([]);
|
||||
@@ -10,11 +13,32 @@ export default function ChallengeList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [searchTimeout, setSearchTimeout] = useState(null);
|
||||
const [respondingTo, setRespondingTo] = useState(null);
|
||||
const { socket } = useSocket();
|
||||
const searchRef = useRef(null);
|
||||
|
||||
useClickOutside(searchRef, () => setShowResults([]));
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenges();
|
||||
}, []);
|
||||
|
||||
// Listen for real-time challenge invitations
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleChallengeInvitation = (invitation) => {
|
||||
toast.success(`📬 ${invitation.invited_by} invited you to "${invitation.challenge_title}"`);
|
||||
loadChallenges(); // Refresh to show new invitation
|
||||
};
|
||||
|
||||
socket.on('challenge:invitation', handleChallengeInvitation);
|
||||
|
||||
return () => {
|
||||
socket.off('challenge:invitation', handleChallengeInvitation);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const loadChallenges = async () => {
|
||||
try {
|
||||
const data = await api.getChallenges();
|
||||
@@ -31,11 +55,15 @@ export default function ChallengeList() {
|
||||
};
|
||||
|
||||
const handleRespondToInvite = async (challengeId, status) => {
|
||||
setRespondingTo(challengeId);
|
||||
try {
|
||||
await api.respondToChallenge(challengeId, status);
|
||||
toast.success(status === 'accepted' ? 'Challenge accepted!' : 'Challenge declined');
|
||||
await loadChallenges();
|
||||
} catch (err) {
|
||||
alert('Failed to respond: ' + err.message);
|
||||
toast.error('Failed to respond: ' + err.message);
|
||||
} finally {
|
||||
setRespondingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,7 +80,7 @@ export default function ChallengeList() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search by 1.5 seconds
|
||||
// Debounce search by 0.75 seconds
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const data = await api.searchShows(query);
|
||||
@@ -60,7 +88,7 @@ export default function ChallengeList() {
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
}, 1500);
|
||||
}, 750);
|
||||
|
||||
setSearchTimeout(timeout);
|
||||
};
|
||||
@@ -72,18 +100,19 @@ export default function ChallengeList() {
|
||||
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
|
||||
: null;
|
||||
|
||||
const result = await api.createChallenge({
|
||||
await api.createChallenge({
|
||||
title: show.title,
|
||||
cover_image_url: coverImage,
|
||||
tmdb_id: show.id,
|
||||
media_type: show.media_type
|
||||
});
|
||||
|
||||
toast.success('Challenge created!');
|
||||
setSearchQuery('');
|
||||
setShowResults([]);
|
||||
await loadChallenges();
|
||||
} catch (err) {
|
||||
alert('Failed to create challenge: ' + err.message);
|
||||
toast.error('Failed to create challenge: ' + err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -124,14 +153,16 @@ export default function ChallengeList() {
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleRespondToInvite(challenge.id, 'accepted')}
|
||||
disabled={respondingTo === challenge.id}
|
||||
>
|
||||
Accept
|
||||
{respondingTo === challenge.id ? '...' : 'Accept'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleRespondToInvite(challenge.id, 'rejected')}
|
||||
disabled={respondingTo === challenge.id}
|
||||
>
|
||||
Decline
|
||||
{respondingTo === challenge.id ? '...' : 'Decline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +172,7 @@ export default function ChallengeList() {
|
||||
)}
|
||||
|
||||
{/* Search/Create */}
|
||||
<div style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<div style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
@@ -214,7 +245,7 @@ export default function ChallengeList() {
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{challenge.title}</h3>
|
||||
<h3 style={{ marginBottom: '0.5rem', color: 'var(--primary)' }}>{challenge.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
Created by {challenge.creator_username}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import { useSocket } from '../SocketContext';
|
||||
|
||||
export default function Friends() {
|
||||
const [friends, setFriends] = useState([]);
|
||||
@@ -9,11 +12,52 @@ export default function Friends() {
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTimeout, setSearchTimeout] = useState(null);
|
||||
const [responding, setResponding] = useState(null);
|
||||
const [sending, setSending] = useState(null);
|
||||
const [removing, setRemoving] = useState(null);
|
||||
const { socket } = useSocket();
|
||||
const searchRef = useRef(null);
|
||||
|
||||
useClickOutside(searchRef, () => setSearchResults([]));
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Listen for real-time friend request events
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleFriendRequest = (request) => {
|
||||
toast.success(`👋 ${request.from_username} sent you a friend request`);
|
||||
loadData(); // Refresh to show new request
|
||||
};
|
||||
|
||||
const handleFriendResponse = (response) => {
|
||||
if (response.status === 'accepted') {
|
||||
toast.success(`🎉 ${response.friend_username} accepted your friend request!`);
|
||||
loadData(); // Refresh friends list
|
||||
} else {
|
||||
toast(`${response.friend_username} declined your friend request`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFriendRemoved = (data) => {
|
||||
toast(`${data.removed_by_username} removed you from their friends`);
|
||||
loadData(); // Refresh friends list
|
||||
};
|
||||
|
||||
socket.on('friend:request', handleFriendRequest);
|
||||
socket.on('friend:response', handleFriendResponse);
|
||||
socket.on('friend:removed', handleFriendRemoved);
|
||||
|
||||
return () => {
|
||||
socket.off('friend:request', handleFriendRequest);
|
||||
socket.off('friend:response', handleFriendResponse);
|
||||
socket.off('friend:removed', handleFriendRemoved);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [friendsData, requestsData] = await Promise.all([
|
||||
@@ -43,7 +87,7 @@ export default function Friends() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search by 1 second
|
||||
// Debounce search by 0.5 second
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const data = await api.searchUsers(query);
|
||||
@@ -51,28 +95,53 @@ export default function Friends() {
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
}, 1000);
|
||||
}, 500);
|
||||
|
||||
setSearchTimeout(timeout);
|
||||
};
|
||||
|
||||
const handleSendRequest = async (userId) => {
|
||||
setSending(userId);
|
||||
try {
|
||||
await api.sendFriendRequest(userId);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
alert('Friend request sent!');
|
||||
toast.success('Friend request sent!');
|
||||
await loadData(); // Refresh the friends list to update friendship status
|
||||
} catch (err) {
|
||||
alert('Failed to send request: ' + err.message);
|
||||
toast.error('Failed to send request: ' + err.message);
|
||||
} finally {
|
||||
setSending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespond = async (requestId, status) => {
|
||||
setResponding(requestId);
|
||||
try {
|
||||
await api.respondToFriendRequest(requestId, status);
|
||||
toast.success(status === 'accepted' ? 'Friend request accepted!' : 'Friend request declined');
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
alert('Failed to respond: ' + err.message);
|
||||
toast.error('Failed to respond: ' + err.message);
|
||||
} finally {
|
||||
setResponding(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFriend = async (friendId, friendName) => {
|
||||
if (!confirm(`Are you sure you want to remove ${friendName} from your friends?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoving(friendId);
|
||||
try {
|
||||
await api.removeFriend(friendId);
|
||||
toast.success('Friend removed');
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
toast.error('Failed to remove friend: ' + err.message);
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,7 +155,7 @@ export default function Friends() {
|
||||
<h1 style={{ marginBottom: '2rem' }}>Friends</h1>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Add Friends</h3>
|
||||
<input
|
||||
type="text"
|
||||
@@ -117,18 +186,21 @@ export default function Friends() {
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
alignItems: 'center',
|
||||
gap: '1rem'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleSendRequest(user.id)}
|
||||
disabled={sending === user.id}
|
||||
style={{ flexShrink: 0, width: 'auto' }}
|
||||
>
|
||||
Add Friend
|
||||
{sending === user.id ? 'Sending...' : 'Add Friend'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -142,23 +214,25 @@ export default function Friends() {
|
||||
<h3 style={{ marginBottom: '1rem' }}>Friend Requests</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{requests.map(req => (
|
||||
<div key={req.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div key={req.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{req.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{req.email}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleRespond(req.id, 'accepted')}
|
||||
disabled={responding === req.id}
|
||||
>
|
||||
Accept
|
||||
{responding === req.id ? '...' : 'Accept'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleRespond(req.id, 'rejected')}
|
||||
disabled={responding === req.id}
|
||||
>
|
||||
Reject
|
||||
{responding === req.id ? '...' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,28 +249,55 @@ export default function Friends() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{friends.map(friend => (
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)' }}>{friend.total_points} points</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleRemoveFriend(friend.id, friend.username)}
|
||||
disabled={removing === friend.id}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{removing === friend.id ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
{challengeFriends.length > 0 && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
||||
{friends.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
From Challenges
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{friends.length === 0 && (
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
From Challenges
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{challengeFriends.map(friend => (
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)' }}>{friend.total_points} points</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexShrink: 0, flexWrap: 'nowrap' }}>
|
||||
<button
|
||||
className={`btn btn-sm ${friend.friendship_status === 'pending' ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => handleSendRequest(friend.id)}
|
||||
disabled={sending === friend.id || friend.friendship_status === 'pending'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{sending === friend.id ? 'Sending...' : friend.friendship_status === 'pending' ? 'Pending' : 'Add Friend'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
toast.success('Welcome back!');
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
toast.error(err.message || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -42,15 +43,35 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0.5rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
|
||||
156
frontend/src/pages/PasswordReset.jsx
Normal file
156
frontend/src/pages/PasswordReset.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import '../App.css';
|
||||
|
||||
function PasswordReset() {
|
||||
const { token } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await api.resetPassword(token, newPassword);
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card" style={{ maxWidth: '500px', margin: '2rem auto' }}>
|
||||
<h1 style={{ color: '#28a745' }}>✓ Password Reset Successful</h1>
|
||||
<p>Your password has been updated successfully.</p>
|
||||
<p>Redirecting to login page...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card" style={{ maxWidth: '500px', margin: '2rem auto' }}>
|
||||
<h1>Reset Your Password</h1>
|
||||
<p style={{ color: '#999', marginBottom: '2rem' }}>
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
background: '#3a1a1a',
|
||||
border: '2px solid #dc3545',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1rem',
|
||||
color: '#dc3545'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
background: '#222',
|
||||
color: '#fff',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
background: '#222',
|
||||
color: '#fff',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
background: loading ? '#666' : '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Resetting Password...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||
<a
|
||||
href="/login"
|
||||
style={{ color: '#007bff', textDecoration: 'none' }}
|
||||
>
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordReset;
|
||||
@@ -1,26 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function Register() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(email, username, password);
|
||||
toast.success('Account created successfully!');
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
toast.error(err.message || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -53,16 +54,36 @@ export default function Register() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0.5rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Register'}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
image: reg.dev.nervesocket.com/wtp-prod:release
|
||||
|
||||
Reference in New Issue
Block a user