Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
+19 -6
View File
@@ -33,15 +33,20 @@ SELECT
-- كانت تُنتج متوسط مدة سالباً.
(SELECT TIME_FORMAT(SEC_TO_TIME(AVG(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))), '%Hh %im') FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL AND TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish) > 0) AS driver_avg_duration,
(SELECT MAX(SEC_TO_TIME(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))) FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL) AS longest_duration,
(SELECT ROUND(SUM(distance),2) FROM ride) AS total_distance,
(SELECT ROUND(AVG(distance),2) FROM ride) AS average_distance,
(SELECT ROUND(MAX(distance),2) FROM ride) AS longest_distance,
-- المسافات تُحسب على الرحلات المكتملة فقط. حسابها على كل الصفوف كان
-- يُدخل الرحلات الملغاة و timeout (مسافتها 0 أو غير مضبوطة) في المتوسط
-- فيهبط average_distance بشكل مضلّل.
(SELECT ROUND(SUM(distance),2) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS total_distance,
(SELECT ROUND(AVG(distance),2) FROM ride WHERE LOWER(status) IN ('finished','completed') AND distance > 0) AS average_distance,
(SELECT ROUND(MAX(distance),2) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS longest_distance,
-- أرباح السائق والشركة
-- ملاحظة: خط الرحلات الحالي يكتب 'completed' بينما القديم يكتب 'Finished'،
-- والاكتفاء بالقديم كان يُرجع NULL للأرباح وصفراً للرحلات المكتملة/الملغاة.
(SELECT SUM(price_for_driver) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS total_driver_earnings,
(SELECT ROUND(AVG(price_for_passenger),2) FROM ride) AS avg_passenger_price,
-- متوسط سعر الراكب على الرحلات المكتملة فقط — الرحلات الملغاة سعرها 0
-- وكانت تسحب المتوسط إلى أسفل.
(SELECT ROUND(AVG(price_for_passenger),2) FROM ride WHERE LOWER(status) IN ('finished','completed') AND price_for_passenger > 0) AS avg_passenger_price,
-- توزيع الرحلات حسب الوقت
(SELECT COUNT(*) FROM ride WHERE HOUR(created_at) BETWEEN 6 AND 11) AS morning_ride_count,
@@ -54,12 +59,20 @@ SELECT
(SELECT COUNT(*) FROM ride WHERE carType = 'Lady') AS lady,
-- حالة الرحلات (تغطي عائلتي الحالات: القديمة CamelCase والجديدة lowercase)
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN ('wait','waiting','new','nothing','pending','searching')) AS ongoing_rides,
-- الحالات الفعلية في قاعدة الإنتاج (فحص 2026-08-03): nothing 930،
-- Begin 624، start 170، waiting 2. كانت Begin و start خارج كل الدلاء
-- الثلاثة، أي أن 794 رحلة (46% من الإجمالي) لا تظهر في أي إحصائية.
-- 'جارية' تشمل المنتظرة للسائق والمنفَّذة فعلياً معاً.
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN
('wait','waiting','new','nothing','pending','searching',
'begin','start','arrived','apply')) AS ongoing_rides,
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS completed_rides,
(SELECT COUNT(*) FROM ride WHERE LOWER(status) LIKE 'cancel%' OR LOWER(status) IN ('timeout','refused')) AS cancelled_rides,
-- عدد السائقين الفريدين
(SELECT COUNT(*) FROM (SELECT driver_id FROM ride GROUP BY driver_id) AS sub) AS num_Driver,
-- cancel_ride_by_driver.php يضبط driver_id = 0 عند الإلغاء، وكان هذا
-- الصفر يُحتسب سائقاً وهمياً إضافياً في العدد.
(SELECT COUNT(DISTINCT driver_id) FROM ride WHERE driver_id > 0) AS num_Driver,
-- التحويلات البنكية
0 AS transfer_from_count
+89
View File
@@ -0,0 +1,89 @@
<?php
// Admin/fuel/drawdowns.php — حركة القسائم وملخّص الائتمان
// POST (اختياري): station_id, status, driver_id, limit
//
// ‏الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وأي محطة تعمل؟
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$status = filterRequest('status');
$driverId = filterRequest('driver_id');
$limit = (int) (filterRequest('limit', 'int') ?: 100);
$limit = max(1, min($limit, 500));
$where = [];
$params = [];
if ($stationId > 0) {
$where[] = 'v.station_id = ?';
$params[] = $stationId;
}
if (!empty($status)) {
$where[] = 'v.status = ?';
$params[] = $status;
}
if (!empty($driverId)) {
$where[] = 'v.driver_id = ?';
$params[] = $driverId;
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
$st = $con->prepare("
SELECT v.id, v.driver_id, v.code, v.amount_authorized, v.amount_redeemed,
v.currency, v.status, v.expires_at, v.redeemed_at, v.created_at,
s.name_ar AS station_name
FROM fuel_vouchers v
LEFT JOIN fuel_stations s ON s.id = v.station_id
$clause
ORDER BY v.id DESC
LIMIT $limit
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
// ‏الملخّص على كامل المنتج لا على الصفحة المعروضة: رقمٌ يتغيّر بتغيّر
// ‏الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
$summary = $con->query("
SELECT
COALESCE(SUM(amount), 0) AS lent_total,
COALESCE(SUM(amount_collected), 0) AS collected_total,
COALESCE(SUM(amount_remaining), 0) AS outstanding_total,
COUNT(DISTINCT driver_id) AS drivers_count
FROM obligation_ledger
WHERE product_code = 'fuel_wallet'
")->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (PDOException $e) {
error_log('[admin/fuel/drawdowns] ' . $e->getMessage());
jsonError('Server error', 500);
}
$vouchers = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'driver_id' => $r['driver_id'],
'code' => $r['code'],
'amount_authorized' => (float) $r['amount_authorized'],
'amount_redeemed' => (float) $r['amount_redeemed'],
'currency' => $r['currency'],
'status' => $r['status'],
'station' => $r['station_name'],
'expires_at' => $r['expires_at'],
'redeemed_at' => $r['redeemed_at'],
'created_at' => $r['created_at'],
], $rows);
jsonSuccess([
'vouchers' => $vouchers,
'summary' => [
'lent_total' => (float) ($summary['lent_total'] ?? 0),
'collected_total' => (float) ($summary['collected_total'] ?? 0),
'outstanding_total' => (float) ($summary['outstanding_total'] ?? 0),
'drivers_count' => (int) ($summary['drivers_count'] ?? 0),
],
], 'success');
+90
View File
@@ -0,0 +1,90 @@
<?php
// Admin/fuel/list.php — قائمة محطات الوقود الشريكة
// POST (اختياري): search, city, only_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$search = filterRequest('search');
$city = filterRequest('city');
$onlyActive = filterRequest('only_active') === '1';
$where = [];
$params = [];
if (!empty($search)) {
$where[] = '(s.name_ar LIKE ? OR s.chain_name LIKE ? OR s.address LIKE ?)';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like);
}
if (!empty($city)) {
$where[] = 's.city = ?';
$params[] = $city;
}
if ($onlyActive) {
$where[] = 's.is_active = 1';
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
// ‏إحصاء الصرف مع كل محطة: لوحة بلا أرقام لا تخبر أحداً أي محطة
// ‏تعمل فعلاً وأيها اسمٌ في جدول. `redeemed` وحدها — القسائم الملغاة
// ‏والمنتهية لا تقول شيئاً عن أداء المحطة.
$st = $con->prepare("
SELECT s.*,
COUNT(v.id) AS redeem_count,
COALESCE(SUM(v.amount_redeemed), 0) AS redeem_total,
MAX(v.redeemed_at) AS last_redeem_at
FROM fuel_stations s
LEFT JOIN fuel_vouchers v
ON v.station_id = s.id AND v.status = 'redeemed'
$clause
GROUP BY s.id
ORDER BY s.is_active DESC, s.city ASC, s.name_ar ASC
LIMIT 200
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[admin/fuel/list] ' . $e->getMessage());
jsonError('Server error', 500);
}
$stations = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'name_ar' => $r['name_ar'],
'chain_name' => $r['chain_name'],
'city' => $r['city'],
'address' => $r['address'],
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
'phone' => $r['phone'],
'discount_percent' => (float) $r['discount_percent'],
'is_active' => (int) $r['is_active'] === 1,
'created_at' => $r['created_at'],
'redeem_count' => (int) $r['redeem_count'],
'redeem_total' => (float) $r['redeem_total'],
'last_redeem_at' => $r['last_redeem_at'],
// ‏المفتاح لا يخرج أبداً — لا هنا ولا في أي نقطة أخرى. مخزَّن مُهشَّراً
// ‏أصلاً، ويُعرض مرة واحدة فقط لحظة إنشائه أو تدويره.
], $rows);
// ‏حالة المنتج تُرسل مع القائمة لا في نقطة منفصلة: الشاشة تحتاج أن تعرض
// ‏«مُجهَّز وغير مُشغَّل» بوضوح، وطلبان لبناء لافتة واحدة إسراف.
$productActive = false;
try {
$st = $con->prepare("SELECT is_active FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
$st->execute();
$productActive = (int) $st->fetchColumn() === 1;
} catch (PDOException $e) {
error_log('[admin/fuel/list] تعذّرت قراءة حالة المنتج: ' . $e->getMessage());
}
jsonSuccess([
'stations' => $stations,
'product_active' => $productActive,
], 'success');
+44
View File
@@ -0,0 +1,44 @@
<?php
// Admin/fuel/rotate_key.php — توليد مفتاح جديد لمحطة
// POST: station_id
//
// ‏يُستعمل عند فقدان المحطة مفتاحها أو الشك في تسريبه. المفتاح القديم
// ‏يتوقّف فوراً — وهذا مقصود: التدوير إجراء أمني، وإبقاء القديم يعمل
// ‏«حتى تنتقل المحطة» يُفرغه من معناه.
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
if ($stationId <= 0) {
jsonError('station_id مطلوب');
}
try {
$st = $con->prepare("SELECT name_ar FROM fuel_stations WHERE id = ? LIMIT 1");
$st->execute([$stationId]);
$name = $st->fetchColumn();
if ($name === false) {
jsonError('المحطة غير موجودة', 404);
}
$plainKey = bin2hex(random_bytes(24));
$con->prepare("UPDATE fuel_stations SET api_key_hash = ? WHERE id = ?")
->execute([hash('sha256', $plainKey), $stationId]);
error_log("[admin/fuel] دُوِّر مفتاح المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'station' => $name,
'api_key' => $plainKey,
], 'المفتاح القديم توقّف. انسخ الجديد الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/rotate_key] ' . $e->getMessage());
jsonError('Server error', 500);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
// Admin/fuel/save.php — إضافة محطة أو تعديلها
// POST: station_id (اختياري للتعديل), name_ar, chain_name, city, address,
// lat, lng, phone, discount_percent, is_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$nameAr = filterRequest('name_ar');
if (empty($nameAr)) {
jsonError('اسم المحطة مطلوب');
}
$discount = (float) (filterRequest('discount_percent', 'numeric') ?: 0);
if ($discount < 0 || $discount > 100) {
jsonError('نسبة الخصم يجب أن تكون بين 0 و 100');
}
$lat = filterRequest('lat', 'numeric');
$lng = filterRequest('lng', 'numeric');
$fields = [
'name_ar' => $nameAr,
'chain_name' => filterRequest('chain_name'),
'city' => filterRequest('city'),
'address' => filterRequest('address'),
'lat' => $lat !== null ? (float) $lat : null,
'lng' => $lng !== null ? (float) $lng : null,
'phone' => filterRequest('phone'),
'discount_percent' => $discount,
'is_active' => filterRequest('is_active') === '0' ? 0 : 1,
];
try {
if ($stationId > 0) {
$sets = implode(', ', array_map(static fn($k) => "`$k` = ?", array_keys($fields)));
$st = $con->prepare("UPDATE fuel_stations SET $sets WHERE id = ?");
$st->execute([...array_values($fields), $stationId]);
if ($st->rowCount() === 0) {
// ‏rowCount صفر قد يعني «غير موجودة» وقد يعني «حُفظت بلا تغيير».
// ‏نفرّق بينهما بقراءة صريحة: إخبار المستخدم أن المحطة غير
// ‏موجودة بينما هي موجودة يدفعه لإنشاء نسخة ثانية منها.
$chk = $con->prepare("SELECT 1 FROM fuel_stations WHERE id = ? LIMIT 1");
$chk->execute([$stationId]);
if (!$chk->fetchColumn()) {
jsonError('المحطة غير موجودة', 404);
}
}
error_log("[admin/fuel] عُدّلت المحطة #$stationId بواسطة $user_id");
jsonSuccess(['station_id' => $stationId, 'created' => false], 'تم حفظ التعديلات');
}
// ── إنشاء جديد ──
// ‏المفتاح يُولَّد هنا ولا يُطلب من المستخدم: مفتاح يختاره إنسان يكون
// ‏قصيراً ومتوقَّعاً، وهذا المفتاح يأذن بصرف مال.
$plainKey = bin2hex(random_bytes(24));
$cols = array_keys($fields);
$st = $con->prepare(
'INSERT INTO fuel_stations (`' . implode('`, `', $cols) . '`, `api_key_hash`) '
. 'VALUES (' . implode(', ', array_fill(0, count($cols), '?')) . ', ?)'
);
$st->execute([...array_values($fields), hash('sha256', $plainKey)]);
$stationId = (int) $con->lastInsertId();
error_log("[admin/fuel] أُنشئت المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'created' => true,
// ‏المرة **الوحيدة** التي يظهر فيها المفتاح. مخزَّن مُهشَّراً، فلا
// ‏سبيل لاستعادته لاحقاً — من يفقده يدوّر مفتاحاً جديداً.
'api_key' => $plainKey,
], 'أُنشئت المحطة. انسخ المفتاح الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/save] ' . $e->getMessage());
jsonError('Server error', 500);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// Admin/fuel/toggle_product.php — تشغيل محفظة الوقود أو إيقافها
// POST: is_active (1 | 0)
//
// ‏قرار المالك: تُبنى الشاشات وتُجهَّز الأمور، ولا يُشغَّل المنتج إلا بعد
// ‏إتمام الاتفاق مع سلسلة المحطات. هذه هي نقطة ذلك التبديل.
//
// ‏ما الذي يتغيّر فعلاً؟ المنتج المُطفأ لا يظهر في `obligationProduct`،
// ‏فلا يستطيع سائق تفعيل محفظته ولا إصدار قسيمة. أما الالتزامات القائمة
// ‏ودفترها فلا تتأثر — الإطفاء يغلق الباب ولا يمحو ما خلفه.
require_once __DIR__ . '/../../connect.php';
// ‏super_admin وحده. تشغيل المنتج يفتح باب إقراض حقيقي بمال الشركة،
// ‏وهو قرار مالك لا قرار مناوب على اللوحة.
if ($role !== 'super_admin') {
jsonError('Unauthorized: Super admin access required', 403);
}
$isActive = filterRequest('is_active') === '1' ? 1 : 0;
try {
// ‏التشغيل بلا محطة واحدة فعّالة يعني سائقاً يفعّل محفظته، يطلب
// ‏قسيمة، ثم لا يجد أين يصرفها. الرفض هنا أرحم من اكتشافه عند مضخة.
if ($isActive === 1) {
$st = $con->query("SELECT COUNT(*) FROM fuel_stations WHERE is_active = 1");
if ((int) $st->fetchColumn() === 0) {
jsonError('لا توجد محطة فعّالة واحدة. أضف محطة قبل تشغيل المنتج.');
}
}
$st = $con->prepare("UPDATE obligation_products SET is_active = ? WHERE code = 'fuel_wallet'");
$st->execute([$isActive]);
if ($st->rowCount() === 0) {
$chk = $con->query("SELECT 1 FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
if (!$chk->fetchColumn()) {
jsonError('منتج محفظة الوقود غير موجود — نفّذ الترحيل أولاً', 404);
}
}
error_log('[admin/fuel] ' . ($isActive ? 'شُغّلت' : 'أُوقفت') . " محفظة الوقود بواسطة $user_id");
jsonSuccess(
['product_active' => $isActive === 1],
$isActive === 1
? 'شُغّلت محفظة الوقود — صارت متاحة للسائقين المؤهَّلين'
: 'أُوقفت محفظة الوقود — لا تفعيل جديد ولا قسائم جديدة'
);
} catch (PDOException $e) {
error_log('[admin/fuel/toggle_product] ' . $e->getMessage());
jsonError('Server error', 500);
}
+28
View File
@@ -1,6 +1,19 @@
<?php
require_once __DIR__ . '/../connect.php';
// هذه النقطة تُرجع بيانات ركّاب مفكوكة التشفير (هاتف، بريد، تاريخ ميلاد،
// هاتف طوارئ، وتوكن إشعارات). لم يكن فيها أي فحص للدور، و connect.php لا
// يفرض بوّابة عامة على مسار /Admin — أي أن أي مستخدم يملك JWT صالحاً، بما
// فيه راكب عادي، كان يستطيع سحب هذه البيانات كاملة.
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Admin access required.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$sql = "SELECT
`passengers`.`id`,
`passengers`.`phone`,
@@ -91,6 +104,21 @@ foreach ($result as &$row) {
}
}
}
unset($row);
// نموذج الصلاحيات: `super_admin` وحده يرى الرقم كاملاً، و`admin` يراقب برقم
// مُخفى جزئياً. توكن الإشعارات لا تعرضه الواجهة إطلاقاً ولا حاجة لإرساله —
// تسريبه يتيح انتحال إشعارات موجّهة لراكب بعينه.
$isSuperAdmin = ($role === 'super_admin');
foreach ($result as &$row) {
unset($row['passengerToken']);
if (!$isSuperAdmin) {
if (isset($row['phone'])) $row['phone'] = maskPhone($row['phone']);
if (isset($row['sosPhone'])) $row['sosPhone'] = maskPhone($row['sosPhone']);
}
}
unset($row);
if ($stmt->rowCount() > 0) {
jsonSuccess($data = $result);
@@ -1,6 +1,18 @@
<?php
require_once __DIR__ . '/../connect.php';
// نقطة تُرجع بيانات راكب مفكوكة التشفير. connect.php لا يفرض بوّابة عامة
// على مسار /Admin، فبدون هذا الفحص كان أي مستخدم يملك JWT صالحاً — بما فيه
// راكب عادي — يستطيع استعلام بيانات أي راكب آخر.
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Admin access required.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$passengerID = filterRequest("passengerID");
$sql = "SELECT
+12
View File
@@ -1,6 +1,18 @@
<?php
require_once __DIR__ . '/../connect.php';
// نقطة تُرجع بيانات راكب مفكوكة التشفير. connect.php لا يفرض بوّابة عامة
// على مسار /Admin، فبدون هذا الفحص كان أي مستخدم يملك JWT صالحاً — بما فيه
// راكب عادي — يستطيع استعلام بيانات أي راكب آخر.
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Admin access required.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$passengerEmail = $encryptionHelper->encryptData(filterRequest("passengerEmail"));
$passengerId = filterRequest("passengerId");
$passengerphone = $encryptionHelper->encryptData(filterRequest("passengerphone"));
@@ -0,0 +1,60 @@
<?php
// ============================================================
// Admin/marketing/get_social_reports.php
// تقارير الذكاء الاجتماعي للوحة التحكم
// ============================================================
//
// لماذا نقطة جديدة بدل استعمال marketing_engine/index.php?action=get_reports:
//
// • تلك نقطة **البوت** لا نقطة إدارية: تُصادَق بترويسة X-Bot-Token
// المخبوزة في APK، وتخدم طابور المهام. استعمالها من اللوحة يعني إما
// تسريب توكن البوت إلى المتصفح، أو إبقاء النقطة مفتوحة للجميع —
// وقد كانت مفتوحة فعلاً حتى فُعّلت المصادقة، فصارت تُرجع 401 للّوحة.
//
// • الشاشة كانت تستدعيها بـ http.get الخام بلا JWT ولا X-Device-FP،
// فلا تظهر في سجل CRUD ولا تخضع لأي تحقّق صلاحيات.
//
// هذه النقطة تحت /Admin فتغطّيها البوّابة الافتراضية في connect.php.
// ============================================================
require_once __DIR__ . '/../../connect.php';
try {
$limit = (int) (filterRequest('limit', 'int') ?? 50);
$limit = max(1, min(200, $limit));
// is_weekly=1 لتقارير الأسبوع فقط، أو 0 للحظية، أو تُترك فارغة للكل
$weeklyOnly = filterRequest('weekly');
$where = '';
$params = [];
if ($weeklyOnly !== null && $weeklyOnly !== '') {
$where = 'WHERE is_weekly = :weekly';
$params[':weekly'] = ((string) $weeklyOnly === '1') ? 1 : 0;
}
$stmt = $con->prepare("
SELECT id, platform, report_html, is_weekly, created_at
FROM marketing_reports
$where
ORDER BY created_at DESC
LIMIT $limit
");
$stmt->execute($params);
$reports = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($reports as &$r) {
$r['id'] = (int) $r['id'];
$r['is_weekly'] = (int) $r['is_weekly'] === 1;
}
unset($r);
jsonSuccess($reports);
} catch (Throwable $e) {
// الجدول قد يكون غير موجود على نشر لم يُطبَّق عليه الترحيل
// 2026_08_03_create_marketing_engine_tables.sql — نُبلغ بوضوح بدل
// إرجاع خطأ عام يُقرأ كـ "لا بيانات".
error_log('[get_social_reports] ' . $e->getMessage());
jsonError('تعذّر جلب تقارير الذكاء الاجتماعي. راجع سجل الخادم.', 500);
}
@@ -6,6 +6,10 @@
require_once __DIR__ . '/../../connect.php'; // تأكد أن هذا الملف يحتوي على $con_tracking
// $con_tracking لا يعرّفه connect.php — كان الاستدعاء يسقط بـ
// "Call to a member function prepare() on null".
$con_tracking = Database::get('tracking');
header("Content-Type: application/json; charset=UTF-8");
+4
View File
@@ -1,6 +1,10 @@
<?php
require_once __DIR__ . '/../../connect.php';
// $con_tracking لا يعرّفه connect.php — كان الاستدعاء يسقط بـ
// "Call to a member function prepare() on null".
$con_tracking = Database::get('tracking');
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized: Admin access required']);
+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);
}
@@ -2,7 +2,14 @@
// Admin/v2/quality/blacklist_manager.php
require_once __DIR__ . '/../../../connect.php';
require_once __DIR__ . '/../../../encrypt_decrypt.php';
require_once __DIR__ . '/../security/audit_logs_helper.php'; // إذا كان متاحاً، وإلا سننفذ الإدخال مباشرة
// audit_logs_helper.php لم يوجد قط في المستودع، والتعليق الأصلي كان
// "إذا كان متاحاً، وإلا سننفذ الإدخال مباشرة" — لكن require_once قاتل
// لا اختياري، فكانت الشاشة تسقط دائماً. الملف اختياري فعلاً: هذا الملف
// يكتب في admin_audit_log مباشرةً أدناه.
$auditHelper = __DIR__ . '/../security/audit_logs_helper.php';
if (file_exists($auditHelper)) {
require_once $auditHelper;
}
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError("Unauthorized", 403);
+5 -11
View File
@@ -1,19 +1,15 @@
<?php
// Admin/v2/security/audit_logs.php
// ── سجل تتبع ────────────────────────────────────────────
$debugFile = __DIR__ . '/../../../logs/audit_debug.txt';
$logDir = dirname($debugFile);
if (!is_dir($logDir)) @mkdir($logDir, 0750, true);
@file_put_contents($debugFile, "[" . date('Y-m-d H:i:s') . "] === REQUEST START ===\n", FILE_APPEND);
// كان هنا تتبّع تشخيصي يكتب ملفاً نصياً على القرص مع كل طلب، ويسجّل فيه
// user_id والدور. أُزيل: يراكم ملفاً بلا حد في الإنتاج ويسرّب سياق الجلسة.
// ما يستحق التسجيل يذهب إلى error_log مع الأخطاء وحدها.
try {
require_once __DIR__ . '/../../../connect.php';
@file_put_contents($debugFile, " → connect.php & encryption OK. user_id=$user_id | role=$role\n", FILE_APPEND);
} catch (Exception $e) {
@file_put_contents($debugFile, " → Loading FAILED: " . $e->getMessage() . "\n", FILE_APPEND);
error_log('[audit_logs] bootstrap failed: ' . $e->getMessage());
http_response_code(500);
printFailure('loading failed', 500);
exit;
@@ -21,7 +17,6 @@ try {
// ── فحص الصلاحيات ────────────────────────────────────────
if ($role !== 'super_admin' && $role !== 'admin') {
@file_put_contents($debugFile, " → BLOCKED: role=$role\n", FILE_APPEND);
printFailure("Unauthorized. role=$role", 403);
}
@@ -55,12 +50,11 @@ try {
}
$count = count($logs);
@file_put_contents($debugFile, " → SUCCESS: fetched $count logs\n", FILE_APPEND);
jsonSuccess($logs);
} catch (Exception $e) {
@file_put_contents($debugFile, " → QUERY ERROR: " . $e->getMessage() . "\n", FILE_APPEND);
error_log('[audit_logs] query failed: ' . $e->getMessage());
jsonError('Query failed', 500);
}
?>