Files
fitness/backend/api_workouts.php
T

341 lines
12 KiB
PHP

<?php
/**
* Workout Submission API Endpoint
* POST /api/v1/workouts
*
* Receives completed workout data from mobile client
* Validates HMAC signature, processes polyline data, and stores in database
*/
header('Content-Type: application/json');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key, X-Signature, X-Timestamp');
// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit(json_encode(['status' => 'ok']));
}
// Only allow POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
die(json_encode(['error' => 'Method not allowed']));
}
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/AuthenticationHandler.php';
require_once __DIR__ . '/JwtToken.php';
require_once __DIR__ . '/ApiAuth.php';
require_once __DIR__ . '/PolylineUtility.php';
require_once __DIR__ . '/WorkoutValidator.php';
require_once dirname(__DIR__) . '/public/api/v1/_bootstrap.php';
try {
// Get raw request body for signature verification
$rawBody = file_get_contents('php://input');
if (empty($rawBody)) {
throw new Exception('Empty request body', 400);
}
$db = Database::getInstance();
if (isset($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Bearer\s+/i', $_SERVER['HTTP_AUTHORIZATION'])) {
$user_id = ApiAuth::bearerClaims()['user_id'];
} else {
AppConfig::loadEnvironment();
if (getenv('LEGACY_HMAC_ENABLED') !== 'true') {
throw new Exception('Bearer authentication required', 401);
}
$api_key = getHeader('X-API-Key');
$signature = getHeader('X-Signature');
$timestamp = getHeader('X-Timestamp');
if (!$api_key || !$signature || !$timestamp) {
throw new Exception('Missing required authentication headers', 401);
}
$auth = new AuthenticationHandler();
$authResult = $auth->validateHmacSignature($api_key, $signature, $rawBody, $timestamp);
if (!$authResult['valid']) {
throw new Exception($authResult['error'], 401);
}
$user_id = $authResult['user_id'];
}
// Parse and validate JSON payload
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
throw new Exception('Invalid JSON payload', 400);
}
// Validate workout data
$validator = new WorkoutValidator();
$validation = $validator->validate($payload);
if (!$validation['valid']) {
throw new Exception('Validation failed: ' . implode(', ', $validation['errors']), 422);
}
// Decode and validate polyline
$coordinates = PolylineUtility::decodePolyline($payload['route_polyline']);
if (empty($coordinates)) {
throw new Exception('Invalid or empty polyline', 400);
}
// Begin transaction
$db->beginTransaction();
try {
// Generate UUID for workout
$workout_uuid = generateUUID();
// Prepare workout insert statement
$stmt = $db->prepare('
INSERT INTO workouts (
workout_uuid, user_id, client_workout_uuid, workout_type, distance_meters,
duration_seconds, elevation_gain_meters, elevation_loss_meters,
calories_burned, average_pace_mps, max_speed_mps,
route_polyline, coordinate_count, start_lat, start_lng,
end_lat, end_lng, start_time, end_time, weather_condition,
temperature_celsius, notes, is_public
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
');
// Extract start and end coordinates
$start_coord = $coordinates[0];
$end_coord = $coordinates[count($coordinates) - 1];
// Calculate average pace
$average_pace = $payload['duration_seconds'] > 0
? $payload['distance_meters'] / $payload['duration_seconds']
: 0;
$client_workout_uuid = $payload['client_workout_id'] ?? null;
$start_time_utc = normalizeUtcDateTime($payload['start_time']);
$end_time_utc = normalizeUtcDateTime($payload['end_time']);
// Bind parameters
$stmt->bind_param(
'sissiiiidddsiddddsssdsi',
$workout_uuid,
$user_id,
$client_workout_uuid,
$payload['workout_type'],
$payload['distance_meters'],
$payload['duration_seconds'],
$payload['elevation_gain_meters'],
$payload['elevation_loss_meters'],
$payload['calories_burned'],
$average_pace,
$payload['max_speed_mps'],
$payload['route_polyline'],
$coordinates_count = count($coordinates),
$start_coord['lat'],
$start_coord['lng'],
$end_coord['lat'],
$end_coord['lng'],
$start_time_utc,
$end_time_utc,
$payload['weather_condition'],
$payload['temperature_celsius'],
$payload['notes'],
$payload['is_public']
);
if (!$stmt->execute()) {
throw new Exception('Failed to insert workout: ' . $stmt->error, 500);
}
$was_inserted = $stmt->affected_rows === 1;
$workout_id = $db->getLastInsertId();
$stmt->close();
if (!$was_inserted) {
$existing_stmt = $db->prepare('SELECT workout_uuid FROM workouts WHERE id = ? AND user_id = ?');
$existing_stmt->bind_param('ii', $workout_id, $user_id);
$existing_stmt->execute();
$existing_result = $existing_stmt->get_result();
$existing_workout = $existing_result->fetch_assoc();
$existing_stmt->close();
if (!$existing_workout) {
throw new Exception('Unable to confirm idempotent workout submission', 500);
}
$workout_uuid = $existing_workout['workout_uuid'];
}
// Process and store segments if provided
if ($was_inserted && !empty($payload['segments'])) {
$segment_stmt = $db->prepare('
INSERT INTO workout_segments (workout_id, segment_order, duration_seconds, distance_meters, average_pace_mps, index_in_polyline)
VALUES (?, ?, ?, ?, ?, ?)
');
foreach ($payload['segments'] as $index => $segment) {
$segment_order = $index;
$segment_pace = $segment['duration_seconds'] > 0
? $segment['distance_meters'] / $segment['duration_seconds']
: 0;
$segment_stmt->bind_param(
'iiiddi',
$workout_id,
$segment_order,
$segment['duration_seconds'],
$segment['distance_meters'],
$segment_pace,
$segment['index_in_polyline']
);
if (!$segment_stmt->execute()) {
throw new Exception('Failed to insert segment', 500);
}
}
$segment_stmt->close();
}
// Update or create user stats cache
if ($was_inserted) {
updateUserStatsCache($db, $user_id);
}
// Log successful submission
if ($was_inserted) {
$logStmt = $db->prepare('
INSERT INTO api_logs (user_id, endpoint, method, status_code, ip_address, user_agent, response_time_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
');
$endpoint = '/api/v1/workouts';
$method = 'POST';
$status = 201;
$ip = getClientIpAddress();
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
$response_time = (int)((microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1000);
$logStmt->bind_param(
'ississi',
$user_id,
$endpoint,
$method,
$status,
$ip,
$user_agent,
$response_time
);
$logStmt->execute();
$logStmt->close();
}
// Commit transaction
$db->commit();
// Return success response
http_response_code($was_inserted ? 201 : 200);
echo json_encode([
'status' => 'success',
'data' => [
'workout_id' => $workout_id,
'workout_uuid' => $workout_uuid,
'message' => $was_inserted ? 'Workout submitted successfully' : 'Workout was already received',
'timestamp' => date('c')
]
]);
} catch (Exception $e) {
// Rollback on error
$db->rollback();
throw $e;
}
} catch (Exception $e) {
// Determine HTTP status code
$status_code = intval($e->getCode());
if ($status_code < 100 || $status_code >= 600) {
$status_code = 500;
}
http_response_code($status_code);
echo json_encode([
'status' => 'error',
'error' => $e->getMessage(),
'timestamp' => date('c')
]);
} finally {
// Ensure database connection is closed
if (isset($db)) {
$db->close();
}
}
/**
* Helper function to get HTTP header value
*/
function getHeader($header_name) {
$header_key = 'HTTP_' . strtoupper(str_replace('-', '_', $header_name));
return $_SERVER[$header_key] ?? null;
}
/**
* Get client IP address
*/
function getClientIpAddress() {
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
}
/**
* Generate UUID v4
*/
function generateUUID() {
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
/** Store ISO-8601 client timestamps as UTC MySQL DATETIME values. */
function normalizeUtcDateTime($value) {
$date = new DateTimeImmutable($value);
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
}
/**
* Update user stats cache
*/
function updateUserStatsCache($db, $user_id) {
$stmt = $db->prepare('
INSERT INTO user_stats_cache (
user_id, total_workouts, total_distance_meters,
total_duration_seconds, total_calories_burned, average_pace_mps, last_workout_date
)
SELECT
? as user_id,
COUNT(*) as total_workouts,
COALESCE(SUM(distance_meters), 0) as total_distance,
COALESCE(SUM(duration_seconds), 0) as total_duration,
COALESCE(SUM(calories_burned), 0) as total_calories,
COALESCE(AVG(average_pace_mps), 0) as avg_pace,
MAX(created_at) as last_workout
FROM workouts
WHERE user_id = ?
ON DUPLICATE KEY UPDATE
total_workouts = VALUES(total_workouts),
total_distance_meters = VALUES(total_distance_meters),
total_duration_seconds = VALUES(total_duration_seconds),
total_calories_burned = VALUES(total_calories_burned),
average_pace_mps = VALUES(average_pace_mps),
last_workout_date = VALUES(last_workout_date),
cached_at = NOW()
');
$stmt->bind_param('ii', $user_id, $user_id);
$stmt->execute();
$stmt->close();
}
?>