diff --git a/.env.example b/.env.example index 995cdf2..2e7f263 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,7 @@ OTP_API_URL= OTP_API_KEY= OTP_SENDER_ID= OTP_ENABLED=false +OTP_HASH_KEY= # Food image analysis provider. Keep credentials on the server. FOOD_AI_PROVIDER= diff --git a/FITNESS_PRODUCT_PLAN_AR.md b/FITNESS_PRODUCT_PLAN_AR.md index b8d5ee4..f3a1530 100644 --- a/FITNESS_PRODUCT_PLAN_AR.md +++ b/FITNESS_PRODUCT_PLAN_AR.md @@ -154,7 +154,7 @@ - `backend/api_workouts.php`: يولّد UUID جديدًا لكل POST؛ إعادة إرسال نفس الجلسة لا تظهر محمية بمعرف ثابت من العميل. المطلوب UUID ثابت وقيد فريد للمستخدم/الجلسة، ونتيجة ناجحة متسقة عند الإعادة. - `mobile/lib/services/database_service.dart`: يستخدم replace لحفظ التمرين، وحفظ المقاطع وطابور المزامنة منفصل؛ نراجع الحفظ بمعاملة واحدة والتحديث دون حذف ضمني للعلاقات. - وجود FOREIGN KEY محليًا دون onConfigure ظاهر لتفعيلها؛ نفعّلها ونختبر سلوك العلاقات والترحيل. لا نغيّر سياسة الحذف قبل اختبار البيانات الموجودة. -- طابور المزامنة يتوقف عن إعادة المحاولة بعد خمس إخفاقات؛ نضيف حالة واضحة للمستخدم وإعادة محاولة، وتأخيرًا متدرجًا وتصنيف الأخطاء. +- طابور المزامنة يتوقف عن الإرسال التلقائي بعد خمس إخفاقات؛ نعرض عدد كل السجلات غير المرفوعة، ونوفّر إعادة محاولة يدوية وتأخيرًا متدرجًا وتصنيفًا للأخطاء. - تقرير الأسابيع يستخدم معدل تاريخ SQLite بصيغة `weeks`؛ نراجع حدود الفترة ووحدات التاريخ المدعومة، ونوحد UTC مع اليوم المحلي عند العرض. - صفحة المنزل تعرض «آخر تمرين» بقيم ثابتة. README يصف أجزاء قديمة؛ الكود الفعلي هو مرجع التوسعة. - مراجعة GPS ووحدات الزمن والسرعة والسعرات، ثم اختبار مسار معروف وثبات الهاتف قبل الحكم على دقة التتبع. diff --git a/IMPLEMENTATION_ROADMAP_AR.md b/IMPLEMENTATION_ROADMAP_AR.md index 04afd10..2ed8c71 100644 --- a/IMPLEMENTATION_ROADMAP_AR.md +++ b/IMPLEMENTATION_ROADMAP_AR.md @@ -44,6 +44,10 @@ لوحة الإدارة خلف تسجيل دخول وصلاحية دورية. الأدوار المقترحة: مالك، مدير محتوى، دعم، ومستخدم. عمليات تعديل البرامج والمحتوى والإعدادات تسجل في audit log؛ إعدادات الخطة المنشورة ذات نسخة ثابتة حتى لا تتغير جلسات سابقة. +بدأ التنفيذ محليًا: `backend/Config.php` يقرأ environment من ملف خارج `public/` أو من مسار `APP_ENV_FILE`، و`backend/Database.php` يأخذ بيانات MySQL من البيئة بدل القيم الثابتة. مخطط التثبيت الجديد يتضمن الهاتف والجلسات والأجهزة، وتوجد ترحيلات `backend/migrations/001_phone_auth_and_sessions.sql` و`002_workout_idempotency.sql` لقواعد البيانات القديمة. لم تُطبق الترحيلات على قاعدة فعلية؛ يلزم أخذ نسخة احتياطية ومراجعة مخطط الخادم قبل تطبيقها. + +عالجت بدايةً الحفظ المحلي: قاعدة SQLite الآن تجهز المفاتيح الأجنبية، وحفظ جلسة GPS ومقاطعها ورسالة outbox يتم في معاملة واحدة. يحمل طلب الرفع معرّف الجلسة الذي أنشأه الهاتف، والخادم يعيد سجل الجلسة الموجود عند تكرار الطلب بعد إضافة migration التوافق. ما زالت مزامنة API تعتمد HMAC القديم إلى أن تكتمل جلسات OTP/JWT؛ هذا الإصلاح لا يغيّر المصادقة الحالية. + ## تصميم الجلسات وJWT - هوية الحساب هي `user_id` الداخلي؛ رقم الهاتف وسيلة دخول قابلة للتغيير. @@ -106,6 +110,8 @@ FOOD_AI_API_KEY= UPLOAD_PRIVATE_PATH= ``` +تم إنشاء repository محليًا على `main` ودفع المحتوى الحالي إلى `origin/main`. إعداد النشر لا يعمل على خادم بعد؛ لا توجد حاليًا بيانات host/site user أو مجلد public صالح. + القيم أعلاه أسماء توضيحية. مزود الرسائل وموفر الذكاء الاصطناعي واسم النطاق والسياسات الرقمية لم تُحسم بعد، لذا تبقى حقولها فارغة ولا يُستخدم المثال كإعداد إنتاج. ## نشر Git إلى CloudPanel والمنفذ 2101 diff --git a/backend/Config.php b/backend/Config.php new file mode 100644 index 0000000..92e235a --- /dev/null +++ b/backend/Config.php @@ -0,0 +1,84 @@ + $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; + } +} diff --git a/backend/Database.php b/backend/Database.php index 6310478..76f9d33 100644 --- a/backend/Database.php +++ b/backend/Database.php @@ -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'])); } diff --git a/backend/WorkoutValidator.php b/backend/WorkoutValidator.php index f928225..33c2f97 100644 --- a/backend/WorkoutValidator.php +++ b/backend/WorkoutValidator.php @@ -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); diff --git a/backend/api_workouts.php b/backend/api_workouts.php index 1b49428..a0be143 100644 --- a/backend/api_workouts.php +++ b/backend/api_workouts.php @@ -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 */ diff --git a/backend/migrations/001_phone_auth_and_sessions.sql b/backend/migrations/001_phone_auth_and_sessions.sql new file mode 100644 index 0000000..36b3080 --- /dev/null +++ b/backend/migrations/001_phone_auth_and_sessions.sql @@ -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; diff --git a/backend/migrations/002_workout_idempotency.sql b/backend/migrations/002_workout_idempotency.sql new file mode 100644 index 0000000..e26c81e --- /dev/null +++ b/backend/migrations/002_workout_idempotency.sql @@ -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); diff --git a/backend/schema.sql b/backend/schema.sql index 3828efc..3a28b5f 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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) diff --git a/deploy/README.md b/deploy/README.md index 0e06fea..b2c2064 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -40,12 +40,12 @@ DEPLOY_DRY_RUN=1 bash deploy/sync-to-server.sh bash deploy/sync-to-server.sh ``` -السكريبت يرفض شجرة عمل غير نظيفة، ويتأكد أن `HEAD` يطابق آخر commit منشور للفرع نفسه في Git. يستخدم SSH بتهيئة غير تفاعلية، ثم يجلب ذلك الفرع على الخادم، يستخرج commit إلى مجلد إصدار منفصل، ويفحص بناء PHP نحويًا إن كان PHP CLI متوفرًا. أخيرًا يحول رابط `current` إلى الإصدار الجديد. +السكريبت يرفض شجرة عمل غير نظيفة، ويتأكد أن `HEAD` يطابق آخر commit منشور للفرع نفسه في Git. يستخدم SSH بتهيئة غير تفاعلية، ثم يجلب ذلك الفرع على الخادم، يستخرج commit إلى مجلد مؤقت، ويفحص بناء PHP نحويًا إن كان PHP CLI متوفرًا. عند نجاح التحقق ينقل النسخة إلى مجلد الإصدار ويحوّل رابط `current` إليها. الفشل قبل ذلك يزيل مجلد الاستخراج المؤقت ويترك النسخة الحالية كما هي. أول تشغيل يحتاج مفتاح SSH صالحًا للوصول إلى Site User عبر المنفذ 2101 ومفتاح Git صالحًا على الخادم. مفاتيح SSH لا تمرر كمتغير ولا تحفظ في هذا المستودع. ## الاسترجاع -كل إصدار محفوظ تحت `/releases/`. عند الحاجة، أنشئ رابطًا مؤقتًا إلى مجلد الإصدار السابق ثم استبدل `current` ذريًا من جلسة SSH الخاصة بـSite User. لا تحذف مجلدات الإصدار السابقة أثناء فترة المراجعة. استرجاع ملفات التطبيق لا يسترجع قاعدة البيانات؛ migrations تحتاج سياسة رجوع منفصلة ونسخة احتياطية مختبرة. +كل إصدار محفوظ تحت `/releases/`. إعادة نشر commit محفوظ تعيد تفعيل نفس الإصدار، ما يسمح بالاسترجاع إلى نسخة سابقة. لا تحذف مجلدات الإصدارات أثناء فترة المراجعة. استرجاع ملفات التطبيق لا يسترجع قاعدة البيانات؛ migrations تحتاج سياسة رجوع منفصلة ونسخة احتياطية مختبرة. لا ينفذ السكربت أوامر SQL أو يغيّر صلاحيات قاعدة البيانات أو ينشئ مستخدمين. بعد إضافة migrations، تُدار كخطوة إصدار مراجعة ومختبرة، ولا تُنفّذ تلقائيًا من آلية النشر قبل تحديد سياسة التراجع. diff --git a/deploy/sync-to-server.sh b/deploy/sync-to-server.sh index 922d391..07732b4 100755 --- a/deploy/sync-to-server.sh +++ b/deploy/sync-to-server.sh @@ -71,33 +71,52 @@ release_dir="$releases_dir/$expected_sha" mkdir -p "$deploy_dir" "$releases_dir" "$shared_dir" chmod 700 "$deploy_dir" "$shared_dir" -[[ -f "$shared_dir/.env" ]] || die "missing $shared_dir/.env; create it privately before the first deploy" +[[ -f "$shared_dir/.env" && ! -L "$shared_dir/.env" ]] || die "missing private regular file $shared_dir/.env; create it before the first deploy" chmod 600 "$shared_dir/.env" if [[ ! -d "$repo_dir" ]]; then git init --bare --quiet "$repo_dir" git --git-dir="$repo_dir" remote add origin "$repo_url" +else + configured_remote="$(git --git-dir="$repo_dir" remote get-url origin 2>/dev/null || true)" + if [[ -z "$configured_remote" ]]; then + git --git-dir="$repo_dir" remote add origin "$repo_url" + elif [[ "$configured_remote" != "$repo_url" ]]; then + die 'server-side repository origin does not match the local origin' + fi fi git --git-dir="$repo_dir" fetch --quiet --no-tags origin \ "+refs/heads/$ref:refs/remotes/origin/$ref" actual_sha="$(git --git-dir="$repo_dir" rev-parse "refs/remotes/origin/$ref")" [[ "$actual_sha" == "$expected_sha" ]] || die 'origin moved during deploy; rerun using the new pushed commit' -[[ ! -e "$release_dir" ]] || die "release already exists: $release_dir" -mkdir "$release_dir" -git --git-dir="$repo_dir" archive "$actual_sha" | tar -x -C "$release_dir" -[[ -d "$release_dir/public" ]] || die 'release has no public/ web root; configure the app layout before deployment' +if [[ -e "$release_dir" ]]; then + [[ -d "$release_dir/public" ]] || die "existing release is incomplete: $release_dir" + [[ -L "$release_dir/.env" ]] || die "existing release has no private environment link: $release_dir" + [[ "$(readlink -f "$release_dir/.env")" == "$(readlink -f "$shared_dir/.env")" ]] || die 'existing release points to a different environment file' +else + staging_dir="$(mktemp -d "$releases_dir/.staging-$expected_sha.XXXXXX")" + trap 'rm -rf -- "$staging_dir"' EXIT + git --git-dir="$repo_dir" archive "$actual_sha" | tar -x -C "$staging_dir" + [[ -d "$staging_dir/public" ]] || die 'release has no public/ web root; configure the app layout before deployment' + [[ ! -e "$staging_dir/.env" && ! -L "$staging_dir/.env" ]] || die 'release must not contain a tracked .env file' + ln -s "$shared_dir/.env" "$staging_dir/.env" -if command -v php >/dev/null 2>&1 && [[ -d "$release_dir/backend" ]]; then - while IFS= read -r -d '' php_file; do - php -l "$php_file" >/dev/null || die "PHP syntax check failed: $php_file" - done < <(find "$release_dir/backend" -type f -name '*.php' -print0) + if command -v php >/dev/null 2>&1 && [[ -d "$staging_dir/backend" ]]; then + while IFS= read -r -d '' php_file; do + php -l "$php_file" >/dev/null || die "PHP syntax check failed: $php_file" + done < <(find "$staging_dir/backend" -type f -name '*.php' -print0) + fi + + mv "$staging_dir" "$release_dir" + trap - EXIT fi printf '%s\n' "$domain" > "$shared_dir/.site-domain" -ln -s "$release_dir" "$root/current.next" -mv -Tf "$root/current.next" "$root/current" +next_link="$root/.current-$expected_sha-$$" +ln -s "$release_dir" "$next_link" +mv -Tf "$next_link" "$root/current" printf 'Published %s to %s\n' "$actual_sha" "$root/current" printf 'CloudPanel document root must point to: %s/current/public\n' "$root" diff --git a/mobile/lib/controllers/workout_controller.dart b/mobile/lib/controllers/workout_controller.dart index 3392e82..d62451f 100644 --- a/mobile/lib/controllers/workout_controller.dart +++ b/mobile/lib/controllers/workout_controller.dart @@ -97,14 +97,7 @@ class WorkoutController extends GetxController { workout.endLng = workout.coordinates.last.longitude; } - // حفظ في قاعدة البيانات المحلية - await _db.saveWorkout(workout); - await _db.saveSegments(workout.id, workout.segments); - - // مسح الإحداثيات المؤقتة من جدول الـ GPS (لأنها حفظت في الـ Polyline) - // أو يمكن تركها إذا أردت دقة عالية جداً لاحقاً - - // إضافة إلى طابور المزامنة + // Save the workout and its outbox record in one local transaction. await _sync.queueWorkout(workout); currentWorkout.value = null; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 082b7cb..7c04dd8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -12,10 +12,9 @@ void main() async { // 1. تهيئة الخدمات (Services Initialization) // بالترتيب: قاعدة البيانات -> الـ GPS -> المزامنة -> التحليل - await Get.putAsync( - () => DatabaseService().onInit().then((_) => DatabaseService())); + await Get.putAsync(() => DatabaseService().init()); Get.put(GpsService()); - await Get.putAsync(() => SyncService().onInit().then((_) => SyncService())); + await Get.putAsync(() => SyncService().init()); Get.put(AnalyticsService()); // 2. تهيئة المتحكمات (Controllers) diff --git a/mobile/lib/models/workout.dart b/mobile/lib/models/workout.dart index 1e4aec6..0709d2e 100644 --- a/mobile/lib/models/workout.dart +++ b/mobile/lib/models/workout.dart @@ -245,6 +245,7 @@ class Workout { /// Convert to API payload for server submission Map toApiPayload() { return { + 'client_workout_id': id, 'workout_type': workoutType.name, 'distance_meters': distanceMeters.toInt(), 'duration_seconds': durationSeconds, diff --git a/mobile/lib/services/database_service.dart b/mobile/lib/services/database_service.dart index 88c5b48..19744eb 100644 --- a/mobile/lib/services/database_service.dart +++ b/mobile/lib/services/database_service.dart @@ -10,16 +10,15 @@ import '../models/workout_segment.dart'; /// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة class DatabaseService extends GetxService { static const String _dbName = 'sportpath_tracker.db'; - static const int _dbVersion = 2; + static const int _dbVersion = 3; late Database _db; Database get db => _db; - @override - Future onInit() async { - super.onInit(); + Future init() async { await _initDatabase(); + return this; } Future _initDatabase() async { @@ -28,6 +27,9 @@ class DatabaseService extends GetxService { _db = await openDatabase( dbPath, version: _dbVersion, + onConfigure: (db) async { + await db.execute('PRAGMA foreign_keys = ON'); + }, onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -136,6 +138,9 @@ class DatabaseService extends GetxService { await db.execute( 'CREATE INDEX idx_sync_status ON sync_queue(status)', ); + await db.execute( + 'CREATE INDEX idx_sync_workout ON sync_queue(workout_id)', + ); debugPrint('[DB] All tables created successfully'); } @@ -155,6 +160,11 @@ class DatabaseService extends GetxService { // Columns may already exist } } + if (oldVersion < 3) { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_sync_workout ON sync_queue(workout_id)', + ); + } } // ─── GPS Coordinates CRUD ───────────────────────────────────── @@ -234,6 +244,73 @@ class DatabaseService extends GetxService { ); } + /// Atomically persist a finished workout, its segments, and its sync outbox item. + Future saveCompletedWorkout(Workout workout, String payload) async { + await _db.transaction((txn) async { + final saved = await txn.query( + 'workouts', + columns: ['id'], + where: 'id = ?', + whereArgs: [workout.id], + limit: 1, + ); + + if (saved.isEmpty) { + await txn.insert('workouts', workout.toMap()); + } else { + final existingWorkout = await txn.query( + 'workouts', + columns: ['sync_status', 'synced_at'], + where: 'id = ?', + whereArgs: [workout.id], + limit: 1, + ); + final values = workout.toMap() + ..['sync_status'] = existingWorkout.first['sync_status'] + ..['synced_at'] = existingWorkout.first['synced_at']; + await txn.update( + 'workouts', + values, + where: 'id = ?', + whereArgs: [workout.id], + ); + } + + await txn.delete( + 'workout_segments', + where: 'workout_id = ?', + whereArgs: [workout.id], + ); + for (final segment in workout.segments) { + final values = segment.toMap()..['workout_id'] = workout.id; + await txn.insert('workout_segments', values); + } + + final queued = await txn.query( + 'sync_queue', + columns: ['id', 'status'], + where: 'workout_id = ?', + whereArgs: [workout.id], + orderBy: 'id ASC', + limit: 1, + ); + if (queued.isEmpty) { + await txn.insert('sync_queue', { + 'workout_id': workout.id, + 'payload': payload, + 'status': 'queued', + }); + } else if (queued.first['status'] != 'sent') { + await txn.update( + 'sync_queue', + {'payload': payload}, + where: 'id = ?', + whereArgs: [queued.first['id']], + ); + } + }); + } + /// Update workout fields Future updateWorkout(Workout workout) async { await _db.update( @@ -369,33 +446,49 @@ class DatabaseService extends GetxService { ); } + Future getPendingSyncCount() async { + final result = await _db.rawQuery( + "SELECT COUNT(*) AS cnt FROM sync_queue WHERE status IN ('queued', 'failed')", + ); + return Sqflite.firstIntValue(result) ?? 0; + } + + Future retryFailedSync(String workoutId) async { + await _db.update( + 'sync_queue', + { + 'status': 'queued', + 'retry_count': 0, + 'last_attempt': null, + 'error_message': null, + }, + where: "workout_id = ? AND status = 'failed'", + whereArgs: [workoutId], + ); + } + /// Update sync queue item status Future updateSyncQueueItem( int id, String status, { String? errorMessage, }) async { - int newRetryCount = 0; - - // سحبنا المنطق خارج الـ Map ليكون واضحاً ومقروءاً - if (status == 'failed') { - final currentItem = - await _db.query('sync_queue', where: 'id = ?', whereArgs: [id]); - if (currentItem.isNotEmpty) { - newRetryCount = (currentItem.first['retry_count'] as int? ?? 0) + 1; - } - } - - await _db.update( - 'sync_queue', - { - 'status': status, - 'last_attempt': DateTime.now().toIso8601String(), - 'retry_count': newRetryCount, // وضعنا القيمة الجاهزة هنا - if (errorMessage != null) 'error_message': errorMessage, - }, - where: 'id = ?', - whereArgs: [id], + await _db.rawUpdate( + ''' + UPDATE sync_queue + SET status = ?, + last_attempt = ?, + retry_count = retry_count + CASE WHEN ? = 'failed' THEN 1 ELSE 0 END, + error_message = ? + WHERE id = ? + ''', + [ + status, + DateTime.now().toUtc().toIso8601String(), + status, + errorMessage, + id, + ], ); } diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index 85e6323..db8982f 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -31,13 +31,12 @@ class SyncService extends GetxService { static const String _apiKeyKey = 'api_key'; static const String _apiSecretKey = 'api_secret'; - @override - Future onInit() async { - super.onInit(); + Future init() async { await _checkConnectivity(); _startConnectivityMonitor(); _startPeriodicSync(); await _updatePendingCount(); + return this; } // ─── Connectivity Monitoring ────────────────────────────────── @@ -107,7 +106,7 @@ class SyncService extends GetxService { /// Queue a workout for sync Future queueWorkout(Workout workout) async { final payload = jsonEncode(workout.toApiPayload()); - await _db.enqueueSync(workout.id, payload); + await _db.saveCompletedWorkout(workout, payload); await _updatePendingCount(); // Try immediate sync if online @@ -116,6 +115,14 @@ class SyncService extends GetxService { } } + Future retryWorkout(String workoutId) async { + await _db.retryFailedSync(workoutId); + await _updatePendingCount(); + if (_isOnline.value && !_isSyncing.value) { + await syncPendingWorkouts(); + } + } + /// Process all pending sync items Future syncPendingWorkouts() async { if (_isSyncing.value) return; @@ -135,6 +142,7 @@ class SyncService extends GetxService { debugPrint('[SYNC] Processing ${pendingItems.length} pending items'); for (final item in pendingItems) { + if (!_isRetryDue(item)) continue; await _syncSingleItem( id: item['id'] as int, workoutId: item['workout_id'] as String, @@ -177,7 +185,7 @@ class SyncService extends GetxService { ) .timeout(const Duration(seconds: 30)); - if (response.statusCode == 201) { + if (response.statusCode == 200 || response.statusCode == 201) { await _db.updateSyncQueueItem(id, 'sent'); await _db.updateSyncStatus( workoutId, @@ -200,8 +208,20 @@ class SyncService extends GetxService { } Future _updatePendingCount() async { - final items = await _db.getPendingSyncs(); - _pendingCount.value = items.length; + _pendingCount.value = await _db.getPendingSyncCount(); + } + + bool _isRetryDue(Map item) { + if (item['status'] != 'failed') return true; + final lastAttempt = item['last_attempt'] as String?; + if (lastAttempt == null) return true; + + final retryCount = item['retry_count'] as int? ?? 0; + final delaySeconds = (30 * (1 << retryCount)).clamp(30, 3600); + final attemptedAt = DateTime.tryParse(lastAttempt); + if (attemptedAt == null) return true; + return DateTime.now().toUtc().difference(attemptedAt.toUtc()).inSeconds >= + delaySeconds; } @override