'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(); } ?>