fe64bd9a67
- Ny databasstruktur för historik/progress tracking - Nya endpoints: POST/GET measurements och strength - Onboarding sparar till rätt tabeller - Beräknar och sparar body_fat_pct - Fixar tomma numeriska fält (null istället för '') - Döljer 1RM för nybörjare
425 lines
14 KiB
JavaScript
425 lines
14 KiB
JavaScript
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 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}`);
|
|
});
|