Initial commit: Gravl MVP med onboarding
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["npm", "start"]
|
||||
Generated
+1490
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "gravl-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Gravl Training App Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user