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
+1
View File
@@ -19,6 +19,7 @@ OTP_API_URL=
OTP_API_KEY= OTP_API_KEY=
OTP_SENDER_ID= OTP_SENDER_ID=
OTP_ENABLED=false OTP_ENABLED=false
OTP_HASH_KEY=
# Food image analysis provider. Keep credentials on the server. # Food image analysis provider. Keep credentials on the server.
FOOD_AI_PROVIDER= FOOD_AI_PROVIDER=
+1 -1
View File
@@ -154,7 +154,7 @@
- `backend/api_workouts.php`: يولّد UUID جديدًا لكل POST؛ إعادة إرسال نفس الجلسة لا تظهر محمية بمعرف ثابت من العميل. المطلوب UUID ثابت وقيد فريد للمستخدم/الجلسة، ونتيجة ناجحة متسقة عند الإعادة. - `backend/api_workouts.php`: يولّد UUID جديدًا لكل POST؛ إعادة إرسال نفس الجلسة لا تظهر محمية بمعرف ثابت من العميل. المطلوب UUID ثابت وقيد فريد للمستخدم/الجلسة، ونتيجة ناجحة متسقة عند الإعادة.
- `mobile/lib/services/database_service.dart`: يستخدم replace لحفظ التمرين، وحفظ المقاطع وطابور المزامنة منفصل؛ نراجع الحفظ بمعاملة واحدة والتحديث دون حذف ضمني للعلاقات. - `mobile/lib/services/database_service.dart`: يستخدم replace لحفظ التمرين، وحفظ المقاطع وطابور المزامنة منفصل؛ نراجع الحفظ بمعاملة واحدة والتحديث دون حذف ضمني للعلاقات.
- وجود FOREIGN KEY محليًا دون onConfigure ظاهر لتفعيلها؛ نفعّلها ونختبر سلوك العلاقات والترحيل. لا نغيّر سياسة الحذف قبل اختبار البيانات الموجودة. - وجود FOREIGN KEY محليًا دون onConfigure ظاهر لتفعيلها؛ نفعّلها ونختبر سلوك العلاقات والترحيل. لا نغيّر سياسة الحذف قبل اختبار البيانات الموجودة.
- طابور المزامنة يتوقف عن إعادة المحاولة بعد خمس إخفاقات؛ نضيف حالة واضحة للمستخدم وإعادة محاولة، وتأخيرًا متدرجًا وتصنيف الأخطاء. - طابور المزامنة يتوقف عن الإرسال التلقائي بعد خمس إخفاقات؛ نعرض عدد كل السجلات غير المرفوعة، ونوفّر إعادة محاولة يدوية وتأخيرًا متدرجًا وتصنيفًا للأخطاء.
- تقرير الأسابيع يستخدم معدل تاريخ SQLite بصيغة `weeks`؛ نراجع حدود الفترة ووحدات التاريخ المدعومة، ونوحد UTC مع اليوم المحلي عند العرض. - تقرير الأسابيع يستخدم معدل تاريخ SQLite بصيغة `weeks`؛ نراجع حدود الفترة ووحدات التاريخ المدعومة، ونوحد UTC مع اليوم المحلي عند العرض.
- صفحة المنزل تعرض «آخر تمرين» بقيم ثابتة. README يصف أجزاء قديمة؛ الكود الفعلي هو مرجع التوسعة. - صفحة المنزل تعرض «آخر تمرين» بقيم ثابتة. README يصف أجزاء قديمة؛ الكود الفعلي هو مرجع التوسعة.
- مراجعة GPS ووحدات الزمن والسرعة والسعرات، ثم اختبار مسار معروف وثبات الهاتف قبل الحكم على دقة التتبع. - مراجعة GPS ووحدات الزمن والسرعة والسعرات، ثم اختبار مسار معروف وثبات الهاتف قبل الحكم على دقة التتبع.
+6
View File
@@ -44,6 +44,10 @@
لوحة الإدارة خلف تسجيل دخول وصلاحية دورية. الأدوار المقترحة: مالك، مدير محتوى، دعم، ومستخدم. عمليات تعديل البرامج والمحتوى والإعدادات تسجل في audit log؛ إعدادات الخطة المنشورة ذات نسخة ثابتة حتى لا تتغير جلسات سابقة. لوحة الإدارة خلف تسجيل دخول وصلاحية دورية. الأدوار المقترحة: مالك، مدير محتوى، دعم، ومستخدم. عمليات تعديل البرامج والمحتوى والإعدادات تسجل في 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 ## تصميم الجلسات وJWT
- هوية الحساب هي `user_id` الداخلي؛ رقم الهاتف وسيلة دخول قابلة للتغيير. - هوية الحساب هي `user_id` الداخلي؛ رقم الهاتف وسيلة دخول قابلة للتغيير.
@@ -106,6 +110,8 @@ FOOD_AI_API_KEY=
UPLOAD_PRIVATE_PATH= UPLOAD_PRIVATE_PATH=
``` ```
تم إنشاء repository محليًا على `main` ودفع المحتوى الحالي إلى `origin/main`. إعداد النشر لا يعمل على خادم بعد؛ لا توجد حاليًا بيانات host/site user أو مجلد public صالح.
القيم أعلاه أسماء توضيحية. مزود الرسائل وموفر الذكاء الاصطناعي واسم النطاق والسياسات الرقمية لم تُحسم بعد، لذا تبقى حقولها فارغة ولا يُستخدم المثال كإعداد إنتاج. القيم أعلاه أسماء توضيحية. مزود الرسائل وموفر الذكاء الاصطناعي واسم النطاق والسياسات الرقمية لم تُحسم بعد، لذا تبقى حقولها فارغة ولا يُستخدم المثال كإعداد إنتاج.
## نشر Git إلى CloudPanel والمنفذ 2101 ## نشر Git إلى CloudPanel والمنفذ 2101
+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 static $instance = null;
private $connection; 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() { private function __construct() {
try { try {
$this->connection = new mysqli( require_once __DIR__ . '/Config.php';
$this->db_host, AppConfig::loadEnvironment();
$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
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
} catch (Exception $e) { $this->connection = new mysqli(
error_log('Database Error: ' . $e->getMessage()); 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); http_response_code(500);
die(json_encode(['error' => 'Database connection failed'])); die(json_encode(['error' => 'Database connection failed']));
} }
+5
View File
@@ -32,6 +32,11 @@ class WorkoutValidator {
// Validate data types and values // Validate data types and values
$this->validateWorkoutType($payload['workout_type']); $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('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('duration_seconds', $payload['duration_seconds'], self::MIN_DURATION, self::MAX_DURATION);
$this->validateNumericField('elevation_gain_meters', $payload['elevation_gain_meters'], 0, 10000); $this->validateNumericField('elevation_gain_meters', $payload['elevation_gain_meters'], 0, 10000);
+60 -31
View File
@@ -85,13 +85,14 @@ try {
// Prepare workout insert statement // Prepare workout insert statement
$stmt = $db->prepare(' $stmt = $db->prepare('
INSERT INTO workouts ( 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, duration_seconds, elevation_gain_meters, elevation_loss_meters,
calories_burned, average_pace_mps, max_speed_mps, calories_burned, average_pace_mps, max_speed_mps,
route_polyline, coordinate_count, start_lat, start_lng, route_polyline, coordinate_count, start_lat, start_lng,
end_lat, end_lng, start_time, end_time, weather_condition, end_lat, end_lng, start_time, end_time, weather_condition,
temperature_celsius, notes, is_public temperature_celsius, notes, is_public
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
'); ');
// Extract start and end coordinates // Extract start and end coordinates
@@ -102,12 +103,16 @@ try {
$average_pace = $payload['duration_seconds'] > 0 $average_pace = $payload['duration_seconds'] > 0
? $payload['distance_meters'] / $payload['duration_seconds'] ? $payload['distance_meters'] / $payload['duration_seconds']
: 0; : 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 // Bind parameters
$stmt->bind_param( $stmt->bind_param(
'sisissiiiddidddssdssi', 'sissiiiidddsiddddsssdsi',
$workout_uuid, $workout_uuid,
$user_id, $user_id,
$client_workout_uuid,
$payload['workout_type'], $payload['workout_type'],
$payload['distance_meters'], $payload['distance_meters'],
$payload['duration_seconds'], $payload['duration_seconds'],
@@ -122,8 +127,8 @@ try {
$start_coord['lng'], $start_coord['lng'],
$end_coord['lat'], $end_coord['lat'],
$end_coord['lng'], $end_coord['lng'],
$payload['start_time'], $start_time_utc,
$payload['end_time'], $end_time_utc,
$payload['weather_condition'], $payload['weather_condition'],
$payload['temperature_celsius'], $payload['temperature_celsius'],
$payload['notes'], $payload['notes'],
@@ -134,11 +139,25 @@ try {
throw new Exception('Failed to insert workout: ' . $stmt->error, 500); throw new Exception('Failed to insert workout: ' . $stmt->error, 500);
} }
$was_inserted = $stmt->affected_rows === 1;
$workout_id = $db->getLastInsertId(); $workout_id = $db->getLastInsertId();
$stmt->close(); $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 // Process and store segments if provided
if (!empty($payload['segments'])) { if ($was_inserted && !empty($payload['segments'])) {
$segment_stmt = $db->prepare(' $segment_stmt = $db->prepare('
INSERT INTO workout_segments (workout_id, segment_order, duration_seconds, distance_meters, average_pace_mps, index_in_polyline) INSERT INTO workout_segments (workout_id, segment_order, duration_seconds, distance_meters, average_pace_mps, index_in_polyline)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
@@ -168,45 +187,49 @@ try {
} }
// Update or create user stats cache // Update or create user stats cache
updateUserStatsCache($db, $user_id); if ($was_inserted) {
updateUserStatsCache($db, $user_id);
}
// Log successful submission // Log successful submission
$logStmt = $db->prepare(' if ($was_inserted) {
INSERT INTO api_logs (user_id, endpoint, method, status_code, ip_address, user_agent, response_time_ms) $logStmt = $db->prepare('
VALUES (?, ?, ?, ?, ?, ?, ?) INSERT INTO api_logs (user_id, endpoint, method, status_code, ip_address, user_agent, response_time_ms)
'); VALUES (?, ?, ?, ?, ?, ?, ?)
');
$endpoint = '/api/v1/workouts'; $endpoint = '/api/v1/workouts';
$method = 'POST'; $method = 'POST';
$status = 201; $status = 201;
$ip = getClientIpAddress(); $ip = getClientIpAddress();
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown'; $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
$response_time = (int)((microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1000); $response_time = (int)((microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1000);
$logStmt->bind_param( $logStmt->bind_param(
'ississi', 'ississi',
$user_id, $user_id,
$endpoint, $endpoint,
$method, $method,
$status, $status,
$ip, $ip,
$user_agent, $user_agent,
$response_time $response_time
); );
$logStmt->execute(); $logStmt->execute();
$logStmt->close(); $logStmt->close();
}
// Commit transaction // Commit transaction
$db->commit(); $db->commit();
// Return success response // Return success response
http_response_code(201); http_response_code($was_inserted ? 201 : 200);
echo json_encode([ echo json_encode([
'status' => 'success', 'status' => 'success',
'data' => [ 'data' => [
'workout_id' => $workout_id, 'workout_id' => $workout_id,
'workout_uuid' => $workout_uuid, 'workout_uuid' => $workout_uuid,
'message' => 'Workout submitted successfully', 'message' => $was_inserted ? 'Workout submitted successfully' : 'Workout was already received',
'timestamp' => date('c') 'timestamp' => date('c')
] ]
]); ]);
@@ -270,6 +293,12 @@ function generateUUID() {
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); 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 * 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 ( CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the user', uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the user',
username VARCHAR(50) UNIQUE NOT NULL, phone_e164 VARCHAR(16) UNIQUE COMMENT 'Verified phone number in E.164 format',
email VARCHAR(100) UNIQUE NOT NULL, phone_verified_at DATETIME NULL,
password_hash VARCHAR(255) NOT NULL COMMENT 'bcrypt hash', username VARCHAR(50) UNIQUE,
api_key VARCHAR(64) UNIQUE NOT NULL COMMENT 'API key for client authentication', email VARCHAR(100) UNIQUE,
api_secret VARCHAR(64) NOT NULL COMMENT 'Secret for HMAC signature', 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), full_name VARCHAR(100),
avatar_url VARCHAR(255), avatar_url VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
account_role ENUM('member', 'owner', 'content_manager', 'support') NOT NULL DEFAULT 'member',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_uuid (uuid), INDEX idx_uuid (uuid),
@@ -25,11 +28,97 @@ CREATE TABLE IF NOT EXISTS users (
INDEX idx_created_at (created_at) INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 -- Workouts Table
CREATE TABLE IF NOT EXISTS workouts ( CREATE TABLE IF NOT EXISTS workouts (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
workout_uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the workout', workout_uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the workout',
user_id INT NOT NULL, user_id INT NOT NULL,
client_workout_uuid CHAR(36) NULL COMMENT 'Client-generated idempotency key',
workout_type ENUM('running', 'walking') NOT NULL, workout_type ENUM('running', 'walking') NOT NULL,
distance_meters INT NOT NULL COMMENT 'Total distance in meters', distance_meters INT NOT NULL COMMENT 'Total distance in meters',
duration_seconds INT NOT NULL COMMENT 'Total duration in seconds', 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_workout_type (workout_type),
INDEX idx_synced_at (synced_at), INDEX idx_synced_at (synced_at),
INDEX idx_created_at (created_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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Workout Segments Table (for detailed route tracking if needed) -- Workout Segments Table (for detailed route tracking if needed)
+2 -2
View File
@@ -40,12 +40,12 @@ DEPLOY_DRY_RUN=1 bash deploy/sync-to-server.sh
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 لا تمرر كمتغير ولا تحفظ في هذا المستودع. أول تشغيل يحتاج مفتاح SSH صالحًا للوصول إلى Site User عبر المنفذ 2101 ومفتاح Git صالحًا على الخادم. مفاتيح SSH لا تمرر كمتغير ولا تحفظ في هذا المستودع.
## الاسترجاع ## الاسترجاع
كل إصدار محفوظ تحت `<DEPLOY_ROOT>/releases/<commit-sha>`. عند الحاجة، أنشئ رابطًا مؤقتًا إلى مجلد الإصدار السابق ثم استبدل `current` ذريًا من جلسة SSH الخاصة بـSite User. لا تحذف مجلدات الإصدار السابقة أثناء فترة المراجعة. استرجاع ملفات التطبيق لا يسترجع قاعدة البيانات؛ migrations تحتاج سياسة رجوع منفصلة ونسخة احتياطية مختبرة. كل إصدار محفوظ تحت `<DEPLOY_ROOT>/releases/<commit-sha>`. إعادة نشر commit محفوظ تعيد تفعيل نفس الإصدار، ما يسمح بالاسترجاع إلى نسخة سابقة. لا تحذف مجلدات الإصدارات أثناء فترة المراجعة. استرجاع ملفات التطبيق لا يسترجع قاعدة البيانات؛ migrations تحتاج سياسة رجوع منفصلة ونسخة احتياطية مختبرة.
لا ينفذ السكربت أوامر SQL أو يغيّر صلاحيات قاعدة البيانات أو ينشئ مستخدمين. بعد إضافة migrations، تُدار كخطوة إصدار مراجعة ومختبرة، ولا تُنفّذ تلقائيًا من آلية النشر قبل تحديد سياسة التراجع. لا ينفذ السكربت أوامر SQL أو يغيّر صلاحيات قاعدة البيانات أو ينشئ مستخدمين. بعد إضافة migrations، تُدار كخطوة إصدار مراجعة ومختبرة، ولا تُنفّذ تلقائيًا من آلية النشر قبل تحديد سياسة التراجع.
+30 -11
View File
@@ -71,33 +71,52 @@ release_dir="$releases_dir/$expected_sha"
mkdir -p "$deploy_dir" "$releases_dir" "$shared_dir" mkdir -p "$deploy_dir" "$releases_dir" "$shared_dir"
chmod 700 "$deploy_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" chmod 600 "$shared_dir/.env"
if [[ ! -d "$repo_dir" ]]; then if [[ ! -d "$repo_dir" ]]; then
git init --bare --quiet "$repo_dir" git init --bare --quiet "$repo_dir"
git --git-dir="$repo_dir" remote add origin "$repo_url" 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 fi
git --git-dir="$repo_dir" fetch --quiet --no-tags origin \ git --git-dir="$repo_dir" fetch --quiet --no-tags origin \
"+refs/heads/$ref:refs/remotes/origin/$ref" "+refs/heads/$ref:refs/remotes/origin/$ref"
actual_sha="$(git --git-dir="$repo_dir" rev-parse "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' [[ "$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" if [[ -e "$release_dir" ]]; then
git --git-dir="$repo_dir" archive "$actual_sha" | tar -x -C "$release_dir" [[ -d "$release_dir/public" ]] || die "existing release is incomplete: $release_dir"
[[ -d "$release_dir/public" ]] || die 'release has no public/ web root; configure the app layout before deployment' [[ -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 if command -v php >/dev/null 2>&1 && [[ -d "$staging_dir/backend" ]]; then
while IFS= read -r -d '' php_file; do while IFS= read -r -d '' php_file; do
php -l "$php_file" >/dev/null || die "PHP syntax check failed: $php_file" php -l "$php_file" >/dev/null || die "PHP syntax check failed: $php_file"
done < <(find "$release_dir/backend" -type f -name '*.php' -print0) done < <(find "$staging_dir/backend" -type f -name '*.php' -print0)
fi
mv "$staging_dir" "$release_dir"
trap - EXIT
fi fi
printf '%s\n' "$domain" > "$shared_dir/.site-domain" printf '%s\n' "$domain" > "$shared_dir/.site-domain"
ln -s "$release_dir" "$root/current.next" next_link="$root/.current-$expected_sha-$$"
mv -Tf "$root/current.next" "$root/current" ln -s "$release_dir" "$next_link"
mv -Tf "$next_link" "$root/current"
printf 'Published %s to %s\n' "$actual_sha" "$root/current" printf 'Published %s to %s\n' "$actual_sha" "$root/current"
printf 'CloudPanel document root must point to: %s/current/public\n' "$root" printf 'CloudPanel document root must point to: %s/current/public\n' "$root"
@@ -97,14 +97,7 @@ class WorkoutController extends GetxController {
workout.endLng = workout.coordinates.last.longitude; workout.endLng = workout.coordinates.last.longitude;
} }
// حفظ في قاعدة البيانات المحلية // Save the workout and its outbox record in one local transaction.
await _db.saveWorkout(workout);
await _db.saveSegments(workout.id, workout.segments);
// مسح الإحداثيات المؤقتة من جدول الـ GPS (لأنها حفظت في الـ Polyline)
// أو يمكن تركها إذا أردت دقة عالية جداً لاحقاً
// إضافة إلى طابور المزامنة
await _sync.queueWorkout(workout); await _sync.queueWorkout(workout);
currentWorkout.value = null; currentWorkout.value = null;
+2 -3
View File
@@ -12,10 +12,9 @@ void main() async {
// 1. تهيئة الخدمات (Services Initialization) // 1. تهيئة الخدمات (Services Initialization)
// بالترتيب: قاعدة البيانات -> الـ GPS -> المزامنة -> التحليل // بالترتيب: قاعدة البيانات -> الـ GPS -> المزامنة -> التحليل
await Get.putAsync( await Get.putAsync<DatabaseService>(() => DatabaseService().init());
() => DatabaseService().onInit().then((_) => DatabaseService()));
Get.put(GpsService()); Get.put(GpsService());
await Get.putAsync(() => SyncService().onInit().then((_) => SyncService())); await Get.putAsync<SyncService>(() => SyncService().init());
Get.put(AnalyticsService()); Get.put(AnalyticsService());
// 2. تهيئة المتحكمات (Controllers) // 2. تهيئة المتحكمات (Controllers)
+1
View File
@@ -245,6 +245,7 @@ class Workout {
/// Convert to API payload for server submission /// Convert to API payload for server submission
Map<String, dynamic> toApiPayload() { Map<String, dynamic> toApiPayload() {
return { return {
'client_workout_id': id,
'workout_type': workoutType.name, 'workout_type': workoutType.name,
'distance_meters': distanceMeters.toInt(), 'distance_meters': distanceMeters.toInt(),
'duration_seconds': durationSeconds, 'duration_seconds': durationSeconds,
+118 -25
View File
@@ -10,16 +10,15 @@ import '../models/workout_segment.dart';
/// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة /// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة
class DatabaseService extends GetxService { class DatabaseService extends GetxService {
static const String _dbName = 'sportpath_tracker.db'; static const String _dbName = 'sportpath_tracker.db';
static const int _dbVersion = 2; static const int _dbVersion = 3;
late Database _db; late Database _db;
Database get db => _db; Database get db => _db;
@override Future<DatabaseService> init() async {
Future<void> onInit() async {
super.onInit();
await _initDatabase(); await _initDatabase();
return this;
} }
Future<void> _initDatabase() async { Future<void> _initDatabase() async {
@@ -28,6 +27,9 @@ class DatabaseService extends GetxService {
_db = await openDatabase( _db = await openDatabase(
dbPath, dbPath,
version: _dbVersion, version: _dbVersion,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@@ -136,6 +138,9 @@ class DatabaseService extends GetxService {
await db.execute( await db.execute(
'CREATE INDEX idx_sync_status ON sync_queue(status)', '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'); debugPrint('[DB] All tables created successfully');
} }
@@ -155,6 +160,11 @@ class DatabaseService extends GetxService {
// Columns may already exist // 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 ───────────────────────────────────── // ─── GPS Coordinates CRUD ─────────────────────────────────────
@@ -234,6 +244,73 @@ class DatabaseService extends GetxService {
); );
} }
/// Atomically persist a finished workout, its segments, and its sync outbox item.
Future<void> 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 /// Update workout fields
Future<void> updateWorkout(Workout workout) async { Future<void> updateWorkout(Workout workout) async {
await _db.update( await _db.update(
@@ -369,33 +446,49 @@ class DatabaseService extends GetxService {
); );
} }
Future<int> 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<void> 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 /// Update sync queue item status
Future<void> updateSyncQueueItem( Future<void> updateSyncQueueItem(
int id, int id,
String status, { String status, {
String? errorMessage, String? errorMessage,
}) async { }) async {
int newRetryCount = 0; await _db.rawUpdate(
'''
// سحبنا المنطق خارج الـ Map ليكون واضحاً ومقروءاً UPDATE sync_queue
if (status == 'failed') { SET status = ?,
final currentItem = last_attempt = ?,
await _db.query('sync_queue', where: 'id = ?', whereArgs: [id]); retry_count = retry_count + CASE WHEN ? = 'failed' THEN 1 ELSE 0 END,
if (currentItem.isNotEmpty) { error_message = ?
newRetryCount = (currentItem.first['retry_count'] as int? ?? 0) + 1; WHERE id = ?
} ''',
} [
status,
await _db.update( DateTime.now().toUtc().toIso8601String(),
'sync_queue', status,
{ errorMessage,
'status': status, id,
'last_attempt': DateTime.now().toIso8601String(), ],
'retry_count': newRetryCount, // وضعنا القيمة الجاهزة هنا
if (errorMessage != null) 'error_message': errorMessage,
},
where: 'id = ?',
whereArgs: [id],
); );
} }
+27 -7
View File
@@ -31,13 +31,12 @@ class SyncService extends GetxService {
static const String _apiKeyKey = 'api_key'; static const String _apiKeyKey = 'api_key';
static const String _apiSecretKey = 'api_secret'; static const String _apiSecretKey = 'api_secret';
@override Future<SyncService> init() async {
Future<void> onInit() async {
super.onInit();
await _checkConnectivity(); await _checkConnectivity();
_startConnectivityMonitor(); _startConnectivityMonitor();
_startPeriodicSync(); _startPeriodicSync();
await _updatePendingCount(); await _updatePendingCount();
return this;
} }
// ─── Connectivity Monitoring ────────────────────────────────── // ─── Connectivity Monitoring ──────────────────────────────────
@@ -107,7 +106,7 @@ class SyncService extends GetxService {
/// Queue a workout for sync /// Queue a workout for sync
Future<void> queueWorkout(Workout workout) async { Future<void> queueWorkout(Workout workout) async {
final payload = jsonEncode(workout.toApiPayload()); final payload = jsonEncode(workout.toApiPayload());
await _db.enqueueSync(workout.id, payload); await _db.saveCompletedWorkout(workout, payload);
await _updatePendingCount(); await _updatePendingCount();
// Try immediate sync if online // Try immediate sync if online
@@ -116,6 +115,14 @@ class SyncService extends GetxService {
} }
} }
Future<void> retryWorkout(String workoutId) async {
await _db.retryFailedSync(workoutId);
await _updatePendingCount();
if (_isOnline.value && !_isSyncing.value) {
await syncPendingWorkouts();
}
}
/// Process all pending sync items /// Process all pending sync items
Future<void> syncPendingWorkouts() async { Future<void> syncPendingWorkouts() async {
if (_isSyncing.value) return; if (_isSyncing.value) return;
@@ -135,6 +142,7 @@ class SyncService extends GetxService {
debugPrint('[SYNC] Processing ${pendingItems.length} pending items'); debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
for (final item in pendingItems) { for (final item in pendingItems) {
if (!_isRetryDue(item)) continue;
await _syncSingleItem( await _syncSingleItem(
id: item['id'] as int, id: item['id'] as int,
workoutId: item['workout_id'] as String, workoutId: item['workout_id'] as String,
@@ -177,7 +185,7 @@ class SyncService extends GetxService {
) )
.timeout(const Duration(seconds: 30)); .timeout(const Duration(seconds: 30));
if (response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
await _db.updateSyncQueueItem(id, 'sent'); await _db.updateSyncQueueItem(id, 'sent');
await _db.updateSyncStatus( await _db.updateSyncStatus(
workoutId, workoutId,
@@ -200,8 +208,20 @@ class SyncService extends GetxService {
} }
Future<void> _updatePendingCount() async { Future<void> _updatePendingCount() async {
final items = await _db.getPendingSyncs(); _pendingCount.value = await _db.getPendingSyncCount();
_pendingCount.value = items.length; }
bool _isRetryDue(Map<String, dynamic> 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 @override