e09017d2e0
- Set up Winston structured logging with console and file outputs - Create GET /api/health endpoint with uptime, database status, response times - Add request logging middleware (method, path, statusCode, duration) - Create health monitoring module with database connectivity checks - Log all HTTP requests with timing information - Log auth events (login, register) and data modifications - Replace console.log/error with structured logger calls - Update backend README with logging configuration documentation - Add tests for health endpoint and logging middleware - Logs directory: logs/combined.log and logs/error.log Deliverables met: ✓ Structured logging (Winston) integrated ✓ Enhanced health endpoint with uptime & database info ✓ Request logging middleware attached to all routes ✓ Comprehensive logging documentation in README.md ✓ Tests passing for health and logging functionality ✓ All critical operations logged with context
813 lines
28 KiB
JavaScript
813 lines
28 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const { Pool } = require('pg');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const logger = require('./utils/logger');
|
|
const requestLoggerMiddleware = require('./middleware/requestLogger');
|
|
const { getHealthStatus, getUptime } = require('./utils/health');
|
|
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
|
|
const { createExerciseRecommendationRouter } = require('./routes/exerciseRecommendations');
|
|
const { searchExerciseResearch } = require('./services/exaSearch');
|
|
|
|
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'
|
|
});
|
|
|
|
// Middleware setup
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(requestLoggerMiddleware); // Add request logging middleware
|
|
|
|
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
|
|
app.use('/api/exercises', createExerciseRecommendationRouter());
|
|
|
|
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' }); }
|
|
};
|
|
|
|
// Enhanced health endpoint with uptime and database status
|
|
app.get('/api/health', async (req, res) => {
|
|
try {
|
|
const health = await getHealthStatus(pool);
|
|
const statusCode = health.status === 'healthy' ? 200 : (health.status === 'degraded' ? 200 : 503);
|
|
res.status(statusCode).json(health);
|
|
} catch (err) {
|
|
logger.error('Health check error', { error: err.message });
|
|
res.status(503).json({
|
|
status: 'unhealthy',
|
|
uptime: getUptime(),
|
|
timestamp: new Date().toISOString(),
|
|
error: 'Health check failed'
|
|
});
|
|
}
|
|
});
|
|
|
|
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' });
|
|
logger.info('User registered', { userId: result.rows[0].id, email: result.rows[0].email });
|
|
res.json({ token, user: result.rows[0] });
|
|
} catch (err) {
|
|
if (err.code === '23505') {
|
|
logger.warn('Registration failed - email exists', { email: req.body.email });
|
|
return res.status(400).json({ error: 'Email already exists' });
|
|
}
|
|
logger.error('Register error', { error: err.message });
|
|
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) {
|
|
logger.warn('Login failed - user not found', { email });
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
const user = result.rows[0];
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) {
|
|
logger.warn('Login failed - invalid password', { userId: user.id });
|
|
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;
|
|
logger.info('User logged in', { userId: user.id, email: user.email });
|
|
res.json({ token, user: safeUser });
|
|
} catch (err) {
|
|
logger.error('Login error', { error: err.message });
|
|
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) {
|
|
logger.error('Profile error', { error: err.message, userId: req.user.id });
|
|
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]
|
|
);
|
|
logger.info('User profile updated', { userId: req.user.id });
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
logger.error('Update profile error', { error: err.message, userId: req.user.id });
|
|
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)]
|
|
);
|
|
logger.info('Measurements added', { userId: req.user.id });
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
logger.error('Add measurements error', { error: err.message, userId: req.user.id });
|
|
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) {
|
|
logger.error('Get measurements error', { error: err.message, userId: req.user.id });
|
|
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)]
|
|
);
|
|
logger.info('Strength record added', { userId: req.user.id });
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
logger.error('Add strength error', { error: err.message, userId: req.user.id });
|
|
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) {
|
|
logger.error('Get strength error', { error: err.message, userId: req.user.id });
|
|
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) {
|
|
logger.error('Error fetching programs', { error: err.message });
|
|
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) {
|
|
logger.error('Error fetching program', { error: err.message, programId: req.params.id });
|
|
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) {
|
|
logger.error('Error fetching exercises', { error: err.message, dayId: req.params.dayId });
|
|
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) {
|
|
logger.error('Error fetching alternatives', { error: err.message, exerciseId: req.params.id });
|
|
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) {
|
|
logger.error('Error fetching last workout for exercise', { error: err.message, exerciseId: req.params.id });
|
|
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) {
|
|
logger.error('Error calculating progression', { error: err.message, programExerciseId: req.params.programExerciseId });
|
|
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) {
|
|
logger.error('Error fetching today workout', { error: err.message, programId: req.params.programId });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
if (require.main === module) {
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
logger.info(`Gravl API started`, { port: PORT, environment: process.env.NODE_ENV || 'development' });
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Custom Workouts API (Phase 4: Workout Modification)
|
|
// ============================================
|
|
|
|
// Get all exercises (for picker UI)
|
|
app.get('/api/exercises', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
'SELECT id, name, muscle_group, description FROM exercises ORDER BY muscle_group, name'
|
|
);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
logger.error('Error fetching exercises', { error: err.message });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
// Create custom workout from program day (fork)
|
|
app.post('/api/custom-workouts', authMiddleware, async (req, res) => {
|
|
const client = await pool.connect();
|
|
try {
|
|
const { source_program_day_id, name, description } = req.body;
|
|
const user_id = req.user.id;
|
|
|
|
await client.query('BEGIN');
|
|
|
|
// Get the program day info and its exercises
|
|
const dayResult = await client.query(
|
|
'SELECT name, program_id FROM program_days WHERE id = $1',
|
|
[source_program_day_id]
|
|
);
|
|
|
|
if (dayResult.rows.length === 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(404).json({ error: 'Program day not found' });
|
|
}
|
|
|
|
const dayName = dayResult.rows[0].name;
|
|
const workoutName = name || `${dayName} (anpassad)`;
|
|
|
|
// Create custom workout
|
|
const workoutResult = await client.query(
|
|
`INSERT INTO custom_workouts (user_id, name, description, source_program_day_id)
|
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
|
[user_id, workoutName, description || null, source_program_day_id]
|
|
);
|
|
const customWorkout = workoutResult.rows[0];
|
|
|
|
// Copy exercises from program day
|
|
const exercisesResult = await client.query(
|
|
`INSERT INTO custom_workout_exercises
|
|
(custom_workout_id, exercise_id, sets, reps_min, reps_max, order_index, replaced_exercise_id)
|
|
SELECT $1, exercise_id, sets, reps_min, reps_max, order_num, NULL
|
|
FROM program_exercises WHERE program_day_id = $2
|
|
RETURNING *`,
|
|
[customWorkout.id, source_program_day_id]
|
|
);
|
|
|
|
await client.query('COMMIT');
|
|
logger.info('Custom workout created', { userId: user_id, workoutId: customWorkout.id });
|
|
|
|
res.json({
|
|
...customWorkout,
|
|
exercises: exercisesResult.rows
|
|
});
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error creating custom workout', { error: err.message, userId: req.user.id });
|
|
res.status(500).json({ error: 'Database error' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// List user's custom workouts
|
|
app.get('/api/custom-workouts', authMiddleware, async (req, res) => {
|
|
try {
|
|
const user_id = req.user.id;
|
|
const result = await pool.query(
|
|
`SELECT cw.*, pd.name as original_day_name, p.name as program_name
|
|
FROM custom_workouts cw
|
|
LEFT JOIN program_days pd ON cw.source_program_day_id = pd.id
|
|
LEFT JOIN programs p ON pd.program_id = p.id
|
|
WHERE cw.user_id = $1
|
|
ORDER BY cw.created_at DESC`,
|
|
[user_id]
|
|
);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
logger.error('Error fetching custom workouts', { error: err.message, userId: req.user.id });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
// Get single custom workout with exercises
|
|
app.get('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
|
|
try {
|
|
const user_id = req.user.id;
|
|
const workout_id = req.params.id;
|
|
|
|
// Get workout header
|
|
const workoutResult = await pool.query(
|
|
`SELECT cw.*, pd.name as original_day_name, p.name as program_name
|
|
FROM custom_workouts cw
|
|
LEFT JOIN program_days pd ON cw.source_program_day_id = pd.id
|
|
LEFT JOIN programs p ON pd.program_id = p.id
|
|
WHERE cw.id = $1 AND cw.user_id = $2`,
|
|
[workout_id, user_id]
|
|
);
|
|
|
|
if (workoutResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Custom workout not found' });
|
|
}
|
|
|
|
// Get exercises with full details
|
|
const exercisesResult = await pool.query(
|
|
`SELECT cwe.*, e.name, e.muscle_group, e.description,
|
|
re.name as replaced_exercise_name,
|
|
re.muscle_group as replaced_exercise_muscle_group
|
|
FROM custom_workout_exercises cwe
|
|
JOIN exercises e ON cwe.exercise_id = e.id
|
|
LEFT JOIN exercises re ON cwe.replaced_exercise_id = re.id
|
|
WHERE cwe.custom_workout_id = $1
|
|
ORDER BY cwe.order_index`,
|
|
[workout_id]
|
|
);
|
|
|
|
res.json({
|
|
...workoutResult.rows[0],
|
|
exercises: exercisesResult.rows
|
|
});
|
|
} catch (err) {
|
|
logger.error('Error fetching custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
// Update custom workout exercises (replace all)
|
|
app.put('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
|
|
const client = await pool.connect();
|
|
try {
|
|
const user_id = req.user.id;
|
|
const workout_id = req.params.id;
|
|
const { name, description, exercises } = req.body;
|
|
|
|
await client.query('BEGIN');
|
|
|
|
// Verify ownership
|
|
const workoutCheck = await client.query(
|
|
'SELECT id FROM custom_workouts WHERE id = $1 AND user_id = $2',
|
|
[workout_id, user_id]
|
|
);
|
|
|
|
if (workoutCheck.rows.length === 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(404).json({ error: 'Custom workout not found' });
|
|
}
|
|
|
|
// Update workout details
|
|
if (name || description !== undefined) {
|
|
await client.query(
|
|
`UPDATE custom_workouts
|
|
SET name = COALESCE($1, name),
|
|
description = COALESCE($2, description),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $3`,
|
|
[name, description, workout_id]
|
|
);
|
|
}
|
|
|
|
// Replace exercises if provided
|
|
if (exercises && Array.isArray(exercises)) {
|
|
// Delete existing exercises
|
|
await client.query(
|
|
'DELETE FROM custom_workout_exercises WHERE custom_workout_id = $1',
|
|
[workout_id]
|
|
);
|
|
|
|
// Insert new exercises
|
|
for (let i = 0; i < exercises.length; i++) {
|
|
const ex = exercises[i];
|
|
await client.query(
|
|
`INSERT INTO custom_workout_exercises
|
|
(custom_workout_id, exercise_id, sets, reps_min, reps_max, order_index, replaced_exercise_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
[workout_id, ex.exercise_id, ex.sets || 3, ex.reps_min || 8, ex.reps_max || 12,
|
|
i, ex.replaced_exercise_id || null]
|
|
);
|
|
}
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
logger.info('Custom workout updated', { userId: user_id, workoutId: workout_id });
|
|
|
|
// Fetch and return updated workout
|
|
const updatedResult = await pool.query(
|
|
`SELECT cw.*, pd.name as original_day_name, p.name as program_name
|
|
FROM custom_workouts cw
|
|
LEFT JOIN program_days pd ON cw.source_program_day_id = pd.id
|
|
LEFT JOIN programs p ON pd.program_id = p.id
|
|
WHERE cw.id = $1`,
|
|
[workout_id]
|
|
);
|
|
|
|
const exercisesResult = await pool.query(
|
|
`SELECT cwe.*, e.name, e.muscle_group, e.description
|
|
FROM custom_workout_exercises cwe
|
|
JOIN exercises e ON cwe.exercise_id = e.id
|
|
WHERE cwe.custom_workout_id = $1
|
|
ORDER BY cwe.order_index`,
|
|
[workout_id]
|
|
);
|
|
|
|
res.json({
|
|
...updatedResult.rows[0],
|
|
exercises: exercisesResult.rows
|
|
});
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error updating custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
|
|
res.status(500).json({ error: 'Database error' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// Delete custom workout
|
|
app.delete('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
|
|
try {
|
|
const user_id = req.user.id;
|
|
const workout_id = req.params.id;
|
|
|
|
const result = await pool.query(
|
|
'DELETE FROM custom_workouts WHERE id = $1 AND user_id = $2 RETURNING id',
|
|
[workout_id, user_id]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Custom workout not found' });
|
|
}
|
|
|
|
logger.info('Custom workout deleted', { userId: user_id, workoutId: workout_id });
|
|
res.json({ deleted: result.rows[0].id });
|
|
} catch (err) {
|
|
logger.error('Error deleting custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Updated Log Endpoints (support source_type)
|
|
// ============================================
|
|
|
|
// Get workout logs (optionally filter by source_type and custom_workout_id)
|
|
app.get('/api/logs', async (req, res) => {
|
|
try {
|
|
const { user_id, date, source_type, custom_workout_id } = req.query;
|
|
|
|
let query = 'SELECT * FROM workout_logs WHERE user_id = $1';
|
|
let params = [user_id];
|
|
let paramIdx = 2;
|
|
|
|
if (date) {
|
|
query += ` AND date = $${paramIdx++}`;
|
|
params.push(date);
|
|
}
|
|
|
|
if (source_type) {
|
|
query += ` AND source_type = $${paramIdx++}`;
|
|
params.push(source_type);
|
|
}
|
|
|
|
if (custom_workout_id) {
|
|
query += ` AND custom_workout_id = $${paramIdx++}`;
|
|
params.push(custom_workout_id);
|
|
}
|
|
|
|
query += ' ORDER BY date DESC, set_number ASC';
|
|
|
|
const result = await pool.query(query, params);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
logger.error('Error fetching logs', { error: err.message });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
// Log a set (updated for source_type and custom_workout support)
|
|
app.post('/api/logs', async (req, res) => {
|
|
try {
|
|
const { user_id, program_exercise_id, custom_workout_exercise_id, date, set_number, weight, reps, completed, source_type, custom_workout_id } = req.body;
|
|
|
|
const source = source_type || 'program';
|
|
|
|
// Determine which exercise identifier to use for lookup
|
|
const exerciseRef = custom_workout_exercise_id || program_exercise_id;
|
|
|
|
// Check if log exists for this set
|
|
let existingQuery, existingParams;
|
|
if (source === 'custom' && custom_workout_id) {
|
|
existingQuery = `SELECT id FROM workout_logs
|
|
WHERE user_id = $1 AND custom_workout_id = $2 AND date = $3 AND set_number = $4`;
|
|
existingParams = [user_id, custom_workout_id, date, set_number];
|
|
} else {
|
|
existingQuery = `SELECT id FROM workout_logs
|
|
WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4`;
|
|
existingParams = [user_id, program_exercise_id, date, set_number];
|
|
}
|
|
|
|
const existing = await pool.query(existingQuery, existingParams);
|
|
|
|
let result;
|
|
if (existing.rows.length > 0) {
|
|
// Update existing
|
|
result = await pool.query(
|
|
`UPDATE workout_logs
|
|
SET weight = $1, reps = $2, completed = $3, source_type = $4
|
|
WHERE id = $5 RETURNING *`,
|
|
[weight, reps, completed, source, existing.rows[0].id]
|
|
);
|
|
} else {
|
|
// Insert new
|
|
result = await pool.query(
|
|
`INSERT INTO workout_logs (user_id, program_exercise_id, custom_workout_exercise_id,
|
|
date, set_number, weight, reps, completed, source_type, custom_workout_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
|
[user_id, program_exercise_id, custom_workout_exercise_id, date, set_number,
|
|
weight, reps, completed, source, custom_workout_id]
|
|
);
|
|
}
|
|
|
|
logger.debug('Workout set logged', { userId: user_id, exerciseId: exerciseRef, weight, reps });
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
logger.error('Error logging set', { error: err.message });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
// Delete a specific set log (updated for source_type support)
|
|
app.delete('/api/logs', async (req, res) => {
|
|
try {
|
|
const { user_id, program_exercise_id, custom_workout_id, date, set_number } = req.body;
|
|
|
|
let query, params;
|
|
if (custom_workout_id) {
|
|
query = `DELETE FROM workout_logs
|
|
WHERE user_id = $1 AND custom_workout_id = $2 AND date = $3 AND set_number = $4
|
|
RETURNING id`;
|
|
params = [user_id, custom_workout_id, date, set_number];
|
|
} else {
|
|
query = `DELETE FROM workout_logs
|
|
WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4
|
|
RETURNING id`;
|
|
params = [user_id, program_exercise_id, date, set_number];
|
|
}
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Log not found' });
|
|
}
|
|
|
|
logger.info('Workout log deleted', { userId: user_id, date, setNumber: set_number });
|
|
res.json({ deleted: result.rows[0].id });
|
|
} catch (err) {
|
|
logger.error('Error deleting log', { error: err.message });
|
|
res.status(500).json({ error: 'Database error' });
|
|
}
|
|
});
|
|
|
|
module.exports = app;
|