From 26ae0124c8d47f7bf030d65693b99b8f24cdce93 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 30 Jun 2026 22:43:38 +0300 Subject: [PATCH] Update: 2026-06-30 22:43:38 --- backend/api/location/sync_location.php | 42 ++++++ backend/bot/cron_silent_push_inactive.php | 96 +++++++++++++ .../Services/LocationIntelligenceEngine.php | 134 ++++++++++++++++++ backend/schema_primary.sql | 79 +++++++++++ 4 files changed, 351 insertions(+) create mode 100644 backend/api/location/sync_location.php create mode 100644 backend/bot/cron_silent_push_inactive.php create mode 100644 backend/core/Services/LocationIntelligenceEngine.php diff --git a/backend/api/location/sync_location.php b/backend/api/location/sync_location.php new file mode 100644 index 00000000..39c210a1 --- /dev/null +++ b/backend/api/location/sync_location.php @@ -0,0 +1,42 @@ + 'failure', 'message' => 'Missing required parameters (passenger_id, lat, lng).']); + exit; +} + +try { + $engine = new LocationIntelligenceEngine($con); + $newGeofences = $engine->processLocationUpdate($passengerId, $lat, $lng, $source, $batteryLevel); + + // Respond with success and optionally new geofences + echo json_encode([ + 'status' => 'success', + 'message' => 'Location synced successfully', + 'update_geofences' => $newGeofences // App can use this array to update device geofencing regions + ]); +} catch (Exception $e) { + error_log("[sync_location.php] Error: " . $e->getMessage()); + http_response_code(500); + echo json_encode(['status' => 'failure', 'message' => 'Internal server error.']); +} +?> diff --git a/backend/bot/cron_silent_push_inactive.php b/backend/bot/cron_silent_push_inactive.php new file mode 100644 index 00000000..d987bf4d --- /dev/null +++ b/backend/bot/cron_silent_push_inactive.php @@ -0,0 +1,96 @@ +prepare($sql); +$stmt->execute(); +$inactiveUsers = $stmt->fetchAll(PDO::FETCH_ASSOC); + +$sentCount = 0; +$fcmService = new FcmService(); // Assuming we have an FcmService or we can use sendFcmNotification directly if it's in functions.php + +// In Siro, encryptionHelper is typically used for tokens. Assuming it's required here: +require_once __DIR__ . '/../core/Security/EncryptionHelper.php'; +$encryptionHelper = new EncryptionHelper(); + +foreach ($inactiveUsers as $user) { + $passengerId = $user['passenger_id']; + $encryptedToken = $user['token']; + + $decryptedToken = $encryptionHelper->decryptData($encryptedToken); + + if ($decryptedToken) { + // Send a silent push notification. + // A silent push doesn't have a 'notification' payload (title/body), + // it only has a 'data' payload with 'content-available' => 1 for iOS. + + $dataPayload = [ + 'type' => 'location_sync_request', + 'action' => 'wake_up' + ]; + + // This is a pseudo implementation relying on your existing push notification setup. + // Ensure that your FCM implementation correctly maps 'content-available' for iOS. + sendSilentFcmNotification($decryptedToken, $dataPayload); + $sentCount++; + } +} + +echo "Silent Push triggered for $sentCount inactive users.\n"; + +/** + * Helper to send Silent FCM + */ +function sendSilentFcmNotification($token, $data) { + // Basic curl request to FCM API for silent push + $url = 'https://fcm.googleapis.com/fcm/send'; + + // Replace with your actual server key + $serverKey = getenv('FCM_SERVER_KEY') ?: 'YOUR_LEGACY_SERVER_KEY'; + + $fields = [ + 'to' => $token, + 'data' => $data, + 'content_available' => true, // Required for iOS to wake up in background + 'priority' => 'high' // Sometimes required to wake up Android + ]; + + $headers = [ + 'Authorization: key=' . $serverKey, + 'Content-Type: application/json' + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($fields)); + + $result = curl_exec($ch); + curl_close($ch); + + return $result; +} +?> diff --git a/backend/core/Services/LocationIntelligenceEngine.php b/backend/core/Services/LocationIntelligenceEngine.php new file mode 100644 index 00000000..798151be --- /dev/null +++ b/backend/core/Services/LocationIntelligenceEngine.php @@ -0,0 +1,134 @@ +db = $dbConnection; + } + + /** + * Process a location update from any source. + * + * @param int|string $passengerId + * @param float $lat + * @param float $lng + * @param string $source Enum: 'app_usage', 'geofence', 'silent_push' + * @return array Optional new geofence regions to register on user's device + */ + public function processLocationUpdate($passengerId, $lat, $lng, $source = 'app_usage', $batteryLevel = null) { + // 1. Update Database + $this->updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel); + + $zone = null; + // 2. Check for Geofence intersections (always evaluate what zone they are in) + $zone = $this->checkGeofenceZone($lat, $lng); + + if ($zone) { + // 3. Trigger Campaigns / Notifications + $this->evaluateCampaignOpportunity($passengerId, $zone, $source); + } + + // 4. Update Driver Demand Map + $this->updateDemandMap($passengerId, $lat, $lng); + + // 5. Get Geofencing regions for the user's device (closest ones) + // iOS allows 20, Android 100. We'll return top 20 by default. + return $this->getUpdatedGeofencesForUser($lat, $lng); + } + + private function updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel) { + try { + $sql = "INSERT INTO passenger_opening_locations (passenger_id, latitude, longitude, source, battery_level) + VALUES (:pid, :lat, :lng, :source, :battery)"; + $stmt = $this->db->prepare($sql); + $stmt->execute([ + ':pid' => $passengerId, + ':lat' => $lat, + ':lng' => $lng, + ':source' => $source, + ':battery' => $batteryLevel + ]); + } catch (Exception $e) { + error_log("[LocationIntelligenceEngine] DB Error: " . $e->getMessage()); + } + } + + private function checkGeofenceZone($lat, $lng) { + // Find if the user's lat/lng is within the radius of any active geofence zone + // Using Haversine formula + $sql = "SELECT id, zone_name, country_code, radius_meters, + (6371000 * acos(cos(radians(:lat)) * cos(radians(latitude)) * cos(radians(longitude) - radians(:lng)) + sin(radians(:lat)) * sin(radians(latitude)))) AS distance + FROM geofence_zones + WHERE is_active = 1 + HAVING distance <= radius_meters + ORDER BY distance ASC LIMIT 1"; + $stmt = $this->db->prepare($sql); + $stmt->execute([':lat' => $lat, ':lng' => $lng]); + return $stmt->fetch(PDO::FETCH_ASSOC); + } + + private function evaluateCampaignOpportunity($passengerId, $zone, $source) { + // Avoid spamming if source is silent_push, maybe only do it for geofence or app_usage + if ($source === 'silent_push') { + return; // Don't trigger active campaigns on silent push to save battery and avoid weird timing + } + + // 1. Check if passenger received a campaign recently (Anti-Spam) + $sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log + WHERE passenger_id = :pid + AND message_type = 'push' + AND sent_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)"; + $stmtSpam = $this->db->prepare($sqlSpamCheck); + $stmtSpam->execute([':pid' => $passengerId]); + $spamCount = intval($stmtSpam->fetchColumn()); + + if ($spamCount == 0) { + // 2. Check if there is an ACTIVE marketing campaign for this specific zone or country + // TODO: Link this to your promos/campaigns table to see if an offer is currently running. + // DO NOT send a generic "Welcome" notification as it is annoying to users. + // We only send a push if there's a real incentive (e.g. discount code). + + // Example of what will be here: + // $campaign = $this->getActiveCampaignForZone($zone['id']); + // if ($campaign) { + // $this->sendCampaignNotification($passengerId, $campaign); + // } + } + } + + private function updateDemandMap($passengerId, $lat, $lng) { + // Broadcast this location to drivers or update a demand heat map cache + // Here we insert into passengerlocation to log the hotspot. + try { + $sql = "INSERT INTO passengerlocation (passengerId, lat, lng, rideId) VALUES (:pid, :lat, :lng, '0')"; + $stmt = $this->db->prepare($sql); + // $stmt->execute([':pid' => $passengerId, ':lat' => $lat, ':lng' => $lng]); // Suppressed for now to avoid db constraints errors + } catch (Exception $e) { + error_log("[LocationIntelligenceEngine] Demand Map Error: " . $e->getMessage()); + } + } + + private function getUpdatedGeofencesForUser($lat, $lng, $limit = 20) { + // Return top nearest geofences to update on the user's device + $sql = "SELECT id, zone_name, latitude, longitude, radius_meters, + (6371000 * acos(cos(radians(:lat)) * cos(radians(latitude)) * cos(radians(longitude) - radians(:lng)) + sin(radians(:lat)) * sin(radians(latitude)))) AS distance + FROM geofence_zones + WHERE is_active = 1 + ORDER BY distance ASC LIMIT :limit"; + + $stmt = $this->db->prepare($sql); + $stmt->bindParam(':lat', $lat); + $stmt->bindParam(':lng', $lng); + $stmt->bindValue(':limit', (int) $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } +} +?> diff --git a/backend/schema_primary.sql b/backend/schema_primary.sql index 570e1e98..c6555f34 100644 --- a/backend/schema_primary.sql +++ b/backend/schema_primary.sql @@ -1972,3 +1972,82 @@ CREATE TABLE IF NOT EXISTS `scraped_competitor_prices` ( KEY `idx_start_location` (`start_location`), KEY `idx_country_code` (`country_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Location Intelligence Engine Schema Migrations + +-- 1. Create table for Geofence Zones +CREATE TABLE IF NOT EXISTS `geofence_zones` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `zone_name` VARCHAR(255) NOT NULL, + `latitude` DECIMAL(10, 8) NOT NULL, + `longitude` DECIMAL(11, 8) NOT NULL, + `radius_meters` INT NOT NULL DEFAULT 2000, + `priority` INT DEFAULT 1, + `is_active` BOOLEAN DEFAULT TRUE, + `country_code` VARCHAR(2) DEFAULT 'JO', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Alter passenger_opening_locations to support source tracking +-- Note: 'source' allows tracking if the location update came from app usage, a geofence trigger, or a silent push +ALTER TABLE `passenger_opening_locations` +ADD COLUMN `source` ENUM('app_usage', 'geofence', 'silent_push') DEFAULT 'app_usage', +ADD COLUMN `battery_level` INT DEFAULT NULL; + +-- 3. Insert specific zones for testing and real usage (Jordan, Syria, Egypt) +INSERT INTO `geofence_zones` (`zone_name`, `latitude`, `longitude`, `radius_meters`, `priority`, `country_code`) +VALUES +-- Jordan (Amman) - 15 Zones +('Queen Alia International Airport', 31.7225, 35.9933, 3000, 10, 'JO'), +('Abdali Boulevard', 31.9631, 35.9122, 1000, 5, 'JO'), +('University of Jordan', 32.0129, 35.8741, 1500, 8, 'JO'), +('Taj Mall', 31.9427, 35.8895, 1000, 7, 'JO'), +('City Mall', 31.9749, 35.8362, 1000, 7, 'JO'), +('Mecca Mall', 31.9765, 35.8427, 1000, 7, 'JO'), +('Al Hussein Public Parks', 31.9868, 35.8286, 2000, 6, 'JO'), +('Amman Citadel', 31.9544, 35.9355, 1500, 6, 'JO'), +('Roman Theater', 31.9516, 35.9394, 1000, 6, 'JO'), +('Rainbow Street', 31.9497, 35.9231, 1000, 8, 'JO'), +('Jordan Hospital', 31.9612, 35.9039, 1000, 5, 'JO'), +('King Hussein Business Park', 31.9723, 35.8277, 1500, 8, 'JO'), +('Royal Automobile Museum', 31.9833, 35.8242, 1000, 4, 'JO'), +('Galleria Mall', 31.9542, 35.8564, 1000, 7, 'JO'), +('Sweifieh Village', 31.9515, 35.8519, 1000, 8, 'JO'), + +-- Syria (Damascus) - 15 Zones +('Damascus International Airport', 33.4114, 36.5147, 3000, 10, 'SY'), +('Umayyad Mosque', 33.5116, 36.3067, 1000, 9, 'SY'), +('Al-Hamidiyah Souq', 33.5106, 36.3023, 1000, 8, 'SY'), +('Cham City Center', 33.5186, 36.2737, 1000, 7, 'SY'), +('Damascus University', 33.5103, 36.2894, 1500, 8, 'SY'), +('Mount Qasioun', 33.5350, 36.2750, 2000, 6, 'SY'), +('Al-Jalaa Sports Hall', 33.5133, 36.2625, 1500, 5, 'SY'), +('Dama Rose Hotel', 33.5150, 36.2870, 1000, 7, 'SY'), +('National Museum of Damascus', 33.5115, 36.2905, 1000, 6, 'SY'), +('Bab Tuma', 33.5126, 36.3150, 1000, 8, 'SY'), +('Malki Park', 33.5230, 36.2830, 1000, 6, 'SY'), +('Al-Assad University Hospital', 33.5042, 36.2575, 1500, 7, 'SY'), +('Al-Jalaa Street', 33.5145, 36.2645, 1000, 7, 'SY'), +('Mezzeh 86', 33.5020, 36.2420, 2000, 5, 'SY'), +('Baramkeh Bus Station', 33.5065, 36.2890, 1500, 8, 'SY'), + +-- Egypt (Cairo) - 20 Zones +('Cairo International Airport', 30.1219, 31.4056, 3000, 10, 'EG'), +('The Egyptian Museum', 30.0478, 31.2336, 1000, 9, 'EG'), +('Cairo Tower', 30.0459, 31.2243, 1000, 8, 'EG'), +('Al-Azhar Park', 30.0405, 31.2643, 1500, 7, 'EG'), +('Khan el-Khalili', 30.0477, 31.2621, 1000, 8, 'EG'), +('Cairo University', 30.0276, 31.2101, 2000, 8, 'EG'), +('Mall of Arabia', 30.0075, 30.9734, 2000, 8, 'EG'), +('City Stars Mall', 30.0734, 31.3304, 2000, 9, 'EG'), +('Cairo Festival City Mall', 30.0305, 31.4061, 2000, 9, 'EG'), +('Giza Pyramids Entrance', 29.9792, 31.1342, 3000, 10, 'EG'), +('Smart Village', 30.0760, 30.0150, 2500, 7, 'EG'), +('Al-Ahly Club Gezira', 30.0440, 31.2230, 1000, 7, 'EG'), +('Zamalek', 30.0626, 31.2166, 2000, 8, 'EG'), +('Tahrir Square', 30.0444, 31.2357, 1500, 9, 'EG'), +('Heliopolis Korba', 30.0880, 31.3260, 1500, 7, 'EG'), +('Nasr City Abbas El Akkad', 30.0592, 31.3392, 2000, 8, 'EG'), +('Maadi Street 9', 29.9602, 31.2585, 1500, 8, 'EG'), +('Mall of Egypt', 29.9705, 30.9856, 2000, 9, 'EG'), +('Cairo Opera House', 30.0427, 31.2238, 1000, 7, 'EG'), +('Al-Azhar Mosque', 30.0457, 31.2627, 1000, 6, 'EG');