Initial commit: Gravl MVP med onboarding

This commit is contained in:
2026-01-31 23:33:20 +01:00
commit 13ade5e903
21 changed files with 5157 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
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 result = await pool.query('SELECT id, email, gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete FROM users WHERE id = $1', [req.user.id]);
if (!result.rows.length) return res.status(404).json({ error: 'User not found' });
res.json(result.rows[0]);
} 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, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete } = req.body;
const result = await pool.query(
`UPDATE users SET gender=$1, age=$2, weight=$3, neck_cm=$4, waist_cm=$5, hip_cm=$6, experience_level=$7, bench_1rm=$8, squat_1rm=$9, deadlift_1rm=$10, goal=$11, workouts_per_week=$12, onboarding_complete=$13 WHERE id=$14 RETURNING id, email, gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, workouts_per_week, onboarding_complete`,
[gender, age, weight, neck_cm, waist_cm, hip_cm, experience_level, bench_1rm, squat_1rm, deadlift_1rm, goal, 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' });
}
});
// 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 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' });
}
});
// 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}`);
});