Prepare secure config and reliable workout sync
This commit is contained in:
@@ -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
@@ -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']));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user