diff --git a/backend/Admin/safety/list_incidents.php b/backend/Admin/safety/list_incidents.php new file mode 100644 index 00000000..32d3fcba --- /dev/null +++ b/backend/Admin/safety/list_incidents.php @@ -0,0 +1,52 @@ +prepare(" + SELECT * FROM safety_incidents + WHERE status = ? + ORDER BY created_at DESC + LIMIT $limit + "); + $stmt->execute([$status]); + } else { + // ‏FIELD() يضع 'open' أولاً مهما كان تاريخه — بلاغ مفتوح من أمس + // ‏أهم من بلاغ عولج قبل دقيقة. + $stmt = $con->query(" + SELECT * FROM safety_incidents + ORDER BY FIELD(status, 'open', 'acknowledged', 'resolved', 'false_alarm'), + created_at DESC + LIMIT $limit + "); + } + + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // ‏عدّاد المفتوح — الرقم الذي يظهر كشارة على اللوحة + $openCount = (int) $con->query( + "SELECT COUNT(*) FROM safety_incidents WHERE status = 'open'" + )->fetchColumn(); + + jsonSuccess(['open_count' => $openCount, 'incidents' => $rows], "ok"); + +} catch (PDOException $e) { + error_log("[safety/list] " . $e->getMessage()); + jsonError("DB Error", 500); +} diff --git a/backend/Admin/safety/update_incident.php b/backend/Admin/safety/update_incident.php new file mode 100644 index 00000000..e95ccb22 --- /dev/null +++ b/backend/Admin/safety/update_incident.php @@ -0,0 +1,88 @@ +prepare("SELECT * FROM safety_incidents WHERE id = ? LIMIT 1"); + $stmt->execute([$incidentId]); + $incident = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$incident) { + jsonError("Incident not found", 404); + } + + $con->prepare(" + UPDATE safety_incidents + SET status = ?, acknowledged_by = ?, acknowledged_at = NOW(), resolution_note = ? + WHERE id = ? + ")->execute([ + $newStatus, + $user_id ?? 'admin', + $note ? mb_substr($note, 0, 500) : null, + $incidentId, + ]); + + // ── رفع التجميد ───────────────────────────────────────── + // ‏افتراضياً يُرفع عند الإغلاق (resolved / false_alarm) ولا يُرفع عند + // ‏مجرد الاطلاع (acknowledged). ويمكن للمراجع أن يقرر صراحةً. + $shouldRelease = $releaseHold !== null + ? in_array(strtolower((string) $releaseHold), ['1', 'true', 'yes'], true) + : in_array($newStatus, ['resolved', 'false_alarm'], true); + + $released = false; + $driverId = $incident['driver_id'] ?? null; + + if ($shouldRelease && $driverId) { + try { + // ‏نرفع فقط إن كان التجميد من هذا البلاغ تحديداً: سائق عليه + // ‏بلاغ ثانٍ أحدث يجب ألا يُفرَج عنه بإغلاق الأقدم. + $upd = $con->prepare(" + UPDATE driver SET safetyHoldIncidentId = NULL + WHERE id = ? AND safetyHoldIncidentId = ? + "); + $upd->execute([$driverId, $incidentId]); + + if ($upd->rowCount() > 0) { + if (isset($redisLocation) && $redisLocation) { + $redisLocation->del("driver:$driverId:safety_hold"); + } + $released = true; + } + } catch (Throwable $e) { + error_log("[safety/update] تعذّر رفع تجميد السائق $driverId: " . $e->getMessage()); + } + } + + error_log("[safety] بلاغ #$incidentId → $newStatus بواسطة " + . ($user_id ?? 'admin') . ($released ? " (رُفع التجميد)" : "")); + + jsonSuccess([ + 'incident_id' => $incidentId, + 'status' => $newStatus, + 'hold_released' => $released, + ], "Incident updated"); + +} catch (PDOException $e) { + error_log("[safety/update] " . $e->getMessage()); + jsonError("DB Error", 500); +} diff --git a/backend/migrations/2026_08_07_safety_incidents.sql b/backend/migrations/2026_08_07_safety_incidents.sql new file mode 100644 index 00000000..79fe8fe4 --- /dev/null +++ b/backend/migrations/2026_08_07_safety_incidents.sql @@ -0,0 +1,63 @@ +-- 2026_08_07_safety_incidents.sql +-- +-- ‏غرفة عمليات السلامة — أثر يمكن مراجعته لواقعة حقيقية. +-- +-- ‏الوضع اليوم: زر SOS في تطبيق الراكب ينتهي عند رسالة واتساب لجهة اتصال +-- ‏شخصية خزّنها الراكب بنفسه (ui_interactions_controller.dart:145). لا نداء +-- ‏API، لا سجل، لا تنبيه لأحد داخل الشركة. لو وقعت حادثة فعلية، الشركة لا +-- ‏تعرف بها ولا تملك دليلاً عنها ولا تستطيع التدخل. +-- +-- ‏هذا الجدول هو الأثر المفقود. رسالة الواتساب تبقى كما هي — تُرسل بالتوازي +-- ‏لا بدلاً، لأنها الأسرع وصولاً لمن يهمّه الأمر فعلاً. +-- +-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها. + +CREATE TABLE IF NOT EXISTS `safety_incidents` ( + `id` INT NOT NULL AUTO_INCREMENT, + + -- ‏من أبلغ ولماذا + `reporter_type` VARCHAR(20) NOT NULL + COMMENT 'passenger | driver — من ضغط الزر', + `reporter_id` VARCHAR(100) NOT NULL, + `trigger_type` VARCHAR(30) NOT NULL DEFAULT 'sos_button' + COMMENT 'sos_button | shake | route_deviation | long_stop | overtime', + + -- ‏سياق الواقعة + `ride_id` VARCHAR(20) NULL DEFAULT NULL, + `driver_id` VARCHAR(100) NULL DEFAULT NULL, + `passenger_id` VARCHAR(100) NULL DEFAULT NULL, + `lat` DECIMAL(10,7) NULL DEFAULT NULL, + `lng` DECIMAL(10,7) NULL DEFAULT NULL, + `note` VARCHAR(500) NULL DEFAULT NULL, + + -- ‏لقطة الحالة وقت الواقعة، مجمّدة كما كانت. + -- ‏الرحلة تتغيّر بعد الحادثة (تُلغى، تُنهى، يُصفَّر السائق) فقراءتها لاحقاً + -- ‏لا تصف اللحظة. هذه اللقطة هي الدليل. + `snapshot` JSON NULL DEFAULT NULL, + + -- ‏دورة المعالجة + `status` VARCHAR(20) NOT NULL DEFAULT 'open' + COMMENT 'open | acknowledged | resolved | false_alarm', + `acknowledged_by` VARCHAR(100) NULL DEFAULT NULL, + `acknowledged_at` DATETIME NULL DEFAULT NULL, + `resolution_note` VARCHAR(500) NULL DEFAULT NULL, + + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (`id`), + KEY `idx_status_created` (`status`, `created_at`), + KEY `idx_ride` (`ride_id`), + KEY `idx_driver` (`driver_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='بلاغات السلامة — السجل الوحيد القابل للمراجعة لواقعة'; + +-- ‏تجميد إسناد الرحلات للسائق حتى المراجعة. +-- ‏منفصل عن blockedUntil (عقوبة الإلغاءات المتكررة): ذاك مؤقت بساعات +-- ‏وينتهي وحده، وهذا يبقى حتى يرفعه إنسان. +ALTER TABLE `driver` + ADD COLUMN `safetyHoldIncidentId` INT NULL DEFAULT NULL + COMMENT 'رقم بلاغ السلامة الذي جمّد هذا السائق. NULL = غير مجمّد'; + +-- ‏للتراجع: +-- DROP TABLE `safety_incidents`; +-- ALTER TABLE `driver` DROP COLUMN `safetyHoldIncidentId`; diff --git a/backend/ride/rides/acceptRide.php b/backend/ride/rides/acceptRide.php index 01e94aff..b0647228 100644 --- a/backend/ride/rides/acceptRide.php +++ b/backend/ride/rides/acceptRide.php @@ -64,6 +64,15 @@ try { printFailure("Your account is temporarily suspended until $blockedUntil"); exit; } + + // ‏تجميد سلامة — بلا TTL: لا ينتهي بالوقت بل برفع إنسان له. + // ‏سائق عليه بلاغ سلامة مفتوح لا يُسند له راكب جديد. + $safetyHold = $redisLocation->get("driver:$driverId:safety_hold"); + if ($safetyHold) { + error_log("[accept_ride] DriverID=$driverId مجمّد ببلاغ سلامة #$safetyHold — رُفض القبول"); + printFailure("Your account is under review. Please contact support."); + exit; + } } } catch (Throwable $eBlock) { error_log("[accept_ride] تعذّر فحص الإيقاف: " . $eBlock->getMessage()); diff --git a/backend/safety/incident.php b/backend/safety/incident.php new file mode 100644 index 00000000..2df54d3b --- /dev/null +++ b/backend/safety/incident.php @@ -0,0 +1,160 @@ + date('c'), 'trigger' => $triggerType]; + +try { + // ── ١. لقطة الحالة ────────────────────────────────────── + // ‏تُلتقط الآن لأن الرحلة تتغيّر بعد الحادثة (تُلغى، يُصفَّر السائق)، + // ‏فقراءتها لاحقاً لا تصف اللحظة التي وقع فيها البلاغ. + if ($rideId) { + $stmt = $con->prepare("SELECT * FROM ride WHERE id = ? LIMIT 1"); + $stmt->execute([$rideId]); + $ride = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($ride) { + $driverId = ($ride['driver_id'] ?? '0') !== '0' ? $ride['driver_id'] : null; + $passengerId = $ride['passenger_id'] ?? null; + + $snapshot['ride'] = [ + 'status' => $ride['status'] ?? null, + 'start_location' => $ride['start_location'] ?? null, + 'end_location' => $ride['end_location'] ?? null, + 'carType' => $ride['carType'] ?? $ride['car_type'] ?? null, + 'price' => $ride['price'] ?? null, + 'rideTimeStart' => $ride['rideTimeStart'] ?? null, + ]; + } + } + + // ‏آخر موقع معروف للسائق من Redis — أدق من أي شيء في MySQL، ويجيب + // ‏على السؤال الأول الذي تسأله غرفة العمليات: أين هم الآن؟ + if ($driverId && isset($redisLocation) && $redisLocation) { + try { + $loc = $redisLocation->hGetAll("driver:public:$driverId"); + if (!empty($loc)) { + $snapshot['driver_location'] = [ + 'lat' => $loc['lat'] ?? null, + 'lng' => $loc['lng'] ?? null, + 'heading' => $loc['heading'] ?? null, + 'speed' => $loc['speed'] ?? null, + 'updated_at' => $loc['updated_at'] ?? null, + ]; + } + } catch (Throwable $e) { + error_log("[safety] تعذّرت قراءة موقع السائق: " . $e->getMessage()); + } + } + + // ‏موقع المبلِّغ كما أرسله التطبيق. نخزّنه كما هو ولا نبني عليه قراراً — + // ‏قيمة قادمة من العميل، لكنها في بلاغ سلامة أفضل من لا شيء. + if (is_numeric($lat) && is_numeric($lng)) { + $snapshot['reporter_location'] = ['lat' => (float) $lat, 'lng' => (float) $lng]; + } + + // ── ٢. تسجيل البلاغ ───────────────────────────────────── + $ins = $con->prepare(" + INSERT INTO safety_incidents + (reporter_type, reporter_id, trigger_type, ride_id, + driver_id, passenger_id, lat, lng, note, snapshot) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $ins->execute([ + $reporterType, $reporterId, $triggerType, $rideId ?: null, + $driverId, $passengerId, + is_numeric($lat) ? $lat : null, + is_numeric($lng) ? $lng : null, + $note ? mb_substr($note, 0, 500) : null, + json_encode($snapshot, JSON_UNESCAPED_UNICODE), + ]); + $incidentId = (int) $con->lastInsertId(); + + error_log("[safety] بلاغ #$incidentId — $triggerType من $reporterType" + . " $reporterId (رحلة=" . ($rideId ?: '-') . " سائق=" . ($driverId ?: '-') . ")"); + +} catch (PDOException $e) { + error_log("[safety] فشل تسجيل البلاغ: " . $e->getMessage()); + jsonError("Failed to record incident", 500); +} + +// ── ٣. تجميد إسناد الرحلات للسائق ─────────────────────────── +// ‏يوقف تكرار الواقعة فوراً. منفصل عن blockedUntil (عقوبة الإلغاءات) الذي +// ‏ينتهي بعد ساعات وحده — هذا يبقى حتى يرفعه إنسان. +// ‏لا يُطبَّق على بلاغ السائق عن نفسه: قد يكون هو المعتدى عليه. +if ($driverId && $reporterType === 'passenger') { + try { + $con->prepare("UPDATE driver SET safetyHoldIncidentId = ? WHERE id = ?") + ->execute([$incidentId, $driverId]); + + // ‏نسخة Redis هي ما يفحصه acceptRide.php — بلا TTL: التجميد لا ينتهي بالوقت. + if (isset($redisLocation) && $redisLocation) { + $redisLocation->set("driver:$driverId:safety_hold", (string) $incidentId); + } + } catch (Throwable $e) { + error_log("[safety] تعذّر تجميد السائق $driverId: " . $e->getMessage()); + } +} + +// ── ٤. تنبيه الإدارة ──────────────────────────────────────── +// ‏أفضل جهد: البلاغ مسجَّل بالفعل، وفشل التنبيه لا يُسقطه. +try { + if (isset($redis) && $redis) { + // ‏قائمة يقرأها بانل الإدارة — أسرع من استعلام على الجدول عند كل تحديث + $redis->lPush('safety:open_incidents', (string) $incidentId); + $redis->lTrim('safety:open_incidents', 0, 199); + } + + if (function_exists('sendFCM_Internal')) { + $topic = getenv('SAFETY_ALERT_TOPIC') ?: 'siro_safety_ops'; + sendFCM_Internal( + $topic, + "🚨 بلاغ سلامة #$incidentId", + "بلاغ $triggerType من $reporterType" . ($rideId ? " — رحلة $rideId" : ""), + ['category' => 'safety_incident', 'incident_id' => (string) $incidentId], + "safety_incident", + true // topic + ); + } +} catch (Throwable $e) { + error_log("[safety] تعذّر تنبيه الإدارة للبلاغ #$incidentId: " . $e->getMessage()); +} + +jsonSuccess([ + 'incident_id' => $incidentId, + 'driver_frozen' => (bool) ($driverId && $reporterType === 'passenger'), +], "Incident recorded"); diff --git a/siro_rider/lib/constant/links.dart b/siro_rider/lib/constant/links.dart index fc67d2c7..425c482d 100644 --- a/siro_rider/lib/constant/links.dart +++ b/siro_rider/lib/constant/links.dart @@ -233,6 +233,11 @@ class AppLink { /// تستخدم نفس دالة الحساب التي يستخدمها الإلغاء الفعلي، فما يُعرض /// للراكب هو ما سيُخصم منه بالضبط. static String get previewCancelFee => "$server/ride/rides/preview_cancel_fee.php"; + + /// تسجيل بلاغ سلامة لدى الشركة. يُستدعى **بالتوازي** مع رسالة الواتساب + /// لجهة الاتصال الشخصية، لا بدلاً منها: الواتساب أسرع وصولاً لمن يهمّه + /// الأمر، وهذه النقطة هي الأثر الذي تملكه الشركة وتستطيع التدخل به. + static String get safetyIncident => "$server/safety/incident.php"; //-----------------ridessss------------------ static String get addRides => "$rideServerSide/ride/rides/add.php"; static String get getRides => "$rideServerSide/ride/rides/get.php"; diff --git a/siro_rider/lib/controller/home/map/ui_interactions_controller.dart b/siro_rider/lib/controller/home/map/ui_interactions_controller.dart index 5a24127a..b8e19fc0 100644 --- a/siro_rider/lib/controller/home/map/ui_interactions_controller.dart +++ b/siro_rider/lib/controller/home/map/ui_interactions_controller.dart @@ -128,6 +128,10 @@ class UiInteractionsController extends GetxController { kolor: AppColor.redColor, onPressed: () { Get.back(); + // ‏الشركة أولاً ثم الواتساب: تسجيل البلاغ لا ينتظر شيئاً ولا + // ‏يؤخّر الرسالة (بلا await)، لكنه يُطلق قبلها حتى لا يضيع + // ‏إن أغلق المستخدم التطبيق بعد فتح واتساب. + _reportSafetyIncident(); _shareTripDetailsSOS(); }, ), @@ -142,6 +146,32 @@ class UiInteractionsController extends GetxController { }); } + /// ‏يسجّل البلاغ لدى الشركة: يُنشئ سجل حادثة، يُجمّد إسناد الرحلات + /// ‏للسائق حتى المراجعة، ويُنبّه غرفة العمليات. + /// + /// ‏لا ينتظر ولا يرمي: زر الطوارئ يجب ألا يتعطّل أبداً بسبب الشبكة. + /// ‏أي فشل هنا لا يمنع رسالة الواتساب التي تليه. + void _reportSafetyIncident({String trigger = 'sos_button'}) { + try { + final rideLifecycle = Get.find(); + final locSearch = Get.find(); + + CRUD().post( + link: AppLink.safetyIncident, + payload: { + 'reporter_type': 'passenger', + 'reporter_id': box.read(BoxName.passengerID).toString(), + 'ride_id': rideLifecycle.rideId?.toString() ?? '', + 'trigger_type': trigger, + 'lat': locSearch.passengerLocation.latitude.toString(), + 'lng': locSearch.passengerLocation.longitude.toString(), + }, + ); + } catch (e) { + Log.print("⚠️ تعذّر تسجيل بلاغ السلامة: $e"); + } + } + void _shareTripDetailsSOS() { final rideLifecycle = Get.find(); final locSearch = Get.find();