Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
420 lines
13 KiB
JavaScript
420 lines
13 KiB
JavaScript
/**
|
|
* fitness/setExerciseController.js — Sets CRUD, exercises catalog, CSV import
|
|
*/
|
|
|
|
import express from 'express';
|
|
import { dbAll, dbGet, dbRun, sqlNow } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, created, error, badRequest, notFound } from '../../../utils/response.js';
|
|
|
|
const router = express.Router();
|
|
|
|
// =============================================================================
|
|
// SETS CRUD
|
|
// =============================================================================
|
|
|
|
/**
|
|
* POST /api/v3/fitness/workouts/:workoutId/sets
|
|
* Add a set to a workout
|
|
*/
|
|
router.post('/workouts/:workoutId/sets', async (req, res) => {
|
|
try {
|
|
const { workoutId } = req.params;
|
|
const {
|
|
exercise_id, exercise_name, set_index, set_type = 'normal',
|
|
weight_kg, reps, rpe, distance_km, duration_seconds, notes, is_pr = false
|
|
} = req.body;
|
|
|
|
// Validation
|
|
if (!exercise_name && !exercise_id) {
|
|
return badRequest(res, 'exercise_name or exercise_id is required', 'VALIDATION_ERROR');
|
|
}
|
|
|
|
// Check workout exists
|
|
const workout = await dbGet('SELECT id FROM fitness_workouts WHERE id = ?', [workoutId]);
|
|
if (!workout) {
|
|
return notFound(res, 'Workout not found');
|
|
}
|
|
|
|
const result = await dbRun(`
|
|
INSERT INTO fitness_sets (
|
|
workout_id, exercise_id, exercise_name, set_index, set_type,
|
|
weight_kg, reps, rpe, distance_km, duration_seconds, notes, is_pr, created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()})
|
|
`, [
|
|
workoutId, exercise_id || null, exercise_name, set_index || 1, set_type,
|
|
weight_kg || null, reps || null, rpe || null, distance_km || null,
|
|
duration_seconds || null, notes || null, is_pr ? true : false
|
|
]);
|
|
|
|
const set = await dbGet('SELECT * FROM fitness_sets WHERE id = ?', [result.lastInsertRowid]);
|
|
|
|
apiLogger.info({ setId: set.id, workoutId }, 'Set created');
|
|
|
|
return created(res, set);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /fitness/workouts/:workoutId/sets error');
|
|
return error(res, 'CREATE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/v3/fitness/sets/:id
|
|
* Update a set
|
|
*/
|
|
router.put('/sets/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const {
|
|
exercise_name, set_index, set_type, weight_kg, reps,
|
|
rpe, distance_km, duration_seconds, notes, is_pr
|
|
} = req.body;
|
|
|
|
// Check set exists
|
|
const existing = await dbGet('SELECT * FROM fitness_sets WHERE id = ?', [id]);
|
|
if (!existing) {
|
|
return notFound(res, 'Set not found');
|
|
}
|
|
|
|
await dbRun(`
|
|
UPDATE fitness_sets
|
|
SET exercise_name = COALESCE(?, exercise_name),
|
|
set_index = COALESCE(?, set_index),
|
|
set_type = COALESCE(?, set_type),
|
|
weight_kg = COALESCE(?, weight_kg),
|
|
reps = COALESCE(?, reps),
|
|
rpe = COALESCE(?, rpe),
|
|
distance_km = COALESCE(?, distance_km),
|
|
duration_seconds = COALESCE(?, duration_seconds),
|
|
notes = COALESCE(?, notes),
|
|
is_pr = COALESCE(?, is_pr)
|
|
WHERE id = ?
|
|
`, [exercise_name, set_index, set_type, weight_kg, reps, rpe, distance_km, duration_seconds, notes, is_pr !== undefined ? (is_pr ? true : false) : null, id]);
|
|
|
|
const updated = await dbGet('SELECT * FROM fitness_sets WHERE id = ?', [id]);
|
|
|
|
apiLogger.info({ setId: id }, 'Set updated');
|
|
|
|
return success(res, updated);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'PUT /fitness/sets/:id error');
|
|
return error(res, 'UPDATE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/v3/fitness/sets/:id
|
|
* Delete a set
|
|
*/
|
|
router.delete('/sets/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check set exists
|
|
const existing = await dbGet('SELECT * FROM fitness_sets WHERE id = ?', [id]);
|
|
if (!existing) {
|
|
return notFound(res, 'Set not found');
|
|
}
|
|
|
|
await dbRun('DELETE FROM fitness_sets WHERE id = ?', [id]);
|
|
|
|
apiLogger.info({ setId: id }, 'Set deleted');
|
|
|
|
return success(res, { deleted: true, id: parseInt(id) });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'DELETE /fitness/sets/:id error');
|
|
return error(res, 'DELETE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// =============================================================================
|
|
// EXERCISES CATALOG
|
|
// =============================================================================
|
|
|
|
/**
|
|
* GET /api/v3/fitness/exercises
|
|
* List exercises (global + user-specific)
|
|
*/
|
|
router.get('/exercises', async (req, res) => {
|
|
try {
|
|
const { space_id, search, muscle, equipment, limit = 100 } = req.query;
|
|
|
|
let sql = 'SELECT * FROM fitness_exercises WHERE 1=1';
|
|
const params = [];
|
|
|
|
// Include global exercises (space_id IS NULL) and user's custom exercises
|
|
if (space_id) {
|
|
sql += ' AND (space_id IS NULL OR space_id = ?)';
|
|
params.push(space_id);
|
|
}
|
|
|
|
if (search) {
|
|
sql += ' AND name ILIKE ?';
|
|
params.push(`%${search}%`);
|
|
}
|
|
|
|
if (muscle) {
|
|
sql += ' AND (primary_muscle = ? OR secondary_muscle = ?)';
|
|
params.push(muscle, muscle);
|
|
}
|
|
|
|
if (equipment) {
|
|
sql += ' AND equipment = ?';
|
|
params.push(equipment);
|
|
}
|
|
|
|
sql += ' ORDER BY name LIMIT ?';
|
|
params.push(parseInt(limit));
|
|
|
|
const exercises = await dbAll(sql, params);
|
|
|
|
return success(res, exercises || []);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /fitness/exercises error');
|
|
return error(res, 'FETCH_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/fitness/exercises
|
|
* Create a custom exercise
|
|
*/
|
|
router.post('/exercises', async (req, res) => {
|
|
try {
|
|
const { space_id, name, equipment, primary_muscle, secondary_muscle, category } = req.body;
|
|
|
|
if (!name) {
|
|
return badRequest(res, 'name is required', 'VALIDATION_ERROR');
|
|
}
|
|
|
|
const result = await dbRun(`
|
|
INSERT INTO fitness_exercises (space_id, name, equipment, primary_muscle, secondary_muscle, category, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ${sqlNow()})
|
|
`, [space_id || null, name, equipment || null, primary_muscle || null, secondary_muscle || null, category || null]);
|
|
|
|
const exercise = await dbGet('SELECT * FROM fitness_exercises WHERE id = ?', [result.lastInsertRowid]);
|
|
|
|
apiLogger.info({ exerciseId: exercise.id, name }, 'Exercise created');
|
|
|
|
return created(res, exercise);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /fitness/exercises error');
|
|
return error(res, 'CREATE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// =============================================================================
|
|
// CSV IMPORT
|
|
// =============================================================================
|
|
|
|
const LBS_TO_KG = 0.45359237;
|
|
|
|
/**
|
|
* Parse CSV string into rows
|
|
*/
|
|
function parseCSV(csvString) {
|
|
const lines = csvString.trim().split('\n');
|
|
if (lines.length < 2) return { headers: [], rows: [] };
|
|
|
|
const headers = lines[0].split(',').map(h => h.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'));
|
|
const rows = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const values = lines[i].split(',').map(v => v.trim());
|
|
const row = {};
|
|
headers.forEach((h, idx) => {
|
|
row[h] = values[idx] || '';
|
|
});
|
|
rows.push(row);
|
|
}
|
|
|
|
return { headers, rows };
|
|
}
|
|
|
|
/**
|
|
* Detect CSV format based on headers
|
|
*/
|
|
function detectFormat(headers) {
|
|
const headerSet = new Set(headers);
|
|
|
|
// Hevy format: title, start_time, exercise_title, weight_kg
|
|
if (headerSet.has('exercise_title') && headerSet.has('start_time')) {
|
|
return 'hevy';
|
|
}
|
|
|
|
// Strong format: Date, Workout Name, Exercise Name, Weight, Weight Unit
|
|
if (headerSet.has('workout_name') || headerSet.has('exercise_name')) {
|
|
return 'strong';
|
|
}
|
|
|
|
// Fallback to generic
|
|
return 'generic';
|
|
}
|
|
|
|
/**
|
|
* Map row to normalized workout/set data based on format
|
|
*/
|
|
function normalizeRow(row, format) {
|
|
if (format === 'hevy') {
|
|
return {
|
|
workout_title: row.title || 'Imported Workout',
|
|
started_at: row.start_time,
|
|
ended_at: row.end_time || null,
|
|
description: row.description || null,
|
|
exercise_name: row.exercise_title,
|
|
set_type: row.set_type || 'normal',
|
|
weight_kg: parseFloat(row.weight_kg) || null,
|
|
reps: parseInt(row.reps) || null,
|
|
rpe: parseFloat(row.rpe) || null,
|
|
distance_km: parseFloat(row.distance_km) || null,
|
|
duration_seconds: parseInt(row.duration_seconds) || null,
|
|
notes: row.notes || null
|
|
};
|
|
}
|
|
|
|
if (format === 'strong') {
|
|
let weight = parseFloat(row.weight) || null;
|
|
const weightUnit = (row.weight_unit || 'kg').toLowerCase();
|
|
if (weight && weightUnit === 'lbs') {
|
|
weight = weight * LBS_TO_KG;
|
|
}
|
|
|
|
return {
|
|
workout_title: row.workout_name || 'Imported Workout',
|
|
started_at: row.date,
|
|
ended_at: null,
|
|
description: row.workout_notes || null,
|
|
exercise_name: row.exercise_name,
|
|
set_type: 'normal',
|
|
set_index: parseInt(row.set_order) || 1,
|
|
weight_kg: weight,
|
|
reps: parseInt(row.reps) || null,
|
|
rpe: parseFloat(row.rpe) || null,
|
|
distance_km: parseFloat(row.distance) || null,
|
|
duration_seconds: parseInt(row.seconds) || null,
|
|
notes: row.notes || null
|
|
};
|
|
}
|
|
|
|
// Generic format - try common field names
|
|
let weight = parseFloat(row.weight_kg || row.weight) || null;
|
|
const weightUnit = (row.weight_unit || 'kg').toLowerCase();
|
|
if (weight && weightUnit === 'lbs') {
|
|
weight = weight * LBS_TO_KG;
|
|
}
|
|
|
|
return {
|
|
workout_title: row.title || row.workout_title || row.workout_name || 'Imported Workout',
|
|
started_at: row.start_time || row.date || row.started_at,
|
|
ended_at: row.end_time || row.ended_at || null,
|
|
description: row.description || null,
|
|
exercise_name: row.exercise_title || row.exercise_name || row.exercise,
|
|
set_type: row.set_type || 'normal',
|
|
weight_kg: weight,
|
|
reps: parseInt(row.reps) || null,
|
|
rpe: parseFloat(row.rpe) || null,
|
|
distance_km: parseFloat(row.distance_km || row.distance) || null,
|
|
duration_seconds: parseInt(row.duration_seconds || row.seconds) || null,
|
|
notes: row.notes || null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* POST /api/v3/fitness/import/csv
|
|
* Import workouts from CSV file
|
|
*/
|
|
router.post('/import/csv', async (req, res) => {
|
|
try {
|
|
const { space_id, format: requestedFormat } = req.query;
|
|
|
|
if (!space_id) {
|
|
return badRequest(res, 'space_id is required', 'VALIDATION_ERROR');
|
|
}
|
|
|
|
const csvData = typeof req.body === 'string' ? req.body : '';
|
|
|
|
if (!csvData.trim()) {
|
|
return badRequest(res, 'CSV data is empty', 'EMPTY_CSV');
|
|
}
|
|
|
|
const { headers, rows } = parseCSV(csvData);
|
|
const format = requestedFormat || detectFormat(headers);
|
|
|
|
// Group rows by workout (by title + started_at)
|
|
const workoutGroups = new Map();
|
|
let rowsSkipped = 0;
|
|
|
|
for (const row of rows) {
|
|
const normalized = normalizeRow(row, format);
|
|
|
|
// Skip rows without exercise name
|
|
if (!normalized.exercise_name) {
|
|
rowsSkipped++;
|
|
continue;
|
|
}
|
|
|
|
const workoutKey = `${normalized.workout_title}|${normalized.started_at}`;
|
|
|
|
if (!workoutGroups.has(workoutKey)) {
|
|
workoutGroups.set(workoutKey, {
|
|
title: normalized.workout_title,
|
|
started_at: normalized.started_at,
|
|
ended_at: normalized.ended_at,
|
|
description: normalized.description,
|
|
sets: []
|
|
});
|
|
}
|
|
|
|
workoutGroups.get(workoutKey).sets.push({
|
|
exercise_name: normalized.exercise_name,
|
|
set_type: normalized.set_type,
|
|
set_index: normalized.set_index || workoutGroups.get(workoutKey).sets.length + 1,
|
|
weight_kg: normalized.weight_kg,
|
|
reps: normalized.reps,
|
|
rpe: normalized.rpe,
|
|
distance_km: normalized.distance_km,
|
|
duration_seconds: normalized.duration_seconds,
|
|
notes: normalized.notes
|
|
});
|
|
}
|
|
|
|
// Insert workouts and sets
|
|
let workoutsCreated = 0;
|
|
let setsCreated = 0;
|
|
|
|
for (const [, workout] of workoutGroups) {
|
|
// Create workout
|
|
const workoutResult = await dbRun(`
|
|
INSERT INTO fitness_workouts (space_id, title, description, started_at, ended_at, source, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
|
`, [space_id, workout.title, workout.description, workout.started_at, workout.ended_at, `csv_${format}`]);
|
|
|
|
const workoutId = workoutResult.lastInsertRowid;
|
|
workoutsCreated++;
|
|
|
|
// Create sets
|
|
for (const set of workout.sets) {
|
|
await dbRun(`
|
|
INSERT INTO fitness_sets (workout_id, exercise_name, set_index, set_type, weight_kg, reps, rpe, distance_km, duration_seconds, notes, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()})
|
|
`, [workoutId, set.exercise_name, set.set_index, set.set_type, set.weight_kg, set.reps, set.rpe, set.distance_km, set.duration_seconds, set.notes]);
|
|
setsCreated++;
|
|
}
|
|
}
|
|
|
|
apiLogger.info({ spaceId: space_id, workoutsCreated, setsCreated, format }, 'CSV import completed');
|
|
|
|
return success(res, {
|
|
format_detected: format,
|
|
workouts_created: workoutsCreated,
|
|
sets_created: setsCreated,
|
|
rows_skipped: rowsSkipped
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /fitness/import/csv error');
|
|
return error(res, 'IMPORT_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|