Prepare secure config and reliable workout sync

This commit is contained in:
Hamza-Ayed
2026-10-04 00:33:44 +03:00
parent 05f1c9ec6b
commit e7f3c777d1
17 changed files with 553 additions and 119 deletions
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* Reads server-side application configuration without bundling secrets.
* The deploy layout places a .env symlink beside backend/, outside public/.
*/
final class AppConfig
{
private static $loaded = false;
public static function loadEnvironment()
{
if (self::$loaded) {
return;
}
$path = getenv('APP_ENV_FILE');
if ($path === false || $path === '') {
$path = dirname(__DIR__) . DIRECTORY_SEPARATOR . '.env';
}
if (!is_file($path) || !is_readable($path)) {
throw new RuntimeException('Server environment file is missing or unreadable');
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
throw new RuntimeException('Unable to read server environment file');
}
foreach ($lines as $lineNumber => $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (!preg_match('/^([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/', $line, $matches)) {
throw new RuntimeException('Invalid environment entry on line ' . ($lineNumber + 1));
}
$name = $matches[1];
$value = trim($matches[2]);
if (strlen($value) >= 2) {
$first = $value[0];
$last = substr($value, -1);
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
$value = substr($value, 1, -1);
}
}
// Explicit process environment values override the file.
if (getenv($name) === false) {
putenv($name . '=' . $value);
$_ENV[$name] = $value;
}
}
self::$loaded = true;
}
public static function required($name)
{
self::loadEnvironment();
$value = getenv($name);
if ($value === false || $value === '') {
throw new RuntimeException('Required server setting is missing: ' . $name);
}
return $value;
}
public static function integer($name, $default)
{
self::loadEnvironment();
$value = getenv($name);
if ($value === false || $value === '') {
return (int) $default;
}
$parsed = filter_var($value, FILTER_VALIDATE_INT);
if ($parsed === false) {
throw new RuntimeException('Server setting must be an integer: ' . $name);
}
return $parsed;
}
}
+13 -25
View File
@@ -8,35 +8,23 @@ class Database {
private static $instance = null;
private $connection;
private $db_host = 'localhost';
private $db_user = 'fitness_app_user';
private $db_pass = 'your_secure_password_here';
private $db_name = 'fitness_app';
private $db_port = 3306;
private function __construct() {
try {
$this->connection = new mysqli(
$this->db_host,
$this->db_user,
$this->db_pass,
$this->db_name,
$this->db_port
);
// Check connection
if ($this->connection->connect_error) {
throw new Exception('Database connection failed: ' . $this->connection->connect_error);
}
// Set charset
$this->connection->set_charset('utf8mb4');
// Enable error reporting
require_once __DIR__ . '/Config.php';
AppConfig::loadEnvironment();
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
} catch (Exception $e) {
error_log('Database Error: ' . $e->getMessage());
$this->connection = new mysqli(
AppConfig::required('DB_HOST'),
AppConfig::required('DB_USERNAME'),
AppConfig::required('DB_PASSWORD'),
AppConfig::required('DB_DATABASE'),
AppConfig::integer('DB_PORT', 3306)
);
$this->connection->set_charset('utf8mb4');
$this->connection->query("SET time_zone = '+00:00'");
} catch (Throwable $e) {
error_log('Database initialization failed: ' . $e->getMessage());
http_response_code(500);
die(json_encode(['error' => 'Database connection failed']));
}
+5
View File
@@ -32,6 +32,11 @@ class WorkoutValidator {
// Validate data types and values
$this->validateWorkoutType($payload['workout_type']);
if (isset($payload['client_workout_id']) &&
(!is_string($payload['client_workout_id']) ||
preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $payload['client_workout_id']) !== 1)) {
$this->errors[] = 'client_workout_id must be a UUID';
}
$this->validateNumericField('distance_meters', $payload['distance_meters'], self::MIN_DISTANCE, self::MAX_DISTANCE);
$this->validateNumericField('duration_seconds', $payload['duration_seconds'], self::MIN_DURATION, self::MAX_DURATION);
$this->validateNumericField('elevation_gain_meters', $payload['elevation_gain_meters'], 0, 10000);
+60 -31
View File
@@ -85,13 +85,14 @@ try {
// Prepare workout insert statement
$stmt = $db->prepare('
INSERT INTO workouts (
workout_uuid, user_id, workout_type, distance_meters,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
');
// Extract start and end coordinates
@@ -102,12 +103,16 @@ try {
$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(
'sisissiiiddidddssdssi',
'sissiiiidddsiddddsssdsi',
$workout_uuid,
$user_id,
$client_workout_uuid,
$payload['workout_type'],
$payload['distance_meters'],
$payload['duration_seconds'],
@@ -122,8 +127,8 @@ try {
$start_coord['lng'],
$end_coord['lat'],
$end_coord['lng'],
$payload['start_time'],
$payload['end_time'],
$start_time_utc,
$end_time_utc,
$payload['weather_condition'],
$payload['temperature_celsius'],
$payload['notes'],
@@ -134,11 +139,25 @@ try {
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 (!empty($payload['segments'])) {
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 (?, ?, ?, ?, ?, ?)
@@ -168,45 +187,49 @@ try {
}
// Update or create user stats cache
updateUserStatsCache($db, $user_id);
if ($was_inserted) {
updateUserStatsCache($db, $user_id);
}
// Log successful submission
$logStmt = $db->prepare('
INSERT INTO api_logs (user_id, endpoint, method, status_code, ip_address, user_agent, response_time_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
');
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);
$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();
$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(201);
http_response_code($was_inserted ? 201 : 200);
echo json_encode([
'status' => 'success',
'data' => [
'workout_id' => $workout_id,
'workout_uuid' => $workout_uuid,
'message' => 'Workout submitted successfully',
'message' => $was_inserted ? 'Workout submitted successfully' : 'Workout was already received',
'timestamp' => date('c')
]
]);
@@ -270,6 +293,12 @@ function generateUUID() {
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
*/
@@ -0,0 +1,99 @@
-- SportPath phone authentication and per-device session records.
-- Apply once to a backed-up database after reviewing the live schema.
-- This migration keeps existing HMAC credentials nullable during the transition.
ALTER TABLE users
MODIFY username VARCHAR(50) NULL,
MODIFY email VARCHAR(100) NULL,
MODIFY password_hash VARCHAR(255) NULL,
MODIFY api_key VARCHAR(64) NULL,
MODIFY api_secret VARCHAR(64) NULL,
ADD COLUMN phone_e164 VARCHAR(16) NULL AFTER uuid,
ADD COLUMN phone_verified_at DATETIME NULL AFTER phone_e164,
ADD COLUMN account_role ENUM('member', 'owner', 'content_manager', 'support') NOT NULL DEFAULT 'member',
ADD UNIQUE KEY uq_users_phone_e164 (phone_e164);
CREATE TABLE app_settings (
setting_key VARCHAR(100) PRIMARY KEY,
setting_value JSON NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
revision BIGINT UNSIGNED NOT NULL DEFAULT 1,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_settings_public (is_public, setting_key),
CONSTRAINT fk_settings_editor FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE app_setting_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) NOT NULL,
previous_value JSON NULL,
new_value JSON NOT NULL,
actor_user_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_settings_audit_key_time (setting_key, created_at),
INDEX idx_settings_audit_actor_time (actor_user_id, created_at),
CONSTRAINT fk_settings_audit_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE otp_challenges (
challenge_uuid CHAR(36) PRIMARY KEY,
phone_e164 VARCHAR(16) NOT NULL,
purpose ENUM('register', 'login', 'change_phone') NOT NULL,
code_digest CHAR(64) NOT NULL COMMENT 'HMAC-SHA256 with a server-only OTP pepper',
attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 0,
max_attempts TINYINT UNSIGNED NOT NULL DEFAULT 5,
request_ip_digest CHAR(64) NULL COMMENT 'Keyed digest; raw IP is not persisted here',
device_uuid CHAR(36) NULL,
expires_at DATETIME NOT NULL,
consumed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_otp_phone_created (phone_e164, created_at),
INDEX idx_otp_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE auth_rate_limit_buckets (
bucket_digest CHAR(64) PRIMARY KEY COMMENT 'HMAC digest of a phone, IP, or device bucket',
bucket_type ENUM('phone', 'ip', 'device') NOT NULL,
window_started_at DATETIME NOT NULL,
request_count INT UNSIGNED NOT NULL DEFAULT 0,
blocked_until DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_rate_bucket_expiry (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE user_devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
device_uuid CHAR(36) NOT NULL COMMENT 'Random app-install ID; not a hardware serial',
platform ENUM('ios', 'android', 'web') NOT NULL,
public_key TEXT NULL COMMENT 'Public half of the device key; private key stays on device',
key_fingerprint CHAR(64) NULL,
key_algorithm VARCHAR(32) NULL,
display_name VARCHAR(80) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NULL,
revoked_at DATETIME NULL,
UNIQUE KEY uq_device_user_uuid (user_id, device_uuid),
UNIQUE KEY uq_device_key_fingerprint (key_fingerprint),
INDEX idx_devices_user_active (user_id, revoked_at),
CONSTRAINT fk_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE auth_sessions (
session_uuid CHAR(36) PRIMARY KEY,
family_uuid CHAR(36) NOT NULL COMMENT 'Allows revoking a rotated refresh-token family',
user_id INT NOT NULL,
device_uuid CHAR(36) NULL,
refresh_token_digest CHAR(64) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
replaced_by CHAR(36) NULL,
INDEX idx_sessions_user_active (user_id, revoked_at, expires_at),
INDEX idx_sessions_family (family_uuid),
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_sessions_device FOREIGN KEY (user_id, device_uuid)
REFERENCES user_devices(user_id, device_uuid) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,7 @@
-- Preserve offline workout identity across network retries.
-- Apply once to an existing database created from the original schema.sql.
-- Fresh databases created from the updated schema.sql already contain this column.
ALTER TABLE workouts
ADD COLUMN client_workout_uuid CHAR(36) NULL AFTER user_id,
ADD UNIQUE KEY uq_user_client_workout (user_id, client_workout_uuid);
+96 -6
View File
@@ -10,14 +10,17 @@ USE fitness_app;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the user',
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL COMMENT 'bcrypt hash',
api_key VARCHAR(64) UNIQUE NOT NULL COMMENT 'API key for client authentication',
api_secret VARCHAR(64) NOT NULL COMMENT 'Secret for HMAC signature',
phone_e164 VARCHAR(16) UNIQUE COMMENT 'Verified phone number in E.164 format',
phone_verified_at DATETIME NULL,
username VARCHAR(50) UNIQUE,
email VARCHAR(100) UNIQUE,
password_hash VARCHAR(255) NULL COMMENT 'Optional legacy password hash',
api_key VARCHAR(64) UNIQUE COMMENT 'Optional legacy API key',
api_secret VARCHAR(64) NULL COMMENT 'Optional legacy HMAC secret',
full_name VARCHAR(100),
avatar_url VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
account_role ENUM('member', 'owner', 'content_manager', 'support') NOT NULL DEFAULT 'member',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_uuid (uuid),
@@ -25,11 +28,97 @@ CREATE TABLE IF NOT EXISTS users (
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE app_settings (
setting_key VARCHAR(100) PRIMARY KEY,
setting_value JSON NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
revision BIGINT UNSIGNED NOT NULL DEFAULT 1,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_settings_public (is_public, setting_key),
CONSTRAINT fk_settings_editor FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE app_setting_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) NOT NULL,
previous_value JSON NULL,
new_value JSON NOT NULL,
actor_user_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_settings_audit_key_time (setting_key, created_at),
INDEX idx_settings_audit_actor_time (actor_user_id, created_at),
CONSTRAINT fk_settings_audit_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Phone OTP challenges contain keyed digests, never the plaintext code.
CREATE TABLE otp_challenges (
challenge_uuid CHAR(36) PRIMARY KEY,
phone_e164 VARCHAR(16) NOT NULL,
purpose ENUM('register', 'login', 'change_phone') NOT NULL,
code_digest CHAR(64) NOT NULL,
attempt_count TINYINT UNSIGNED NOT NULL DEFAULT 0,
max_attempts TINYINT UNSIGNED NOT NULL DEFAULT 5,
request_ip_digest CHAR(64) NULL,
device_uuid CHAR(36) NULL,
expires_at DATETIME NOT NULL,
consumed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_otp_phone_created (phone_e164, created_at),
INDEX idx_otp_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE auth_rate_limit_buckets (
bucket_digest CHAR(64) PRIMARY KEY,
bucket_type ENUM('phone', 'ip', 'device') NOT NULL,
window_started_at DATETIME NOT NULL,
request_count INT UNSIGNED NOT NULL DEFAULT 0,
blocked_until DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_rate_bucket_expiry (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE user_devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
device_uuid CHAR(36) NOT NULL,
platform ENUM('ios', 'android', 'web') NOT NULL,
public_key TEXT NULL,
key_fingerprint CHAR(64) NULL UNIQUE,
key_algorithm VARCHAR(32) NULL,
display_name VARCHAR(80) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NULL,
revoked_at DATETIME NULL,
UNIQUE KEY uq_device_user_uuid (user_id, device_uuid),
INDEX idx_devices_user_active (user_id, revoked_at),
CONSTRAINT fk_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE auth_sessions (
session_uuid CHAR(36) PRIMARY KEY,
family_uuid CHAR(36) NOT NULL,
user_id INT NOT NULL,
device_uuid CHAR(36) NULL,
refresh_token_digest CHAR(64) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
replaced_by CHAR(36) NULL,
INDEX idx_sessions_user_active (user_id, revoked_at, expires_at),
INDEX idx_sessions_family (family_uuid),
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_sessions_device FOREIGN KEY (user_id, device_uuid)
REFERENCES user_devices(user_id, device_uuid) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Workouts Table
CREATE TABLE IF NOT EXISTS workouts (
id INT AUTO_INCREMENT PRIMARY KEY,
workout_uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the workout',
user_id INT NOT NULL,
client_workout_uuid CHAR(36) NULL COMMENT 'Client-generated idempotency key',
workout_type ENUM('running', 'walking') NOT NULL,
distance_meters INT NOT NULL COMMENT 'Total distance in meters',
duration_seconds INT NOT NULL COMMENT 'Total duration in seconds',
@@ -59,7 +148,8 @@ CREATE TABLE IF NOT EXISTS workouts (
INDEX idx_workout_type (workout_type),
INDEX idx_synced_at (synced_at),
INDEX idx_created_at (created_at),
INDEX idx_user_created (user_id, created_at)
INDEX idx_user_created (user_id, created_at),
UNIQUE KEY uq_user_client_workout (user_id, client_workout_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Workout Segments Table (for detailed route tracking if needed)