getMessage()); printFailure("Database connection failed"); exit; } // ── 1. Input & Validation ────────────────────────────────────── $rideId = filterRequest("id"); // Force driver_id from JWT — never trust user-supplied driver_id $driverId = $user_id; $status = filterRequest("status"); // القيمة التي يرسلها التطبيق: 'accepted' $passengerToken = filterRequest("passengerToken"); $passengerFingerprint = filterRequest("passengerFingerprint"); $passengerIdValue = filterRequest("passenger_id"); if (empty($rideId) || empty($driverId)) { printFailure("Missing required parameters"); exit; } // Self-ride validation $driverFingerprint = isset($_SERVER['HTTP_X_DEVICE_FP']) ? $_SERVER['HTTP_X_DEVICE_FP'] : ''; if (!empty($driverFingerprint) && $driverFingerprint === $passengerFingerprint) { error_log("[accept_ride] Self-ride attempt blocked. DriverID=$driverId, Fingerprint=$driverFingerprint"); printFailure("Self-matching is not allowed"); exit; } // status whitelist — لا نقبل قيمة عشوائية من التطبيق $allowedStatuses = ['accepted', 'Apply']; if (!in_array($status, $allowedStatuses, true)) { $status = 'accepted'; // fallback آمن } error_log("[accept_ride] DriverID=$driverId attempting RideID=$rideId"); try { // ═══════════════════════════════════════════════════════════ // STEP A0 — تطبيق الإيقاف على الخادم // ‏كان الإيقاف بعد ٣ إلغاءات يُكتب في تخزين تطبيق السائق وحده، فيُفكّ // ‏بمسح بيانات التطبيق أو إعادة تثبيته. هذه هي البوابة الحقيقية: سائق // ‏موقوف لا يستطيع أن يفوز برحلة مهما فعل بتطبيقه. // ═══════════════════════════════════════════════════════════ // ‏الفحص من Redis لا MySQL: هذا المسار يُستدعى على كل محاولة قبول من كل // ‏سائق، والإيقاف حالة نادرة قصيرة العمر. مفتاح بـ TTL يجعل انتهاء // ‏الإيقاف تلقائياً بلا كرون ولا استعلام تنظيف. // ‏MySQL يبقى السجل الدائم للمراجعة الإدارية، ولا يُقرأ هنا. try { if (isset($redisLocation) && $redisLocation) { $blockedUntil = $redisLocation->get("driver:$driverId:blocked"); if ($blockedUntil) { error_log("[accept_ride] DriverID=$driverId موقوف حتى $blockedUntil — رُفض القبول"); printFailure("Your account is temporarily suspended until $blockedUntil"); exit; } } } catch (Throwable $eBlock) { error_log("[accept_ride] تعذّر فحص الإيقاف: " . $eBlock->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP A0b — حارس الحصرية في الإسناد بالدفعات // // ‏بدون هذا الفحص تبقى الحصرية شكلية: سائق تلقّى العرض سابقاً (أو صاغ // ‏الطلب يدوياً) يستطيع القبول خارج دوره فيعود السباق كما كان. // // ‏وجود المفتاح هو ما يحدد الوضع: البثّ الحر لا يكتبه أصلاً، فالفحص // ‏يمرّ بلا أثر — لا حاجة لقراءة DISPATCH_MODE هنا. // ═══════════════════════════════════════════════════════════ try { if (isset($redisLocation) && $redisLocation) { $currentOffer = $redisLocation->get("ride:$rideId:current_offer"); if ($currentOffer !== false && $currentOffer !== null && (string) $currentOffer !== (string) $driverId) { error_log("[accept_ride] DriverID=$driverId حاول قبول RideID=$rideId" . " والعرض الحالي لـ $currentOffer — رُفض"); printFailure("Ride not available"); exit; } } } catch (Throwable $eOffer) { // ‏فشل الفحص لا يمنع القبول — القفل التفاؤلي في STEP A يبقى الضامن // ‏ضد الازدواج، وأسوأ نتيجة هنا هي عودة سلوك البثّ الحر لهذه الرحلة. error_log("[accept_ride] تعذّر فحص حصرية العرض: " . $eOffer->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP A — القفل على ride DB (المرجع الأساسي) // Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait' // السائق الأول الذي يصل يربح — الباقي يجدون rowCount=0 // ═══════════════════════════════════════════════════════════ $stmtLock = $con_ride->prepare(" UPDATE `ride` SET `status` = ?, `driver_id` = ?, `rideTimeStart` = NOW() WHERE `id` = ? AND `status` IN ('waiting', 'wait') "); $stmtLock->execute([$status, $driverId, $rideId]); if ($stmtLock->rowCount() === 0) { // الرحلة غير متاحة — سائق آخر سبق أو الرحلة ألغيت error_log("[accept_ride] RideID=$rideId not available for DriverID=$driverId (rowCount=0)"); printFailure("Ride not available"); exit; } error_log("[accept_ride] ride DB locked. RideID=$rideId → DriverID=$driverId"); // ‏عدّاد القبول — بسط معدّل القبول الذي يرتّب به محرك الإسناد. // ‏يُعدّ هنا بعد نجاح القفل حصراً: العدّ قبله كان سيحسب للخاسرين في // ‏السباق قبولاً لم يحدث. try { if (isset($redisLocation) && $redisLocation) { $redisLocation->hIncrBy("driver:score:$driverId", 'accepts', 1); } } catch (Throwable $eScore) { error_log("[accept_ride] تعذّر تحديث عدّاد القبول: " . $eScore->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP B — تزامن primary DB (بعد نجاح القفل) // ═══════════════════════════════════════════════════════════ try { $con->prepare(" UPDATE `ride` SET `driver_id` = ?, `status` = ?, `rideTimeStart` = NOW() WHERE `id` = ? ")->execute([$driverId, $status, $rideId]); error_log("[accept_ride] primary DB synced. RideID=$rideId"); } catch (PDOException $eSync) { // لا نوقف — ride DB هو المرجع error_log("[accept_ride] primary DB sync WARNING: " . $eSync->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP B1 — تثبيت مسافة الاقتراب لحظة القبول (في Redis لا MySQL) // // ‏هذه المسافة أساس تعويض عدم الحضور لاحقاً، ويجب أن تُقاس الآن حصراً: // ‏عند الإلغاء يكون السائق واقفاً عند نقطة الالتقاط فتصير صفراً. // // ‏لكنها قيمة عابرة تُقرأ مرة واحدة عند نهاية الرحلة — فلا تستحق كتابتين // ‏في MySQL على أسخن مسار في النظام. تُحفظ في نفس الهاش الذي يستخدمه // ‏السوكِت لحالة الرحلة (ride:$id:state)، وتُثبَّت في MySQL كتابةً واحدة // ‏مع تحديث الحالة النهائي عند الإلغاء. // // ‏hSet لا يمسح حقول السوكِت الأخرى في نفس الهاش، وTTL الـ24 ساعة الذي // ‏يضبطه السوكِت يغطي هذه الحقول تلقائياً. // ═══════════════════════════════════════════════════════════ try { $approachKm = 0.0; if (isset($redisLocation) && $redisLocation) { $stmtPickup = $con->prepare("SELECT start_location FROM ride WHERE id = ?"); $stmtPickup->execute([$rideId]); [$pickupLat, $pickupLng] = array_pad( array_map('trim', explode(',', (string) $stmtPickup->fetchColumn(), 2)), 2, null ); if (is_numeric($pickupLat) && is_numeric($pickupLng)) { $loc = $redisLocation->hGetAll("driver:public:$driverId"); if (!empty($loc['lat']) && !empty($loc['lng'])) { $approachKm = round(getDistanceBetweenPoints( (float) $loc['lat'], (float) $loc['lng'], (float) $pickupLat, (float) $pickupLng ), 3); } } if ($approachKm > 0) { $redisLocation->hSet("ride:$rideId:state", 'pickup_approach_km', $approachKm); } } if ($approachKm <= 0) { error_log("[accept_ride] تعذّر قياس مسافة الاقتراب RideID=$rideId" . " — التعويض سيحتسب زمن الانتظار وحده"); } } catch (Throwable $eApproach) { // ‏قياس التعويض لا يُسقط قبول الرحلة أبداً. error_log("[accept_ride] pickupApproachKm WARNING: " . $eApproach->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP C — driver_orders (INSERT أو UPDATE بسطر واحد آمن) // ON DUPLICATE KEY يمنع race condition ثانية على هذا الجدول // ═══════════════════════════════════════════════════════════ try { $con->prepare(" INSERT INTO `driver_orders` (`driver_id`, `order_id`, `status`, `created_at`) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE `driver_id` = VALUES(`driver_id`), `status` = VALUES(`status`), `created_at` = NOW() ")->execute([$driverId, $rideId, $status]); } catch (PDOException $eOrders) { error_log("[accept_ride] driver_orders WARNING: " . $eOrders->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP C.1 — تحديث جدول waitingRides حتى لا تظهر للكباتن الآخرين // ═══════════════════════════════════════════════════════════ try { $con->prepare("UPDATE `waitingRides` SET `status` = 'Apply' WHERE `id` = ?")->execute([$rideId]); } catch (PDOException $eWaiting) { error_log("[accept_ride] waitingRides WARNING: " . $eWaiting->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP D — جلب بيانات السائق للراكب // ═══════════════════════════════════════════════════════════ $driverInfo = []; $stmtDriver = $con->prepare(" SELECT d.id AS driver_id, d.first_name, d.last_name, d.gender, d.phone, c.make, c.model, c.car_plate, c.year, c.color, c.color_hex, (SELECT ROUND(AVG(rating), 2) FROM ratingDriver WHERE driver_id = d.id) AS ratingDriver, (SELECT COUNT(*) FROM ratingDriver WHERE driver_id = d.id) AS ratingCount, (SELECT COUNT(*) FROM ride WHERE driver_id = d.id AND status IN ('Finished', 'finished')) AS completedRides, dt.token FROM driver d LEFT JOIN CarRegistration c ON c.driverID = d.id LEFT JOIN driverToken dt ON dt.captain_id = d.id WHERE d.id = ? LIMIT 1 "); $stmtDriver->execute([$driverId]); $driverRaw = $stmtDriver->fetch(PDO::FETCH_ASSOC); if ($driverRaw) { $encryptedFields = ['first_name', 'last_name', 'gender', 'phone', 'car_plate', 'token']; foreach ($driverRaw as $key => $value) { if (in_array($key, $encryptedFields, true) && !empty($value)) { $decrypted = false; try { $decrypted = $encryptionHelper->decryptData($value); } catch (\Throwable $e) { $decrypted = false; } $driverInfo[$key] = ($decrypted !== false && $decrypted !== null && $decrypted !== '') ? $decrypted : $value; } else { $driverInfo[$key] = $value; } } $driverInfo['driverName'] = trim(($driverInfo['first_name'] ?? '') . ' ' . ($driverInfo['last_name'] ?? '')); if (empty($driverInfo['driverName']) && !empty($driverInfo['phone'])) { $driverInfo['driverName'] = $driverInfo['phone']; } $driverInfo['ratingDriver'] = !empty($driverInfo['ratingDriver']) ? (string)$driverInfo['ratingDriver'] : "5.0"; $ratingValue = (float) $driverInfo['ratingDriver']; $ratingCount = (int) ($driverInfo['ratingCount'] ?? 0); $completedRides = (int) ($driverInfo['completedRides'] ?? 0); if ($ratingValue >= 4.8 && $ratingCount >= 50 && $completedRides >= 100) { $driverInfo['driverTier'] = 'Professional driver'; } elseif ($ratingValue >= 4.5 && $ratingCount >= 15 && $completedRides >= 30) { $driverInfo['driverTier'] = 'Trusted driver'; } else { $driverInfo['driverTier'] = 'Verified driver'; } if (isset($redisLocation) && $redisLocation) { try { // ‏المفتاح الصحيح driver:public — و driver:location لا يُكتب في أي // ‏مكان إطلاقاً فكانت القراءة ترجع فارغة دائماً، فيصل الراكب بلا // ‏إحداثيات أولية للسائق. الكاتب driver_socket.php في معالج // ‏الدفعات (hmset على driver:profile و driver:public معاً، بحقول // ‏lat/lng/heading نفسها، وTTL أربعٍ وعشرين ساعة للعام). $driverLoc = $redisLocation->hGetAll("driver:public:$driverId"); if (!empty($driverLoc)) { $driverInfo['lat'] = (float)($driverLoc['lat'] ?? 0); $driverInfo['lng'] = (float)($driverLoc['lng'] ?? 0); $driverInfo['heading'] = (float)($driverLoc['heading'] ?? 0); } } catch (Exception $eLoc) { // Ignore location fetch error } } } // ═══════════════════════════════════════════════════════════ // STEP E — جلب passenger_id وإرسال الإشعارات // ═══════════════════════════════════════════════════════════ if (empty($passengerIdValue)) { $passengerId = $con->prepare("SELECT passenger_id FROM ride WHERE id = ? LIMIT 1"); $passengerId->execute([$rideId]); $passengerIdValue = $passengerId->fetchColumn(); } // 🆕 نحلّ توكن الراكب من القاعدة دائماً ولا نعتمد على ما يرسله التطبيق: // • مسار الـ dispatch/FCM يرسل التوكن سليماً (نص صريح من add_ride.php). // • مسار "سوق الرحلات" يرسله كما خرج من getRideWaiting.php أي **مشفّراً** // (جدول tokens يخزّنه مشفّراً — انظر ride/firebase/addToken.php)، فكان // FCM يرفضه بـ 400 ولا يصل الراكب أي إشعار قبول. // القيمة القادمة من العميل تبقى fallback أخيراً فقط. $passengerTokenFromClient = $passengerToken; $passengerToken = ''; if ($passengerIdValue) { try { $stmtTok = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? LIMIT 1"); $stmtTok->execute([$passengerIdValue]); $rawPassengerToken = $stmtTok->fetchColumn(); if (!empty($rawPassengerToken)) { $decryptedToken = false; try { $decryptedToken = $encryptionHelper->decryptData($rawPassengerToken); } catch (\Throwable $eTok) { $decryptedToken = false; } $passengerToken = ($decryptedToken !== false && $decryptedToken !== null && $decryptedToken !== '') ? trim($decryptedToken) : $rawPassengerToken; } } catch (PDOException $eTok) { error_log("[accept_ride] passenger token lookup WARNING: " . $eTok->getMessage()); } } if (empty($passengerToken)) { $passengerToken = $passengerTokenFromClient; } if ($passengerIdValue) { // Socket — real-time update على خريطة الراكب if (function_exists('notifyPassengerOnRideServer')) { notifyPassengerOnRideServer($passengerIdValue, [ 'status' => 'accepted', 'ride_id' => $rideId, 'driver_id' => $driverId, 'driver_info' => $driverInfo, ]); } // FCM — push notification صامت if (!empty($passengerToken)) { sendFCM_Internal( $passengerToken, "", // تفريغ العنوان للإرسال الصامت "", // تفريغ المحتوى للإرسال الصامت ['ride_id' => (string) $rideId, 'driver_info' => $driverInfo, 'status' => 'accepted'], "Accepted Ride", false ); } } // ═══════════════════════════════════════════════════════════ // STEP F — تنظيف السوق + Cache ride state (أبلغ location server) // ═══════════════════════════════════════════════════════════ sendToLocationServer('ride_taken_event', [ 'ride_id' => $rideId, 'taken_by_driver_id' => $driverId, ]); // 🆕 Cache ride state in Redis sendToLocationServer('update_ride_state', [ 'ride_id' => $rideId, 'status' => $status, 'driver_id' => $driverId, 'passenger_id' => $passengerIdValue ?? '', ]); error_log("[accept_ride] SUCCESS. RideID=$rideId accepted by DriverID=$driverId"); // ═══════════════════════════════════════════════════════════ // STEP G — رد النجاح للسائق (نفس بنية الرد القديمة) // ═══════════════════════════════════════════════════════════ echo json_encode([ "status" => "success", "message" => "Ride Accepted", "data" => $driverInfo, ]); } catch (PDOException $e) { error_log("[accept_ride] CRITICAL: " . $e->getMessage()); printFailure("Server error"); }