const express = require('express'); const cors = require('cors'); const { Pool } = require('pg'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const app = express(); const PORT = process.env.PORT || 3001; const JWT_SECRET = process.env.JWT_SECRET || 'gravl-secret-key-change-in-production'; const pool = new Pool({ host: process.env.DB_HOST || 'postgres', port: process.env.DB_PORT || 5432, user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'homelab_postgres_2026', database: process.env.DB_NAME || 'gravl' }); app.use(cors()); app.use(express.json()); const authMiddleware = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token' }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Invalid token' }); } }; app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.post('/api/auth/register', async (req, res) => { try { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); const hash = await bcrypt.hash(password, 10); const result = await pool.query( 'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email', [email.toLowerCase(), hash] ); const token = jwt.sign({ id: result.rows[0].id, email: result.rows[0].email }, JWT_SECRET, { expiresIn: '30d' }); res.json({ token, user: result.rows[0] }); } catch (err) { if (err.code === '23505') return res.status(400).json({ error: 'Email already exists' }); console.error('Register error:', err); res.status(500).json({ error: 'Server error' }); } }); app.post('/api/auth/login', async (req, res) => { try { const { email, password } = req.body; const result = await pool.query('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]); if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' }); const user = result.rows[0]; const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' }); const { password_hash, ...safeUser } = user; res.json({ token, user: safeUser }); } catch (err) { console.error('Login error:', err); res.status(500).json({ error: 'Server error' }); } }); app.get('/api/user/profile', authMiddleware, async (req, res) => { try { const userResult = await pool.query( 'SELECT id, email, gender, age, height_cm, experience_level, goal, workouts_per_week, onboarding_complete FROM users WHERE id = $1', [req.user.id] ); if (!userResult.rows.length) return res.status(404).json({ error: 'User not found' }); const user = userResult.rows[0]; // Get latest measurements const measResult = await pool.query( 'SELECT weight, neck_cm, waist_cm, hip_cm, body_fat_pct, measured_at FROM user_measurements WHERE user_id = $1 ORDER BY measured_at DESC LIMIT 1', [req.user.id] ); // Get latest strength const strResult = await pool.query( 'SELECT bench_1rm, squat_1rm, deadlift_1rm, measured_at FROM user_strength WHERE user_id = $1 ORDER BY measured_at DESC LIMIT 1', [req.user.id] ); res.json({ ...user, measurements: measResult.rows[0] || null, strength: strResult.rows[0] || null }); } catch (err) { console.error('Profile error:', err); res.status(500).json({ error: 'Server error' }); } }); app.put('/api/user/profile', authMiddleware, async (req, res) => { try { const { gender, age, height_cm, experience_level, goal, workouts_per_week, onboarding_complete } = req.body; const num = v => (v === '' || v === undefined) ? null : v; const result = await pool.query( `UPDATE users SET gender=$1, age=$2, height_cm=$3, experience_level=$4, goal=$5, workouts_per_week=$6, onboarding_complete=$7 WHERE id=$8 RETURNING id, email, gender, age, height_cm, experience_level, goal, workouts_per_week, onboarding_complete`, [gender, num(age), num(height_cm), experience_level, goal, num(workouts_per_week), onboarding_complete, req.user.id] ); res.json(result.rows[0]); } catch (err) { console.error('Update profile error:', err); res.status(500).json({ error: 'Server error' }); } }); // Add measurements app.post('/api/user/measurements', authMiddleware, async (req, res) => { try { const { weight, neck_cm, waist_cm, hip_cm, body_fat_pct } = req.body; const num = v => (v === '' || v === undefined) ? null : v; const result = await pool.query( `INSERT INTO user_measurements (user_id, weight, neck_cm, waist_cm, hip_cm, body_fat_pct) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [req.user.id, num(weight), num(neck_cm), num(waist_cm), num(hip_cm), num(body_fat_pct)] ); res.json(result.rows[0]); } catch (err) { console.error('Add measurements error:', err); res.status(500).json({ error: 'Server error' }); } }); // Get measurements history app.get('/api/user/measurements', authMiddleware, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM user_measurements WHERE user_id = $1 ORDER BY measured_at DESC LIMIT 100', [req.user.id] ); res.json(result.rows); } catch (err) { console.error('Get measurements error:', err); res.status(500).json({ error: 'Server error' }); } }); // Add strength record app.post('/api/user/strength', authMiddleware, async (req, res) => { try { const { bench_1rm, squat_1rm, deadlift_1rm } = req.body; const num = v => (v === '' || v === undefined) ? null : v; const result = await pool.query( `INSERT INTO user_strength (user_id, bench_1rm, squat_1rm, deadlift_1rm) VALUES ($1, $2, $3, $4) RETURNING *`, [req.user.id, num(bench_1rm), num(squat_1rm), num(deadlift_1rm)] ); res.json(result.rows[0]); } catch (err) { console.error('Add strength error:', err); res.status(500).json({ error: 'Server error' }); } }); // Get strength history app.get('/api/user/strength', authMiddleware, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM user_strength WHERE user_id = $1 ORDER BY measured_at DESC LIMIT 100', [req.user.id] ); res.json(result.rows); } catch (err) { console.error('Get strength error:', err); res.status(500).json({ error: 'Server error' }); } }); // Get all programs app.get('/api/programs', async (req, res) => { try { const result = await pool.query('SELECT * FROM programs ORDER BY id'); res.json(result.rows); } catch (err) { console.error('Error fetching programs:', err); res.status(500).json({ error: 'Database error' }); } }); // Get program details with days app.get('/api/programs/:id', async (req, res) => { try { const program = await pool.query('SELECT * FROM programs WHERE id = $1', [req.params.id]); if (program.rows.length === 0) { return res.status(404).json({ error: 'Program not found' }); } const days = await pool.query(` SELECT pd.*, json_agg(json_build_object( 'id', pe.id, 'exercise_id', e.id, 'name', e.name, 'muscle_group', e.muscle_group, 'sets', pe.sets, 'reps_min', pe.reps_min, 'reps_max', pe.reps_max, 'order', pe.order_num ) ORDER BY pe.order_num) as exercises FROM program_days pd LEFT JOIN program_exercises pe ON pd.id = pe.program_day_id LEFT JOIN exercises e ON pe.exercise_id = e.id WHERE pd.program_id = $1 GROUP BY pd.id ORDER BY pd.day_number `, [req.params.id]); res.json({ ...program.rows[0], days: days.rows }); } catch (err) { console.error('Error fetching program:', err); res.status(500).json({ error: 'Database error' }); } }); // Get exercises for a specific day app.get('/api/days/:dayId/exercises', async (req, res) => { try { const result = await pool.query(` SELECT pe.id, pe.sets, pe.reps_min, pe.reps_max, pe.order_num, e.id as exercise_id, e.name, e.muscle_group, e.description FROM program_exercises pe JOIN exercises e ON pe.exercise_id = e.id WHERE pe.program_day_id = $1 ORDER BY pe.order_num `, [req.params.dayId]); res.json(result.rows); } catch (err) { console.error('Error fetching exercises:', err); res.status(500).json({ error: 'Database error' }); } }); // Get alternative exercises for a given exercise (same muscle group) app.get('/api/exercises/:id/alternatives', async (req, res) => { try { const exerciseResult = await pool.query( 'SELECT muscle_group FROM exercises WHERE id = $1', [req.params.id] ); if (!exerciseResult.rows.length) { return res.status(404).json({ error: 'Exercise not found' }); } const muscleGroup = exerciseResult.rows[0].muscle_group; const alternatives = await pool.query( `SELECT id, name, muscle_group, description FROM exercises WHERE muscle_group = $1 AND id <> $2 ORDER BY name`, [muscleGroup, req.params.id] ); res.json(alternatives.rows); } catch (err) { console.error('Error fetching alternatives:', err); res.status(500).json({ error: 'Database error' }); } }); // Get last workout for a specific exercise id app.get('/api/exercises/:id/last-workout', async (req, res) => { try { const { user_id } = req.query; const result = await pool.query(` WITH latest AS ( SELECT wl.date FROM workout_logs wl JOIN program_exercises pe ON wl.program_exercise_id = pe.id WHERE pe.exercise_id = $1 AND wl.user_id = $2 ORDER BY wl.date DESC LIMIT 1 ) SELECT wl.* FROM workout_logs wl JOIN program_exercises pe ON wl.program_exercise_id = pe.id JOIN latest l ON wl.date = l.date WHERE pe.exercise_id = $1 AND wl.user_id = $2 ORDER BY wl.set_number ASC `, [req.params.id, user_id || 1]); res.json(result.rows); } catch (err) { console.error('Error fetching last workout for exercise:', err); res.status(500).json({ error: 'Database error' }); } }); // Get workout logs for a user and date app.get('/api/logs', async (req, res) => { try { const { user_id, date, program_exercise_id } = req.query; let query = 'SELECT * FROM workout_logs WHERE 1=1'; const params = []; if (user_id) { params.push(user_id); query += ` AND user_id = $${params.length}`; } if (date) { params.push(date); query += ` AND date = $${params.length}`; } if (program_exercise_id) { params.push(program_exercise_id); query += ` AND program_exercise_id = $${params.length}`; } query += ' ORDER BY date DESC, set_number ASC'; const result = await pool.query(query, params); res.json(result.rows); } catch (err) { console.error('Error fetching logs:', err); res.status(500).json({ error: 'Database error' }); } }); // Get last workout for an exercise (for progression) app.get('/api/logs/last/:programExerciseId', async (req, res) => { try { const { user_id } = req.query; const result = await pool.query(` SELECT * FROM workout_logs WHERE program_exercise_id = $1 AND user_id = $2 ORDER BY date DESC, set_number ASC LIMIT 10 `, [req.params.programExerciseId, user_id || 1]); res.json(result.rows); } catch (err) { console.error('Error fetching last workout:', err); res.status(500).json({ error: 'Database error' }); } }); // Log a set app.post('/api/logs', async (req, res) => { try { const { user_id, program_exercise_id, date, set_number, weight, reps, completed } = req.body; // Check if log exists for this set const existing = await pool.query( 'SELECT id FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4', [user_id, program_exercise_id, date, set_number] ); let result; if (existing.rows.length > 0) { // Update existing result = await pool.query( 'UPDATE workout_logs SET weight = $1, reps = $2, completed = $3 WHERE id = $4 RETURNING *', [weight, reps, completed, existing.rows[0].id] ); } else { // Insert new result = await pool.query( 'INSERT INTO workout_logs (user_id, program_exercise_id, date, set_number, weight, reps, completed) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *', [user_id, program_exercise_id, date, set_number, weight, reps, completed] ); } res.json(result.rows[0]); } catch (err) { console.error('Error logging set:', err); res.status(500).json({ error: 'Database error' }); } }); // Delete a specific set log app.delete('/api/logs', async (req, res) => { try { const { user_id, program_exercise_id, date, set_number } = req.body; const result = await pool.query( 'DELETE FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4 RETURNING id', [user_id, program_exercise_id, date, set_number] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Log not found' }); } res.json({ deleted: result.rows[0].id }); } catch (err) { console.error('Error deleting log:', err); res.status(500).json({ error: 'Database error' }); } }); // Calculate suggested weight based on progression app.get('/api/progression/:programExerciseId', async (req, res) => { try { const { user_id } = req.query; // Get exercise details const exerciseInfo = await pool.query(` SELECT pe.*, e.name FROM program_exercises pe JOIN exercises e ON pe.exercise_id = e.id WHERE pe.id = $1 `, [req.params.programExerciseId]); if (exerciseInfo.rows.length === 0) { return res.status(404).json({ error: 'Exercise not found' }); } const exercise = exerciseInfo.rows[0]; // Get last workout logs for this exercise const lastLogs = await pool.query(` SELECT * FROM workout_logs WHERE program_exercise_id = $1 AND user_id = $2 AND completed = true ORDER BY date DESC, set_number ASC LIMIT $3 `, [req.params.programExerciseId, user_id || 1, exercise.sets]); if (lastLogs.rows.length === 0) { return res.json({ suggestedWeight: 20, // Starting weight reason: 'No previous data - start light' }); } const lastWeight = lastLogs.rows[0].weight; const allSetsHitMaxReps = lastLogs.rows.every(log => log.reps >= exercise.reps_max); if (allSetsHitMaxReps) { // Progress: increase weight by 2.5kg return res.json({ suggestedWeight: lastWeight + 2.5, reason: `Hit ${exercise.reps_max} reps on all sets - increase weight!` }); } return res.json({ suggestedWeight: lastWeight, reason: 'Keep same weight until you hit max reps on all sets' }); } catch (err) { console.error('Error calculating progression:', err); res.status(500).json({ error: 'Database error' }); } }); // Get today's workout based on program day cycle app.get('/api/today/:programId', async (req, res) => { try { const { week } = req.query; const currentWeek = week || 1; // Get program days const days = await pool.query(` SELECT pd.*, json_agg(json_build_object( 'id', pe.id, 'exercise_id', e.id, 'name', e.name, 'muscle_group', e.muscle_group, 'sets', pe.sets, 'reps_min', pe.reps_min, 'reps_max', pe.reps_max, 'order', pe.order_num ) ORDER BY pe.order_num) as exercises FROM program_days pd LEFT JOIN program_exercises pe ON pd.id = pe.program_day_id LEFT JOIN exercises e ON pe.exercise_id = e.id WHERE pd.program_id = $1 GROUP BY pd.id ORDER BY pd.day_number `, [req.params.programId]); res.json({ week: parseInt(currentWeek), days: days.rows }); } catch (err) { console.error('Error fetching today workout:', err); res.status(500).json({ error: 'Database error' }); } }); app.listen(PORT, () => { console.log(`Gravl API running on port ${PORT}`); });