Update: 2026-08-07 02:58:47

This commit is contained in:
Hamza-Ayed
2026-08-07 02:58:47 +03:00
parent e070c2d9a8
commit f0ea698ab2
7 changed files with 407 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
<?php
// ============================================================
// safety/list_incidents.php — قائمة بلاغات السلامة للوحة الإدارة
//
// ‏قراءة خالصة. الترتيب: المفتوح أولاً ثم الأحدث — غرفة العمليات تحتاج
// ‏ما لم يُعالَج بعد، لا أحدث ما وقع.
// ============================================================
// ‏تحت Admin/ عمداً: بوّابة connect.php المسارية تفرض دور admin أو
// ‏super_admin على كل ما تحت هذا المجلد. لو بقي في safety/ لقرأه أي حامل
// ‏JWT صالح — بما فيه راكب — وفيه معرّفات ومواقع أطراف الحادثة.
require_once __DIR__ . '/../../connect.php';
$status = filterRequest("status"); // open | acknowledged | resolved | false_alarm | (فارغ = الكل)
$limit = (int) (filterRequest("limit", 'int') ?: 50);
$limit = max(1, min(200, $limit));
$allowed = ['open', 'acknowledged', 'resolved', 'false_alarm'];
try {
if ($status && in_array($status, $allowed, true)) {
$stmt = $con->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);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
// ============================================================
// Admin/safety/update_incident.php — معالجة بلاغ سلامة
//
// ‏يحوّل البلاغ من "مفتوح" إلى حالة نهائية، ويرفع تجميد السائق عند
// ‏الإغلاق. بدون هذه النقطة يبقى السائق مجمّداً إلى الأبد — التجميد بلا
// ‏TTL عمداً، فرفعه قرار إنسان لا انقضاء وقت.
//
// ‏تحت Admin/ فترث بوّابة الدور من connect.php.
// ============================================================
require_once __DIR__ . '/../../connect.php';
$incidentId = filterRequest("incident_id", 'int');
$newStatus = filterRequest("status");
$note = filterRequest("note");
$releaseHold = filterRequest("release_hold");
$allowed = ['acknowledged', 'resolved', 'false_alarm'];
if (!$incidentId || !in_array($newStatus, $allowed, true)) {
jsonError("Missing incident_id or invalid status");
}
try {
$stmt = $con->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);
}
@@ -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`;
+9
View File
@@ -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());
+160
View File
@@ -0,0 +1,160 @@
<?php
// ============================================================
// safety/incident.php — تسجيل بلاغ سلامة
//
// ‏كان زر SOS ينتهي عند رسالة واتساب لجهة اتصال شخصية. الشركة لا تعلم،
// ‏ولا تملك دليلاً، ولا تستطيع التدخل. هذه النقطة هي الأثر المفقود.
//
// ‏رسالة الواتساب تبقى كما هي: التطبيق يرسلها **بالتوازي** لا بدلاً —
// ‏هي الأسرع وصولاً لمن يهمّه أمر الراكب فعلاً، وهذه النقطة للشركة.
//
// ‏ثلاثة أشياء تحدث هنا، بهذا الترتيب عمداً:
// ١. تسجيل البلاغ ولقطة الحالة ← الأهم، ولا يعتمد على شيء
// ٢. تجميد إسناد الرحلات للسائق ← يمنع تكرار الواقعة فوراً
// ٣. تنبيه الإدارة ← أفضل جهد، لا يُسقط ما قبله
// ============================================================
require_once __DIR__ . '/../connect.php';
$reporterType = filterRequest("reporter_type") ?: 'passenger';
$reporterId = filterRequest("reporter_id");
$rideId = filterRequest("ride_id");
$triggerType = filterRequest("trigger_type") ?: 'sos_button';
$note = filterRequest("note");
$lat = filterRequest("lat");
$lng = filterRequest("lng");
if (!$reporterId) {
jsonError("Missing reporter_id");
}
// ‏قائمة بيضاء: القيم تُخزَّن وتُعرض في لوحة الإدارة، ولا نريد نصاً حراً.
$allowedReporters = ['passenger', 'driver'];
$allowedTriggers = ['sos_button', 'shake', 'route_deviation', 'long_stop', 'overtime'];
if (!in_array($reporterType, $allowedReporters, true)) $reporterType = 'passenger';
if (!in_array($triggerType, $allowedTriggers, true)) $triggerType = 'sos_button';
$driverId = null;
$passengerId = null;
$snapshot = ['reported_at' => 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");
+5
View File
@@ -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";
@@ -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<RideLifecycleController>();
final locSearch = Get.find<LocationSearchController>();
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<RideLifecycleController>();
final locSearch = Get.find<LocationSearchController>();