feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9909d9b4c1
commit
4d8414c96b
@@ -0,0 +1,199 @@
|
||||
# =============================================================================
|
||||
# 🔐 Siro Project - Secure Environment Configuration
|
||||
# =============================================================================
|
||||
# ⚠️ CRITICAL: NEVER commit this file to Git!
|
||||
# Add .env to .gitignore immediately
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# Database Configuration - MAIN DATABASE
|
||||
# =============================================================================
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_NAME=siro_main
|
||||
DB_USER=siro_user
|
||||
DB_PASS=<CHANGE_ME_STRONG_PASSWORD>
|
||||
|
||||
# =============================================================================
|
||||
# Database Configuration - TRANSIT DATABASE (مواصلاتي — جامعات/مدارس/فنادق/شركات)
|
||||
# =============================================================================
|
||||
# قاعدة بيانات معزولة تماماً عن main/ride/tracking — ممنوع أي JOIN بينها وبينهم،
|
||||
# الربط بينها وبين النظام الرئيسي عبر المعرّفات (passenger_id/driver_id) فقط.
|
||||
DB_TRANSIT_HOST=localhost
|
||||
DB_TRANSIT_PORT=3306
|
||||
DB_TRANSIT_NAME=siroTransitDb
|
||||
DB_TRANSIT_USER=siroTransitUser
|
||||
DB_TRANSIT_PASS=<CHANGE_ME_STRONG_PASSWORD>
|
||||
# مفتاح تشفير الرقم الجامعي (32 byte) — منفصل عن ENC_KEY لمزيد من العزل
|
||||
TRANSIT_STUDENT_ID_KEY=<CHANGE_ME_32_CHAR_KEY>
|
||||
# Origins مسموحة للوحة الويب (مشرف المؤسسة)
|
||||
TRANSIT_ADMIN_ORIGINS=https://transit.siromove.com,https://admin.siromove.com
|
||||
# رابط تفعيل السائق (deep link في تطبيق السائق)
|
||||
APP_DEEP_LINK_BASE=https://siromove.com/driver/transit-activate
|
||||
|
||||
# =============================================================================
|
||||
# Encryption Configuration - CRITICAL FOR SECURITY
|
||||
# =============================================================================
|
||||
# 🔐 Generate 32-character hex key: openssl rand -hex 16
|
||||
ENC_KEY=<CHANGE_ME_32_BYTE_HEX_KEY>
|
||||
ENCRYPTION_KEY_PATH=/home/siro-api/env/.encryption_key
|
||||
|
||||
# =============================================================================
|
||||
# JWT Configuration
|
||||
# =============================================================================
|
||||
JWT_SECRET=<CHANGE_ME_LONG_RANDOM_STRING>
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRY=3600
|
||||
JWT_REFRESH_EXPIRY=86400
|
||||
|
||||
# =============================================================================
|
||||
# Redis Configuration
|
||||
# =============================================================================
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH=<CHANGE_ME_REDIS_PASSWORD>
|
||||
REDIS_DB=0
|
||||
|
||||
# =============================================================================
|
||||
# Rate Limiter Configuration
|
||||
# =============================================================================
|
||||
RATE_LIMIT_LOGIN_ATTEMPTS=5
|
||||
RATE_LIMIT_LOGIN_WINDOW=300
|
||||
RATE_LIMIT_API_REQUESTS=100
|
||||
RATE_LIMIT_API_WINDOW=60
|
||||
|
||||
# =============================================================================
|
||||
# Wallet Configuration - S2S API
|
||||
# =============================================================================
|
||||
WALLET_API_URL=https://walletintaleq.intaleq.xyz/v2/main/
|
||||
# 🔐 Generate HMAC secret: openssl rand -base64 32
|
||||
WALLET_HMAC_SECRET=<CHANGE_ME_LONG_HMAC_SECRET>
|
||||
BACKEND_ID=siromove-backend-01
|
||||
ALLOWED_BACKEND_IDS=siromove-backend-01,siromove-backend-02
|
||||
|
||||
# =============================================================================
|
||||
# Socket/Location Server Configuration
|
||||
# =============================================================================
|
||||
ALLOWED_SOCKET_URLS=https://location.siromove.com,https://socket.siromove.com
|
||||
SOCKET_API_TIMEOUT=10
|
||||
SOCKET_INTERNAL_KEY=<CHANGE_ME_INTERNAL_KEY>
|
||||
LOCATION_SERVER_URL=http://location.intaleq.xyz:2021
|
||||
RIDE_SOCKET_URL=http://location.intaleq.xyz:3031
|
||||
|
||||
# =============================================================================
|
||||
# CORS Configuration
|
||||
# =============================================================================
|
||||
CORS_ALLOWED_ORIGINS=https://siromove.com,https://www.siromove.com
|
||||
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
|
||||
CORS_ALLOWED_HEADERS=Content-Type,Authorization
|
||||
|
||||
# =============================================================================
|
||||
# Logging Configuration
|
||||
# =============================================================================
|
||||
LOG_LEVEL=info
|
||||
LOG_PATH=/var/log/siro-api/
|
||||
SECURITY_LOG_PATH=/var/log/siro-api/security/
|
||||
|
||||
# =============================================================================
|
||||
# Bot Configuration
|
||||
# =============================================================================
|
||||
BOT_SECRET_KEY=<CHANGE_ME_BOT_SECRET>
|
||||
FEMALE_GENDER_HASH=<CHANGE_ME_FEMALE_HASH>
|
||||
|
||||
# =============================================================================
|
||||
# Firebase Configuration
|
||||
# =============================================================================
|
||||
FIREBASE_PROJECT_ID=siro-project
|
||||
FIREBASE_API_KEY=<CHANGE_ME_FIREBASE_KEY>
|
||||
|
||||
# =============================================================================
|
||||
# Payment Gateway Configuration
|
||||
# =============================================================================
|
||||
PAYMENT_GATEWAY_URL=https://api.paymentprovider.com
|
||||
PAYMENT_GATEWAY_KEY=<CHANGE_ME_PAYMENT_KEY>
|
||||
PAYMENT_GATEWAY_SECRET=<CHANGE_ME_PAYMENT_SECRET>
|
||||
PAYMENT_WEBHOOK_SECRET=<CHANGE_ME_WEBHOOK_SECRET>
|
||||
# Internal key used for server-to-server calls (Siro Backend → Wallet Server)
|
||||
PAYMENT_KEY=<CHANGE_ME_SHARED_PAYMENT_KEY>
|
||||
|
||||
# =============================================================================
|
||||
# Wallet Servers — Multi-Country (انطلق / Wallet Intaliq)
|
||||
# =============================================================================
|
||||
# Jordan wallet server (walletintaleq.intaleq.xyz)
|
||||
WALLET_SERVER_JORDAN=https://walletintaleq.intaleq.xyz
|
||||
|
||||
# Egypt wallet server
|
||||
WALLET_SERVER_EGYPT=https://wallet-egypt.siromove.com
|
||||
|
||||
# Syria wallet server
|
||||
WALLET_SERVER_SYRIA=https://wallet-syria.siromove.com
|
||||
|
||||
# Shared S2S secret key (must match wallet server's X-S2S-Api-Key config)
|
||||
S2S_SHARED_KEY=<CHANGE_ME_S2S_SHARED_SECRET>
|
||||
|
||||
# =============================================================================
|
||||
# Siro Commissions per Country
|
||||
# =============================================================================
|
||||
# Set the commission percentage Siro takes from drivers in each country
|
||||
# (0.15 = 15%, 0.12 = 12%, 0.10 = 10%)
|
||||
SIRO_COMMISSION_JO=0.15
|
||||
SIRO_COMMISSION_SY=0.12
|
||||
SIRO_COMMISSION_EG=0.10
|
||||
SIRO_COMMISSION_IQ=0.10
|
||||
|
||||
# =============================================================================
|
||||
# SMS Configuration (for OTP)
|
||||
# =============================================================================
|
||||
SMS_PROVIDER=twilio
|
||||
SMS_API_KEY=<CHANGE_ME_SMS_KEY>
|
||||
SMS_API_SECRET=<CHANGE_ME_SMS_SECRET>
|
||||
|
||||
# =============================================================================
|
||||
# Email Configuration
|
||||
# =============================================================================
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USER=<CHANGE_ME_EMAIL>
|
||||
MAIL_PASS=<CHANGE_ME_EMAIL_PASSWORD>
|
||||
|
||||
# =============================================================================
|
||||
# Application Configuration
|
||||
# =============================================================================
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_NAME=Siro
|
||||
APP_DOMAIN=api-syria.siromove.com
|
||||
APP_COUNTRY=Jordan
|
||||
APP_CURRENCY=JOD
|
||||
|
||||
# =============================================================================
|
||||
# Nabeh Integration (server-to-server API key)
|
||||
# Must match NABEH_API_KEY in Nabeh's .env
|
||||
# =============================================================================
|
||||
NABEH_API_KEY=<CHANGE_ME_SHARED_SECRET>
|
||||
SECRET_KEY_HMAC=<CHANGE_ME_HMAC_SECRET_FOR_SIGNED_URLS>
|
||||
|
||||
# =============================================================================
|
||||
# Security Configuration - Fingerprint & Testers
|
||||
# =============================================================================
|
||||
FP_PEPPER=<CHANGE_ME_FINGERPRINT_PEPPER>
|
||||
ALLOWED_TESTER_EMAILS=driver_tester@siromove.com,passenger_tester@siromove.com
|
||||
|
||||
# =============================================================================
|
||||
# Gemini AI Configuration
|
||||
# =============================================================================
|
||||
GEMINI_API_KEY=<CHANGE_ME_GEMINI_KEY>
|
||||
|
||||
# =============================================================================
|
||||
# Feature Flags
|
||||
# =============================================================================
|
||||
FEATURE_MFA_ENABLED=true
|
||||
FEATURE_S2S_WALLET_ENABLED=true
|
||||
FEATURE_CERTIFICATE_PINNING=true
|
||||
|
||||
# =============================================================================
|
||||
# SECRETS - DO NOT EDIT OR COMMIT!
|
||||
# =============================================================================
|
||||
# This file contains secrets. Keep it secure!
|
||||
# Permissions: chmod 600 .env
|
||||
# Owner: www-data (or your web server user)
|
||||
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
logs/
|
||||
*.log
|
||||
error_log
|
||||
.gemini/
|
||||
portrate_captain_image/
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
`driver`.`id`,
|
||||
`driver`.`phone`,
|
||||
`driver`.`email`,
|
||||
`driver`.`gender`,
|
||||
`driver`.`status`,
|
||||
`driver`.`birthdate`,
|
||||
`driver`.`site`,
|
||||
`driver`.`first_name`,
|
||||
`driver`.`last_name`,
|
||||
`driver`.`employmentType`,
|
||||
`driver`.`maritalStatus`,
|
||||
`driver`.`created_at`,
|
||||
`driver`.`updated_at`,
|
||||
(
|
||||
SELECT COUNT(`driver`.`id`) FROM `driver`
|
||||
) AS countPassenger,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2))
|
||||
FROM `ratingPassenger`
|
||||
WHERE `ratingPassenger`.`driverID` = `driver`.`id`
|
||||
) AS ratingPassenger,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ratingPassenger` WHERE `driverID` = `driver`.`id`
|
||||
) AS countDriverRate,
|
||||
(
|
||||
SELECT COUNT(*) FROM `canecl` WHERE `driverID` = `driver`.`id`
|
||||
) AS countPassengerCancel,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2))
|
||||
FROM `ratingDriver`
|
||||
WHERE `driver_id` = `driver`.`id`
|
||||
) AS passengerAverageRating,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ratingDriver` WHERE `driver_id` = `driver`.`id`
|
||||
) AS countPassengerRate,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ride` WHERE `driver_id` = `driver`.`id`
|
||||
) AS countPassengerRide,
|
||||
(
|
||||
SELECT `token`
|
||||
FROM `driverToken`
|
||||
WHERE `captain_id` = `driver`.`id`
|
||||
LIMIT 1
|
||||
) AS passengerToken
|
||||
FROM `driver`
|
||||
ORDER BY passengerAverageRating DESC
|
||||
LIMIT :lim OFFSET :off";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$page = max(1, (int) filterRequest('page'));
|
||||
$limit = 10;
|
||||
$offset = ($page - 1) * $limit;
|
||||
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير الحقول الحساسة
|
||||
foreach ($result as &$row) {
|
||||
$row['phone'] = $encryptionHelper->decryptData($row['phone'] ?? '') ?: ($row['phone'] ?? '');
|
||||
$row['email'] = $encryptionHelper->decryptData($row['email'] ?? '') ?: ($row['email'] ?? '');
|
||||
$row['gender'] = $encryptionHelper->decryptData($row['gender'] ?? '') ?: ($row['gender'] ?? 'unknown yet');
|
||||
$row['birthdate'] = $encryptionHelper->decryptData($row['birthdate'] ?? '') ?: ($row['birthdate'] ?? 'unknown yet');
|
||||
$row['site'] = $encryptionHelper->decryptData($row['site'] ?? '') ?: ($row['site'] ?? 'unknown yet');
|
||||
$row['first_name'] = $encryptionHelper->decryptData($row['first_name'] ?? '') ?: ($row['first_name'] ?? '');
|
||||
$row['last_name'] = $encryptionHelper->decryptData($row['last_name'] ?? '') ?: ($row['last_name'] ?? '');
|
||||
$row['employmentType'] = $encryptionHelper->decryptData($row['employmentType'] ?? '') ?: ($row['employmentType'] ?? 'unknown yet');
|
||||
$row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus'] ?? '') ?: ($row['maritalStatus'] ?? 'unknown yet');
|
||||
}
|
||||
|
||||
$countStmt = $con->query("SELECT COUNT(*) FROM `driver`");
|
||||
$total = $countStmt->fetchColumn();
|
||||
|
||||
if (count($result) > 0) {
|
||||
jsonSuccess([
|
||||
'data' => $result,
|
||||
'total' => (int) $total,
|
||||
'page' => $page,
|
||||
'pages' => (int) ceil($total / $limit),
|
||||
]);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$driver_id = filterRequest("driver_id");
|
||||
$driverEmail = $encryptionHelper->encryptData(filterRequest("driverEmail"));
|
||||
$driverPhone = $encryptionHelper->encryptData(filterRequest("driverPhone"));
|
||||
|
||||
|
||||
/**
|
||||
* الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي.
|
||||
* تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس.
|
||||
*/
|
||||
global $blindIndex;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', filterRequest("driverEmail")) : null;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', filterRequest("driverPhone")) : null;
|
||||
|
||||
$sql = "SELECT
|
||||
`driver`.`id`,
|
||||
`driver`.`phone`,
|
||||
`driver`.`email`,
|
||||
`driver`.`gender`,
|
||||
`driver`.`status`,
|
||||
`driver`.`birthdate`,
|
||||
`driver`.`site`,
|
||||
`driver`.`first_name`,
|
||||
`driver`.`last_name`,
|
||||
`driver`.`education`,
|
||||
`driver`.`employmentType`,
|
||||
`driver`.`maritalStatus`,
|
||||
`driver`.`created_at`,
|
||||
`driver`.`updated_at`,
|
||||
(
|
||||
SELECT COUNT(*) FROM `driver`
|
||||
) AS countPassenger,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2))
|
||||
FROM `ratingPassenger`
|
||||
WHERE `ratingPassenger`.`driverID` = `driver`.`id`
|
||||
) AS ratingPassenger,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ratingPassenger` WHERE `driverID` = `driver`.`id`
|
||||
) AS countDriverRate,
|
||||
(
|
||||
SELECT COUNT(*) FROM `canecl` WHERE `driverID` = `driver`.`id`
|
||||
) AS countPassengerCancel,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2))
|
||||
FROM `ratingDriver`
|
||||
WHERE `driver_id` = `driver`.`id`
|
||||
) AS passengerAverageRating,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ratingDriver` WHERE `driver_id` = `driver`.`id`
|
||||
) AS countPassengerRate,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ride` WHERE `driver_id` = `driver`.`id`
|
||||
) AS countPassengerRide,
|
||||
(
|
||||
SELECT `token`
|
||||
FROM `driverToken`
|
||||
WHERE `captain_id` = `driver`.`id`
|
||||
LIMIT 1
|
||||
) AS passengerToken
|
||||
FROM `driver`
|
||||
WHERE `driver`.`email` = :email OR `driver`.`phone` = :phone OR `driver`.`id` = :id
|
||||
OR (:email_bidx IS NOT NULL AND `driver`.`email_bidx` = :email_bidx)
|
||||
OR (:phone_bidx IS NOT NULL AND `driver`.`phone_bidx` = :phone_bidx)
|
||||
ORDER BY passengerAverageRating DESC
|
||||
LIMIT 10
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(":email", $driverEmail);
|
||||
$stmt->bindParam(":phone", $driverPhone);
|
||||
$stmt->bindParam(":id", $driver_id);
|
||||
$stmt->bindParam(":email_bidx", $emailBidx);
|
||||
$stmt->bindParam(":phone_bidx", $phoneBidx);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير الحقول الحساسة
|
||||
foreach ($result as &$row) {
|
||||
$row['phone'] = $encryptionHelper->decryptData($row['phone']);
|
||||
$row['email'] = $encryptionHelper->decryptData($row['email']);
|
||||
$row['gender'] = $encryptionHelper->decryptData($row['gender']);
|
||||
$row['birthdate'] = $encryptionHelper->decryptData($row['birthdate']);
|
||||
$row['site'] = $encryptionHelper->decryptData($row['site']);
|
||||
$row['first_name'] = $encryptionHelper->decryptData($row['first_name']);
|
||||
$row['last_name'] = $encryptionHelper->decryptData($row['last_name']);
|
||||
$row['education'] = $encryptionHelper->decryptData($row['education']);
|
||||
$row['employmentType'] = $encryptionHelper->decryptData($row['employmentType']);
|
||||
$row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus']);
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess($result);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$driver_id = filterRequest("driver_id");
|
||||
|
||||
if (empty($driver_id)) {
|
||||
jsonError("driver_id is required", 400);
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
`driver`.`id`,
|
||||
`driver`.`phone`,
|
||||
`driver`.`email`,
|
||||
`driver`.`gender`,
|
||||
`driver`.`status`,
|
||||
`driver`.`birthdate`,
|
||||
`driver`.`site`,
|
||||
`driver`.`first_name`,
|
||||
`driver`.`last_name`,
|
||||
`driver`.`employmentType`,
|
||||
`driver`.`maritalStatus`,
|
||||
`driver`.`created_at`,
|
||||
`driver`.`updated_at`,
|
||||
(
|
||||
SELECT COUNT(*) FROM `driver`
|
||||
) AS countPassenger,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2))
|
||||
FROM `ratingPassenger`
|
||||
WHERE `ratingPassenger`.`driverID` = `driver`.`id`
|
||||
) AS ratingPassenger,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ratingPassenger`
|
||||
WHERE `ratingPassenger`.`driverID` = `driver`.`id`
|
||||
) AS countDriverRate,
|
||||
(
|
||||
SELECT COUNT(*) FROM `canecl`
|
||||
WHERE `canecl`.`driverID` = `driver`.`id`
|
||||
) AS countPassengerCancel,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2))
|
||||
FROM `ratingDriver`
|
||||
WHERE `ratingDriver`.`driver_id` = `driver`.`id`
|
||||
) AS passengerAverageRating,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ratingDriver`
|
||||
WHERE `ratingDriver`.`driver_id` = `driver`.`id`
|
||||
) AS countPassengerRate,
|
||||
(
|
||||
SELECT COUNT(*) FROM `ride`
|
||||
WHERE `ride`.`driver_id` = `driver`.`id`
|
||||
) AS countPassengerRide,
|
||||
(
|
||||
SELECT `token`
|
||||
FROM `driverToken`
|
||||
WHERE `driverToken`.`captain_id` = `driver`.`id`
|
||||
LIMIT 1
|
||||
) AS passengerToken
|
||||
FROM `driver`
|
||||
WHERE `driver`.`id` = :driver_id
|
||||
ORDER BY passengerAverageRating DESC
|
||||
LIMIT 10";
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':driver_id', $driver_id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
// بلا هذا الالتقاط كان الاستثناء يُنهي السكربت فيصل للعميل جسم فارغ
|
||||
// بحالة HTTP 200، فيظهر كـ "رد غير JSON".
|
||||
error_log("[getCaptainDetailsById] " . $e->getMessage());
|
||||
jsonError("Could not read the captain record: " . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// فك تشفير الحقول الحساسة بعد الجلب
|
||||
foreach ($result as &$row) {
|
||||
foreach (['phone','email','gender','birthdate','site','first_name','last_name','employmentType','maritalStatus'] as $f) {
|
||||
if (!array_key_exists($f, $row)) $row[$f] = null;
|
||||
}
|
||||
$row['phone'] = $encryptionHelper->decryptData($row['phone']);
|
||||
$row['email'] = $encryptionHelper->decryptData($row['email']);
|
||||
$row['gender'] = $encryptionHelper->decryptData($row['gender']);
|
||||
$row['birthdate'] = $encryptionHelper->decryptData($row['birthdate']);
|
||||
$row['site'] = $encryptionHelper->decryptData($row['site']);
|
||||
$row['first_name'] = $encryptionHelper->decryptData($row['first_name']);
|
||||
$row['last_name'] = $encryptionHelper->decryptData($row['last_name']);
|
||||
$row['employmentType'] = $encryptionHelper->decryptData($row['employmentType']);
|
||||
$row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus']);
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess($result);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$page = max(1, (int) filterRequest('page'));
|
||||
$limit = 50;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
d.phone,
|
||||
d.id,
|
||||
d.name_arabic,
|
||||
dt.token
|
||||
FROM
|
||||
`driver` d
|
||||
LEFT JOIN driverToken dt ON
|
||||
dt.captain_id = d.id
|
||||
LIMIT :lim OFFSET :off
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$countStmt = $con->query("SELECT COUNT(*) FROM `driver`");
|
||||
$total = $countStmt->fetchColumn();
|
||||
|
||||
foreach ($result as &$row) {
|
||||
$row['phone'] = $encryptionHelper->decryptData($row['phone']);
|
||||
if (!empty($row['token'])) {
|
||||
$row['token'] = $encryptionHelper->decryptData($row['token']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess([
|
||||
'data' => $result,
|
||||
'total' => (int) $total,
|
||||
'page' => $page,
|
||||
'pages' => (int) ceil($total / $limit),
|
||||
]);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$sql = "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
|
||||
) AS driver_avg_duration,
|
||||
|
||||
(
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT COUNT(driver_id) FROM ride GROUP BY driver_id
|
||||
) AS sub
|
||||
) AS num_Driver,
|
||||
|
||||
(
|
||||
SELECT COUNT(*) FROM ride
|
||||
) AS total_rides,
|
||||
|
||||
(
|
||||
SELECT COUNT(*) FROM ride WHERE status = 'waiting'
|
||||
) AS ongoing_rides,
|
||||
|
||||
(
|
||||
SELECT COUNT(*) FROM ride WHERE status = 'Finished'
|
||||
) AS completed_rides,
|
||||
|
||||
(
|
||||
SELECT COUNT(*) FROM ride WHERE status = 'cancelled'
|
||||
) AS cancelled_rides,
|
||||
|
||||
(
|
||||
SELECT TIME_FORMAT(SEC_TO_TIME(MAX(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))), '%Hh %im')
|
||||
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,
|
||||
|
||||
(
|
||||
SELECT ROUND(SUM(price_for_driver), 2) FROM ride
|
||||
) AS total_driver_earnings,
|
||||
|
||||
(
|
||||
SELECT ROUND(SUM(price_for_passenger), 2) FROM ride
|
||||
) AS total_company_earnings,
|
||||
|
||||
(
|
||||
SELECT ROUND(
|
||||
(SELECT SUM(price_for_passenger) FROM ride) /
|
||||
NULLIF((SELECT SUM(price_for_driver) FROM ride), 0),
|
||||
2
|
||||
)
|
||||
) AS companyPercent
|
||||
|
||||
FROM dual
|
||||
LIMIT 1";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess($result);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$currentYear = date('Y');
|
||||
$currentMonth = date('m');
|
||||
|
||||
// SQL to get daily ride counts
|
||||
$sql = "
|
||||
SELECT
|
||||
YEAR(date) AS year,
|
||||
MONTH(date) AS month,
|
||||
DAY(date) AS day,
|
||||
COUNT(*) AS rides_count
|
||||
FROM
|
||||
ride
|
||||
GROUP BY
|
||||
YEAR(date),
|
||||
MONTH(date),
|
||||
DAY(date)
|
||||
ORDER BY
|
||||
YEAR(date),
|
||||
MONTH(date),
|
||||
DAY(date)
|
||||
";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$dailyRides = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// SQL to get current month's total ride count
|
||||
$sqlMonth = "
|
||||
SELECT COUNT(*) AS current_month_rides_count
|
||||
FROM ride
|
||||
WHERE MONTH(date) = :currentMonth AND YEAR(date) = :currentYear
|
||||
";
|
||||
$stmtMonth = $con->prepare($sqlMonth);
|
||||
$stmtMonth->bindParam(':currentMonth', $currentMonth);
|
||||
$stmtMonth->bindParam(':currentYear', $currentYear);
|
||||
$stmtMonth->execute();
|
||||
$monthRides = $stmtMonth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Append current month total to each row (if needed)
|
||||
foreach ($dailyRides as &$row) {
|
||||
$row['current_month_rides_count'] = $monthRides['current_month_rides_count'];
|
||||
}
|
||||
|
||||
// Return result
|
||||
if ($dailyRides) {
|
||||
jsonSuccess($dailyRides);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/Staff/activate.php
|
||||
* تفعيل الحسابات المعلقة للمشرفين (Admins) وموظفي خدمة العملاء (Service) من قبل المشرف العام
|
||||
*/
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
|
||||
$userId = filterRequest('user_id');
|
||||
$type = filterRequest('type'); // 'admin' or 'service'
|
||||
|
||||
if (empty($userId) || empty($type)) {
|
||||
jsonError("رقم المستخدم ونوع الحساب مطلوبان.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// التحقق من صلاحية المشرف العام (Super Admin أو Admin فقط)
|
||||
$jwtService = new JwtService($redis);
|
||||
$auth = $jwtService->authenticate();
|
||||
$authRole = $auth->role ?? '';
|
||||
if ($authRole !== 'super_admin' && $authRole !== 'admin') {
|
||||
jsonError("غير مصرح لك. فقط المشرف العام يمكنه تفعيل الحسابات.");
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($type === 'admin') {
|
||||
$stmt = $con->prepare("UPDATE adminUser SET status = 'active' WHERE id = :id AND status = 'pending'");
|
||||
$stmt->execute([':id' => $userId]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
printSuccess(["message" => "تم تفعيل حساب المشرف بنجاح."]);
|
||||
} else {
|
||||
jsonError("لم يتم العثور على حساب مشرف معلق بهذا المعرف.");
|
||||
}
|
||||
} elseif ($type === 'service') {
|
||||
$stmt = $con->prepare("UPDATE users SET status = 'approved' WHERE id = :id AND status = 'pending' AND user_type = 'service'");
|
||||
$stmt->execute([':id' => $userId]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
printSuccess(["message" => "تم تفعيل حساب موظف الخدمة بنجاح."]);
|
||||
} else {
|
||||
jsonError("لم يتم العثور على حساب موظف خدمة معلق بهذا المعرف.");
|
||||
}
|
||||
} else {
|
||||
jsonError("نوع حساب غير صالح.");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[Staff Activate Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
exit();
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/Staff/add.php
|
||||
* إضافة موظف جديد (أدمن أو خدمة عملاء) مع تشفير البيانات وحفظ بصمة الجهاز
|
||||
*/
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
$con = Database::get('main');
|
||||
|
||||
// التحقق من الصلاحيات: فقط المشرف العام يمكنه إضافة مشرفين جدد
|
||||
$jwtService = new JwtService($redis);
|
||||
$auth = $jwtService->authenticate();
|
||||
$authRole = $auth->role ?? '';
|
||||
|
||||
$name = filterRequest("name");
|
||||
$phone = filterRequest("phone");
|
||||
$email = filterRequest("email");
|
||||
$password = filterRequest("password");
|
||||
$role = filterRequest("role"); // 'admin' or 'service'
|
||||
|
||||
// ✅ FIX H-01: تقييد إضافة المشرفين لـ super_admin فقط
|
||||
if ($role === 'admin' && $authRole !== 'super_admin') {
|
||||
jsonError("غير مصرح لك. فقط المشرف العام يمكنه إضافة مشرفين جدد.");
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($authRole !== 'super_admin' && $authRole !== 'admin') {
|
||||
jsonError("غير مصرح لك. فقط المشرفون يمكنهم إضافة موظفين.");
|
||||
exit;
|
||||
}
|
||||
$fingerprint = filterRequest("fingerprint") ?: '';
|
||||
$gender = filterRequest("gender") ?? 'Male';
|
||||
$birthdate = filterRequest("birthdate") ?? date('Y-m-d');
|
||||
$site = filterRequest("site") ?? 'main';
|
||||
$country = filterRequest("country") ?? 'Jordan';
|
||||
|
||||
if (empty($name) || empty($password) || empty($role)) {
|
||||
jsonError("Missing required fields (name, password, role).");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
// تشفير البيانات الحساسة
|
||||
$encName = $encryptionHelper->encryptData($name);
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
$encEmail = $encryptionHelper->encryptData($email);
|
||||
|
||||
// تشفير البصمة وهش البصمة (إذا تم إرسالها)
|
||||
$encFp = $fingerprint ? $encryptionHelper->encryptData($fingerprint) : '';
|
||||
$fpHash = $fingerprint ? hash('sha256', $fingerprint) : '';
|
||||
$uniqueId = bin2hex(random_bytes(16));
|
||||
|
||||
if ($role === 'admin') {
|
||||
$sql = "INSERT INTO adminUser (id, fingerprint, fingerprint_hash, name, phone, email, password, role, created_at)
|
||||
VALUES (:id, :fp, :fp_hash, :name, :phone, :email, :pass, :role, NOW())";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
':id' => $uniqueId,
|
||||
':fp' => $encFp,
|
||||
':fp_hash' => $fpHash,
|
||||
':name' => $encName,
|
||||
':phone' => $encPhone,
|
||||
':email' => $encEmail,
|
||||
':pass' => $hashedPassword,
|
||||
':role' => $role
|
||||
]);
|
||||
} else {
|
||||
// الإضافة لجدول المستخدمين (خدمة العملاء)
|
||||
$sql = "INSERT INTO users (id, fingerprint, fingerprint_hash, phone, email, gender, password, birthdate, user_type, first_name, last_name, site, country, status, created_at)
|
||||
VALUES (:id, :fp, :fp_hash, :phone, :email, :gender, :pass, :bdate, 'service', :fname, :lname, :site, :country, 'approved', NOW())";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
':id' => $uniqueId,
|
||||
':fp' => $encFp,
|
||||
':fp_hash' => $fpHash,
|
||||
':phone' => $encPhone,
|
||||
':email' => $encEmail,
|
||||
':gender' => $gender,
|
||||
':pass' => $hashedPassword,
|
||||
':bdate' => $birthdate,
|
||||
':fname' => $encName,
|
||||
':lname' => '',
|
||||
':site' => $site,
|
||||
':country' => $country
|
||||
]);
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess("Staff member added successfully.");
|
||||
} else {
|
||||
jsonError("Failed to add staff member.");
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[Staff Add Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/Staff/pending.php
|
||||
* جلب الحسابات المعلقة للإداريين والخدمة
|
||||
*/
|
||||
// connect.php يفرض JWT — بدونه كانت هذه النقطة تكشف أسماء وأرقام
|
||||
// المشرفين المعلقين لأي زائر بلا أي مصادقة.
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$allPending = [];
|
||||
$sources = [];
|
||||
|
||||
// كل مصدر يُجلب على حدة: غياب جدول users في بعض عمليات النشر كان يُفشل
|
||||
// الطلب بالكامل ويخفي طلبات المشرفين المعلقة أيضاً.
|
||||
/**
|
||||
* بعض عمليات النشر أنشأت adminUser بلا عمود status (انظر schema_primary.sql)،
|
||||
* وعندها لا يمكن تمييز الحسابات المعلقة أصلاً. نفحص العمود أولاً لنُرجع سبباً
|
||||
* واضحاً بدل فشل عام.
|
||||
*/
|
||||
function columnExists(PDO $con, string $table, string $column): bool
|
||||
{
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?");
|
||||
$stmt->execute([$table, $column]);
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!columnExists($con, 'adminUser', 'status')) {
|
||||
throw new RuntimeException("adminUser.status column is missing — admin approvals cannot be tracked until it is added.");
|
||||
}
|
||||
|
||||
$stmt1 = $con->query("SELECT id, name, phone, role, created_at, 'admin' as type FROM adminUser WHERE status = 'pending'");
|
||||
$admins = $stmt1->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($admins as &$admin) {
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
$admin['phone'] = $encryptionHelper->decryptData($admin['phone']) ?: $admin['phone'];
|
||||
}
|
||||
unset($admin);
|
||||
|
||||
$allPending = array_merge($allPending, $admins);
|
||||
$sources['admins'] = 'ok';
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Staff Pending] adminUser query failed: " . $e->getMessage());
|
||||
$sources['admins'] = 'unavailable: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt2 = $con->query("SELECT id, first_name, last_name, phone, user_type as role, created_at, 'service' as type FROM users WHERE status = 'pending' AND user_type = 'service'");
|
||||
$services = $stmt2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($services as &$service) {
|
||||
$service['name'] = trim(
|
||||
($encryptionHelper->decryptData($service['first_name']) ?: $service['first_name']) . ' ' .
|
||||
($encryptionHelper->decryptData($service['last_name']) ?: $service['last_name'])
|
||||
);
|
||||
$service['phone'] = $encryptionHelper->decryptData($service['phone']) ?: $service['phone'];
|
||||
}
|
||||
unset($service);
|
||||
|
||||
$allPending = array_merge($allPending, $services);
|
||||
$sources['service_staff'] = 'ok';
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Staff Pending] users query failed: " . $e->getMessage());
|
||||
$sources['service_staff'] = 'unavailable';
|
||||
}
|
||||
|
||||
printSuccess([
|
||||
"data" => $allPending,
|
||||
"sources" => $sources,
|
||||
]);
|
||||
exit();
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/Staff/setup.php
|
||||
* سكربت إعداد المسؤول الأول (Super Admin)
|
||||
* ⚠️ للاستخدام لمرة واحدة فقط. يحمي نفسه بـ MIGRATION_ADMIN_KEY.
|
||||
* بعد أول تشغيل ناجح، امسح الملف من السيرفر.
|
||||
*/
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
// ── حماية بمفتاح الترحيل ────────────────────────────────
|
||||
$adminKey = filterRequest('admin_key') ?? '';
|
||||
$expectedAdminKey = getenv('MIGRATION_ADMIN_KEY');
|
||||
if (empty($adminKey) || empty($expectedAdminKey) || !hash_equals($expectedAdminKey, $adminKey)) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(['error' => 'Access denied. Admin key required.']));
|
||||
}
|
||||
|
||||
$con = Database::get('main');
|
||||
|
||||
// ── منع إعادة التهيئة إذا كان هناك مشرفون مسبقاً ─────────
|
||||
$count = $con->query("SELECT COUNT(*) FROM adminUser")->fetchColumn();
|
||||
if ($count > 0) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(['error' => 'Admin already initialized. This script runs only once.']));
|
||||
}
|
||||
|
||||
// ── كلمة المرور من البيئة أو تُنشأ عشوائياً ──────────────
|
||||
$password = getenv('SETUP_SUPER_ADMIN_PASSWORD');
|
||||
if (!$password) {
|
||||
$password = bin2hex(random_bytes(12));
|
||||
}
|
||||
$hashedPass = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
// ── بصمات افتراضية (تُستبدل عند أول تسجيل دخول فعلي) ───
|
||||
$admins = [
|
||||
[
|
||||
'name' => 'Super Admin',
|
||||
'fp' => 'SETUP_DEFAULT_FP_001',
|
||||
'role' => 'super_admin'
|
||||
]
|
||||
];
|
||||
|
||||
try {
|
||||
foreach ($admins as $admin) {
|
||||
$encName = $encryptionHelper->encryptData($admin['name']);
|
||||
$encFp = $encryptionHelper->encryptData($admin['fp']);
|
||||
$fpHash = hash('sha256', $admin['fp']);
|
||||
$uniqueId = bin2hex(random_bytes(16));
|
||||
|
||||
$sql = "INSERT INTO adminUser (id, fingerprint, fingerprint_hash, name, password, role, created_at)
|
||||
VALUES (:id, :fp, :fp_hash, :name, :pass, :role, NOW())";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
':id' => $uniqueId,
|
||||
':fp' => $encFp,
|
||||
':fp_hash' => $fpHash,
|
||||
':name' => $encName,
|
||||
':pass' => $hashedPass,
|
||||
':role' => $admin['role']
|
||||
]);
|
||||
}
|
||||
echo "<h1>Initialization Successful</h1>";
|
||||
} catch (Exception $e) {
|
||||
echo "An internal error occurred";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
$deviceNumber = filterRequest("deviceNumber");
|
||||
$name = filterRequest("name");
|
||||
$password = filterRequest("password");
|
||||
$role = filterRequest("role") ?? 'admin';
|
||||
|
||||
if (empty($name) || empty($password)) {
|
||||
jsonError("Name and password are required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// Hash the password for security
|
||||
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$sql = "INSERT INTO `adminUser`(`id`, `device_number`, `name`, `password`, `role`) VALUES (
|
||||
UUID(),
|
||||
:deviceNumber,
|
||||
:name,
|
||||
:password,
|
||||
:role
|
||||
)";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
':deviceNumber' => $deviceNumber,
|
||||
':name' => $name,
|
||||
':password' => $hashedPassword,
|
||||
':role' => $role
|
||||
]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess("Admin user data saved successfully");
|
||||
} else {
|
||||
jsonError("Failed to save admin user data");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[Admin Add Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
// عرض كافة الأخطاء
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$invoiceNumber = filterRequest("invoiceNumber");
|
||||
$amount = filterRequest("amount");
|
||||
$date = filterRequest("date");
|
||||
$name = filterRequest("name");
|
||||
$driverID = filterRequest("driverID") ?? '0';
|
||||
|
||||
$linkImage = null;
|
||||
$uploadDate = date("Y-m-d H:i:s");
|
||||
|
||||
// ✅ طباعة بيانات الإدخال للتأكد
|
||||
error_log("[add_invoice.php] 📥 Data received | invoiceNumber: $invoiceNumber, amount: $amount, date: $date");
|
||||
|
||||
// التحقق من وجود ملف الصورة
|
||||
if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
|
||||
$image_file = $_FILES['image'];
|
||||
$image_name = $image_file['name'];
|
||||
$image_extension = strtolower(pathinfo($image_name, PATHINFO_EXTENSION));
|
||||
$allowed_extensions = ['jpg', 'jpeg', 'png'];
|
||||
|
||||
if (!in_array($image_extension, $allowed_extensions)) {
|
||||
error_log("[add_invoice.php] ❌ Invalid image extension: .$image_extension");
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid file type.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime_type = finfo_file($finfo, $image_file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
$allowed_mime_types = ['image/jpeg', 'image/png', 'image/jpg'];
|
||||
if (!in_array($mime_type, $allowed_mime_types)) {
|
||||
error_log("[add_invoice.php] ❌ Invalid MIME type: $mime_type");
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid file type (MIME mismatch).']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$new_filename = $invoiceNumber . '.' . $image_extension;
|
||||
$target_dir = "invoice_images/";
|
||||
$target_file = $target_dir . $new_filename;
|
||||
|
||||
if (!is_dir($target_dir)) {
|
||||
if (!mkdir($target_dir, 0755, true)) {
|
||||
error_log("[add_invoice.php] ❌ Failed to create directory: $target_dir");
|
||||
}
|
||||
}
|
||||
|
||||
if (!move_uploaded_file($image_file['tmp_name'], $target_file)) {
|
||||
error_log("[add_invoice.php] ❌ Failed to move uploaded file.");
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to upload image.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'api.siromove.com';
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
|
||||
$linkImage = "$protocol://$host/siro/Admin/adminUser/invoice_images/" . $new_filename;
|
||||
error_log("[add_invoice.php] ✅ Image uploaded successfully: $linkImage");
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare("INSERT INTO invoice_records (driverID, invoice_number, name, amount, date, image_link, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$driverID, $invoiceNumber, $name, $amount, $date, $linkImage, $uploadDate]);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Invoice data saved.',
|
||||
'image' => $linkImage
|
||||
]);
|
||||
|
||||
error_log("[add_invoice.php] ✅ Invoice saved successfully.");
|
||||
} catch (PDOException $e) {
|
||||
$errorMsg = $e->getMessage();
|
||||
error_log("[add_invoice.php] 🛑 PDO ERROR: $errorMsg");
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "Database error occurred: " . $errorMsg()
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$device_number = filterRequest("device_number");
|
||||
|
||||
$sql = "SELECT
|
||||
*
|
||||
FROM
|
||||
`adminUser`
|
||||
WHERE
|
||||
`device_number` = '$device_number'";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (count($result) === 1) {
|
||||
// Print the first record as a success message
|
||||
jsonSuccess($result[0]);
|
||||
} else {
|
||||
// Print a failure message
|
||||
jsonError($message = "Failed to retrieve Password or user name incorrect");
|
||||
}
|
||||
?>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 MiB |
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// ✅ استرجاع كل الفواتير من قاعدة البيانات
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT * FROM invoice_records ORDER BY date DESC");
|
||||
$stmt->execute();
|
||||
$invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ✅ حساب عدد الفواتير ومجموع المبالغ
|
||||
$count = count($invoices);
|
||||
$totalAmount = array_sum(array_column($invoices, 'amount'));
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"data" => $invoices,
|
||||
"summary" => [
|
||||
"count" => $count,
|
||||
"total" => $totalAmount
|
||||
]
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode([
|
||||
"status" => "error",
|
||||
"message" => "An internal error occurred"
|
||||
]);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$driverID = $_POST['driverID'] ?? 'MISSING';
|
||||
$invoiceNumber = $_POST['invoiceNumber'] ?? 'MISSING';
|
||||
$name = $_POST['name'] ?? 'MISSING';
|
||||
$amount = $_POST['amount'] ?? 'MISSING';
|
||||
$date = $_POST['date'] ?? 'MISSING';
|
||||
$uploadDate = date("Y-m-d H:i:s");
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare("INSERT INTO invoice_records (driverID, invoice_number, name, amount, date, image_link, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$driverID, $invoiceNumber, $name, $amount, $date, null, $uploadDate]);
|
||||
echo json_encode(['status' => 'success', 'message' => 'OK']);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/auth/approve_admin.php
|
||||
* الموافقة على أو رفض طلبات انضمام المشرفين
|
||||
* مسموح فقط للسوبر أدمن
|
||||
*/
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden. Super Admin access required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$targetId = filterRequest('admin_id');
|
||||
$action = filterRequest('action'); // approved, rejected, suspended
|
||||
|
||||
if (empty($targetId) || empty($action)) {
|
||||
jsonError("Admin ID and action are required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($action, ['approved', 'rejected', 'suspended'])) {
|
||||
jsonError("Invalid action.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
$sql = "UPDATE adminUser SET status = :status, approved_by = :by, approved_at = NOW() WHERE id = :id";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
':status' => $action,
|
||||
':by' => $user_id, // السوبر أدمن الحالي
|
||||
':id' => $targetId
|
||||
]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
printSuccess(null, "Admin status updated to $action.");
|
||||
} else {
|
||||
jsonError("Admin not found or status already updated.");
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[Approve Admin Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/auth/list_pending.php
|
||||
* عرض قائمة المشرفين الذين ينتظرون الموافقة
|
||||
* مسموح فقط للسوبر أدمن
|
||||
*/
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// التحقق من الصلاحيات
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden. Super Admin access required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
$stmt = $con->prepare("SELECT id, name, phone, created_at FROM adminUser WHERE status = 'pending' ORDER BY created_at DESC");
|
||||
$stmt->execute();
|
||||
$pending = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير الأسماء
|
||||
foreach ($pending as &$admin) {
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
}
|
||||
|
||||
printSuccess($pending);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[List Pending Admins Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/auth/login.php
|
||||
* تسجيل دخول المشرفين باستخدام البصمة وكلمة المرور ونظام OTP الموحد (Nabeh API)
|
||||
*/
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
|
||||
// $encryptionHelper is already initialized by bootstrap.php (lines 159, 182)
|
||||
global $encryptionHelper;
|
||||
|
||||
$fingerprint = filterRequest('fingerprint');
|
||||
$password = filterRequest('password');
|
||||
$phone = filterRequest('phone');
|
||||
$audience = filterRequest('aud') ?? 'admin';
|
||||
$isRenewal = filterRequest('is_renewal') === '1';
|
||||
|
||||
if (empty($fingerprint) || empty($password)) {
|
||||
jsonError("Fingerprint and password are required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate Limiting
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'login');
|
||||
|
||||
// تتبع المحاولات الفاشلة لكل حساب
|
||||
if ($redis && !empty($phone)) {
|
||||
$accountKey = "login_attempts:account:" . hash('sha256', $phone);
|
||||
$accountAttempts = (int) $redis->get($accountKey);
|
||||
|
||||
if ($accountAttempts >= 5) {
|
||||
$ttl = $redis->ttl($accountKey);
|
||||
$waitMinutes = ceil($ttl / 60);
|
||||
jsonError("تم تعليق تسجيل الدخول لهذا الحساب مؤقتاً. يرجى المحاولة بعد {$waitMinutes} دقيقة.");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// البحث عن المشرف باستخدام بصمة الجهاز (Fingerprint Hash)
|
||||
$fpHash = hash('sha256', $fingerprint);
|
||||
$isTrustedDevice = false;
|
||||
|
||||
// تسجيل محاولة تسجيل الدخول للتدقيق
|
||||
$loginAuditData = [
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'fingerprint_hash' => $fpHash,
|
||||
'phone_hash' => !empty($phone) ? hash('sha256', $phone) : null,
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'result' => 'pending'
|
||||
];
|
||||
error_log("[LOGIN_AUDIT] " . json_encode($loginAuditData));
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$stmt = $con->prepare("SELECT * FROM adminUser WHERE fingerprint_hash = :fp LIMIT 1");
|
||||
$stmt->execute([':fp' => $fpHash]);
|
||||
$admin = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($admin) {
|
||||
$isTrustedDevice = true;
|
||||
} else if (!empty($phone)) {
|
||||
// 1. بحث بالـ ID أو الفهارس العمياء (الهاتف / البريد) لضمان السرعة والتوافق مع التشفير المتغير
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('adminUser.phone', $phone) : null;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('adminUser.email', $phone) : null;
|
||||
|
||||
$sql = "SELECT * FROM adminUser WHERE id = :id";
|
||||
$params = [':id' => $phone];
|
||||
|
||||
if ($phoneBidx) {
|
||||
$sql .= " OR phone_bidx = :phone_bidx";
|
||||
$params[':phone_bidx'] = $phoneBidx;
|
||||
}
|
||||
if ($emailBidx) {
|
||||
$sql .= " OR email_bidx = :email_bidx";
|
||||
$params[':email_bidx'] = $emailBidx;
|
||||
}
|
||||
$sql .= " LIMIT 1";
|
||||
|
||||
$stmtId = $con->prepare($sql);
|
||||
$stmtId->execute($params);
|
||||
$admin = $stmtId->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// 2. إذا لم يتم العثور بالـ ID، نفحص الحقول المشفّرة (email / phone / name) عبر فك التشفير
|
||||
if (!$admin) {
|
||||
$stmtAll = $con->query("SELECT * FROM adminUser");
|
||||
while ($row = $stmtAll->fetch(PDO::FETCH_ASSOC)) {
|
||||
$decPhone = ($encryptionHelper && !empty($row['phone'])) ? $encryptionHelper->decryptData($row['phone']) : $row['phone'];
|
||||
$decEmail = ($encryptionHelper && !empty($row['email'])) ? $encryptionHelper->decryptData($row['email']) : $row['email'];
|
||||
$decName = ($encryptionHelper && !empty($row['name'])) ? $encryptionHelper->decryptData($row['name']) : $row['name'];
|
||||
|
||||
if ($phone === $decPhone || $phone === $decEmail || $phone === $decName || $phone === $row['phone'] || $phone === $row['email']) {
|
||||
$admin = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// فحص ما إذا كانت بصمة الجهاز محفوظة ومطابقة للجهاز الحالي
|
||||
if ($admin && !empty($admin['fingerprint_hash']) && hash_equals($admin['fingerprint_hash'], $fpHash)) {
|
||||
$isTrustedDevice = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($admin) {
|
||||
// 1. التحقق من حالة الحساب
|
||||
if (isset($admin['status'])) {
|
||||
if ($admin['status'] === 'pending') {
|
||||
jsonError("حسابك قيد المراجعة حالياً. يرجى الانتظار للموافقة.");
|
||||
exit;
|
||||
} elseif ($admin['status'] === 'suspended') {
|
||||
jsonError("هذا الحساب معلق. يرجى التواصل مع المدير.");
|
||||
exit;
|
||||
} elseif ($admin['status'] === 'rejected') {
|
||||
jsonError("تم رفض طلب الانضمام لهذا الحساب.");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. التحقق من كلمة المرور
|
||||
if (password_verify($password, $admin['password'])) {
|
||||
|
||||
// إذا كان الجهاز موثوقاً (البصمة محفوظة ومطابقة) أو طلب تجديد توكن تلقائي
|
||||
if ($isTrustedDevice || $isRenewal) {
|
||||
$encFpRaw = $encryptionHelper ? $encryptionHelper->encryptData($fingerprint) : $fingerprint;
|
||||
$updateStmt = $con->prepare("UPDATE adminUser SET fingerprint = :fp_raw, fingerprint_hash = :fp WHERE id = :id");
|
||||
$updateStmt->execute([
|
||||
':fp_raw' => $encFpRaw,
|
||||
':fp' => $fpHash,
|
||||
':id' => $admin['id']
|
||||
]);
|
||||
$admin['fingerprint_hash'] = $fpHash;
|
||||
|
||||
$jwtService = new JwtService($redis);
|
||||
$role = $admin['role'] ?? 'admin';
|
||||
|
||||
if ($redis) {
|
||||
$oldJti = $redis->get("active_jti:" . $admin['id']);
|
||||
if ($oldJti) {
|
||||
$jwtService->revokeToken($oldJti, 3600);
|
||||
}
|
||||
}
|
||||
|
||||
$jwt = $jwtService->generateAccessToken($admin['id'], $role, $audience, $fingerprint);
|
||||
|
||||
if ($encryptionHelper && !empty($admin['name'])) {
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
}
|
||||
unset($admin['password']);
|
||||
|
||||
printSuccess([
|
||||
"message" => "Login successful",
|
||||
"admin" => $admin,
|
||||
"jwt" => $jwt,
|
||||
"expires_in" => 3600
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. توليد رمز تحقق OTP (3 أرقام) وإرساله عبر نظام OTP الموحد (Nabeh API للواتساب)
|
||||
$otp = (string)random_int(100, 999);
|
||||
$encryptedPhone = $admin['phone'] ?? '';
|
||||
|
||||
$rawPhone = ($encryptionHelper && !empty($encryptedPhone)) ? $encryptionHelper->decryptData($encryptedPhone) : $encryptedPhone;
|
||||
if (!$rawPhone || empty($rawPhone)) {
|
||||
$rawPhone = $encryptedPhone;
|
||||
}
|
||||
|
||||
// تحميل موزع خدمات OTP عبر Nabeh API
|
||||
require_once __DIR__ . '/../../auth/otp/providers.php';
|
||||
|
||||
$success = false;
|
||||
if (function_exists('sendNabehOtp')) {
|
||||
$success = sendNabehOtp($rawPhone, $otp, 'whatsapp', 'admin');
|
||||
}
|
||||
|
||||
// تخزين OTP (SHA-256 hash) في جدول token_verification_admin
|
||||
$otpHash = hash('sha256', $otp);
|
||||
$stmt = $con->prepare("INSERT INTO token_verification_admin (phone_number, token, expiration_time)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 10 MINUTE))
|
||||
ON DUPLICATE KEY UPDATE token = VALUES(token), expiration_time = VALUES(expiration_time)");
|
||||
$stmt->execute([$encryptedPhone, $otpHash]);
|
||||
|
||||
$maskedPhone = (strlen($rawPhone) > 7) ? substr($rawPhone, 0, 4) . '****' . substr($rawPhone, -3) : $rawPhone;
|
||||
|
||||
printSuccess([
|
||||
"status" => "otp_required",
|
||||
"message" => $success ? "تم إرسال رمز التحقق إلى WhatsApp الخاص بك." : "فشل إرسال واتساب. تحقق من error_log لمعرفة OTP.",
|
||||
"phone" => $maskedPhone
|
||||
]);
|
||||
exit;
|
||||
} else {
|
||||
jsonError("كلمة المرور غير صحيحة.");
|
||||
}
|
||||
} else {
|
||||
jsonError("الحساب غير موجود. يرجى التأكد من اسم المستخدم أو البريد الإلكتروني وكلمة المرور.");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Admin Login Throwable Error] " . $e->getMessage() . "\nTrace: " . $e->getTraceAsString());
|
||||
jsonError("حدث خطأ في السيرفر: " . $e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/auth/loginWallet.php
|
||||
* توليد توكن خاص بسيرفر المحفظة (Wallet SSO)
|
||||
* يتم توقيعه بالمفتاح المشترك (SECRET_KEY_PAY)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
|
||||
// التحقق من الجلسة الحالية للأدمن
|
||||
$jwtService = new JwtService($redis ?? null);
|
||||
$admin = $jwtService->authenticate();
|
||||
|
||||
error_log("[Wallet_SSO] Authenticated Admin ID: " . ($admin->user_id ?? 'N/A') . " | Role: " . ($admin->role ?? 'N/A'));
|
||||
|
||||
if ($admin->role !== 'admin' && $admin->role !== 'super_admin') {
|
||||
jsonError("Unauthorized. Admin access required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// جلب المفتاح المشترك لسيرفر المحفظة من متغير البيئة أو الملف
|
||||
$payKeyPath = getenv('SECRET_KEY_PAY_PATH');
|
||||
$payKey = ($payKeyPath && file_exists($payKeyPath)) ? trim(file_get_contents($payKeyPath)) : getenv('SECRET_KEY_PAY');
|
||||
|
||||
if (empty($payKey)) {
|
||||
$fallbackPath = getenv('SECRET_KEY_PATH');
|
||||
$payKey = ($fallbackPath && file_exists($fallbackPath)) ? trim(file_get_contents($fallbackPath)) : null;
|
||||
}
|
||||
|
||||
if (empty($payKey)) {
|
||||
jsonError("Internal configuration error: Shared secret key missing.");
|
||||
exit;
|
||||
}
|
||||
|
||||
$issuer = 'Tripz-Wallet';
|
||||
$audience = 'Tripz-Wallet';
|
||||
$hmacSecret = getenv('SECRET_KEY_HMAC') ?: '';
|
||||
|
||||
$ttl = 600; // 10 دقائق
|
||||
$iat = time();
|
||||
$exp = $iat + $ttl;
|
||||
$jti = bin2hex(random_bytes(16));
|
||||
|
||||
// محتوى التوكن (Payload)
|
||||
$payload = [
|
||||
'iss' => $issuer,
|
||||
'aud' => $audience,
|
||||
'user_id' => $admin->user_id,
|
||||
'role' => 'admin', // نرسل 'admin' للمحفظة لضمان التوافق مع برمجياتها القديمة
|
||||
'iat' => $iat,
|
||||
'exp' => $exp,
|
||||
'jti' => $jti
|
||||
];
|
||||
|
||||
// إلغاء التوكن القديم إذا وجد في Redis
|
||||
if ($redis) {
|
||||
$oldJtiKey = "wallet_jti:" . $admin->user_id;
|
||||
$oldJti = $redis->get($oldJtiKey);
|
||||
if ($oldJti) {
|
||||
// إضافة التوكن القديم للقائمة السوداء
|
||||
$redis->setex("jwt:blacklist:$oldJti", $ttl + 60, '1');
|
||||
}
|
||||
// تخزين الـ JTI الجديد
|
||||
$redis->setex($oldJtiKey, $ttl, $jti);
|
||||
}
|
||||
|
||||
// إضافة بصمة الجهاز للتوكن لزيادة الأمان
|
||||
$fpHeader = $_SERVER['HTTP_X_DEVICE_FP'] ?? null;
|
||||
$fpPepper = getenv('FP_PEPPER');
|
||||
if ($fpHeader && $fpPepper) {
|
||||
$payload['fingerPrint'] = hash('sha256', $fpHeader . $fpPepper);
|
||||
}
|
||||
|
||||
// توليد التوكن
|
||||
$jwt = JWT::encode($payload, $payKey, 'HS256');
|
||||
|
||||
// حساب الـ HMAC Hash المطلوب لسيرفر المحفظة
|
||||
$hmacHash = hash_hmac('sha256', (string)$admin->user_id, $hmacSecret);
|
||||
|
||||
printSuccess([
|
||||
"status" => "success",
|
||||
"jwt" => $jwt,
|
||||
"hmac" => $hmacHash,
|
||||
"expires_in" => $ttl
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[Admin Wallet SSO Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/auth/register.php
|
||||
* التسجيل الذاتي للمشرفين (Admins) مع التحقق من الصلاحيات من ملف .env
|
||||
*/
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
|
||||
$name = filterRequest('name');
|
||||
$phone = filterRequest('phone');
|
||||
$password = filterRequest('password');
|
||||
$fingerprint = filterRequest('fingerprint');
|
||||
|
||||
if (empty($name) || empty($phone) || empty($password) || empty($fingerprint)) {
|
||||
jsonError("جميع الحقول مطلوبة بما فيها بصمة الجهاز.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. التحقق من البيئة (Environment Whitelist)
|
||||
$allowedPhonesStr = getenv('AUTHORIZED_ADMIN_PHONES');
|
||||
if (!$allowedPhonesStr) {
|
||||
// في حال لم يتم إعداد المتغير، نرفض الجميع للأمان
|
||||
jsonError("غير مصرح لك بالتسجيل كمشرف (القائمة البيضاء غير معدة).");
|
||||
exit;
|
||||
}
|
||||
|
||||
$allowedPhones = array_map('trim', explode(',', $allowedPhonesStr));
|
||||
if (!in_array($phone, $allowedPhones)) {
|
||||
jsonError("أنت غير مصرح لك بالتسجيل كمشرف. يرجى مراجعة الإدارة.");
|
||||
exit;
|
||||
}
|
||||
|
||||
$con = Database::get('main');
|
||||
|
||||
// 2. التحقق من عدم وجود الحساب مسبقاً (عن طريق الهاتف أو البصمة)
|
||||
$fpHash = hash('sha256', $fingerprint);
|
||||
$encPhoneInput = $encryptionHelper->encryptData($phone);
|
||||
|
||||
$check = $con->prepare("SELECT id FROM adminUser WHERE phone = ? OR fingerprint_hash = ? LIMIT 1");
|
||||
$check->execute([$encPhoneInput, $fpHash]);
|
||||
|
||||
if ($check->rowCount() > 0) {
|
||||
jsonError("رقم الهاتف أو الجهاز مسجل مسبقاً.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. تجهيز البيانات
|
||||
$uniqueId = bin2hex(random_bytes(16)); // UUID آمن (32 حرف hex عشوائي)
|
||||
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$encName = $encryptionHelper->encryptData($name);
|
||||
$encPhone = $encPhoneInput;
|
||||
$encFp = $encryptionHelper->encryptData($fingerprint);
|
||||
|
||||
// 4. الإدخال في قاعدة البيانات بحالة pending
|
||||
$sql = "INSERT INTO adminUser (id, fingerprint, fingerprint_hash, name, phone, password, role, status, created_at)
|
||||
VALUES (:id, :fp, :fp_hash, :name, :phone, :pass, 'admin', 'pending', NOW())";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
':id' => $uniqueId,
|
||||
':fp' => $encFp,
|
||||
':fp_hash' => $fpHash,
|
||||
':name' => $encName,
|
||||
':phone' => $encPhone,
|
||||
':pass' => $hashedPassword
|
||||
]);
|
||||
|
||||
printSuccess([
|
||||
"status" => "pending",
|
||||
"message" => "تم تسجيل حسابك بنجاح وهو الآن قيد المراجعة. يرجى انتظار تفعيل المشرف العام."
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[Admin Register Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
|
||||
exit();
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// send_otp_admin.php — إرسال رمز التحقق لمسؤول عبر WhatsApp
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
error_log("--- [send_otp_admin] Script started ---");
|
||||
|
||||
// جلب الرقم من الطلب
|
||||
$receiver = filterRequest("receiver");
|
||||
//error_log("[send_otp_admin] Received phone number: " . var_export($receiver, true));
|
||||
|
||||
if (!$receiver) {
|
||||
// error_log("[send_otp_admin] Missing phone number");
|
||||
jsonError("رقم الهاتف مفقود.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// قراءة الأرقام المصرح بها من ENV
|
||||
$allowedPhones = explode(',', getenv('ADMIN_PHONE_NUMBERS'));
|
||||
//error_log("[send_otp_admin] Allowed phones: " . implode(', ', $allowedPhones));
|
||||
|
||||
if (!in_array($receiver, $allowedPhones)) {
|
||||
error_log("[send_otp_admin] Unauthorized phone number attempted: $receiver");
|
||||
jsonError("رقم الهاتف غير مصرح له.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// توليد رمز تحقق عشوائي
|
||||
$otp = rand(10000, 99999);
|
||||
$messageBody = "رمز التحقق الخاص بك للدخول إلى لوحة الإدارة هو: $otp";
|
||||
//error_log("[send_otp_admin] Generated OTP: $otp for $receiver");
|
||||
|
||||
// إرسال الرسالة عبر WhatsApp
|
||||
$success = sendWhatsAppFromServer($receiver, $messageBody);
|
||||
error_log("[send_otp_admin] WhatsApp sending result: " . ($success ? "success" : "failure"));
|
||||
|
||||
if ($success) {
|
||||
try {
|
||||
$stmt = $con->prepare("INSERT INTO token_verification_admin (phone_number, token, expiration_time)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE))
|
||||
ON DUPLICATE KEY UPDATE token = VALUES(token), expiration_time = VALUES(expiration_time)");
|
||||
$stmt->execute([$receiver, $otp]);
|
||||
// error_log("[send_otp_admin] OTP saved to database successfully for $receiver");
|
||||
|
||||
jsonSuccess(null, "OTP sent successfully.");
|
||||
} catch (PDOException $e) {
|
||||
// error_log("[send_otp_admin] Database error: " . $e->getMessage());
|
||||
jsonError("حدث خطأ في حفظ الرمز.");
|
||||
}
|
||||
} else {
|
||||
// error_log("[send_otp_admin] Failed to send WhatsApp message to $receiver");
|
||||
jsonError("فشل في إرسال الرمز عبر WhatsApp.");
|
||||
}
|
||||
|
||||
//error_log("--- [send_otp_admin] Script ended ---");
|
||||
?>
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/auth/verify_login.php
|
||||
* الخطوة الثانية من تسجيل الدخول: التحقق من الـ OTP وإصدار التوكن النهائي
|
||||
*/
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
|
||||
$otp = filterRequest('otp');
|
||||
$fingerprint = filterRequest('fingerprint'); // مطلوب لربط التوكن بالجهاز
|
||||
$audience = filterRequest('aud') ?? 'admin';
|
||||
|
||||
if (empty($otp) || empty($fingerprint)) {
|
||||
jsonError("OTP and fingerprint are required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate Limiting: 3 محاولات OTP في 5 دقائق لكل IP
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'otp');
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// 1. جلب بيانات المسؤول عبر البصمة أو من الـ OTP المعلق للجهاز الجديد
|
||||
$fpHash = hash('sha256', $fingerprint);
|
||||
$stmt = $con->prepare("SELECT * FROM adminUser WHERE fingerprint_hash = :fp LIMIT 1");
|
||||
$stmt->execute([':fp' => $fpHash]);
|
||||
$admin = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$otpHash = hash('sha256', (string)$otp);
|
||||
|
||||
if (!$admin) {
|
||||
// إذا كانت البصمة جديدة وغير مسجلة بعد، نبحث عن الحساب المرتبط بـ OTP المعلق
|
||||
$stmtOtp = $con->prepare("SELECT phone_number FROM token_verification_admin WHERE token = ? AND expiration_time >= NOW() LIMIT 1");
|
||||
$stmtOtp->execute([$otpHash]);
|
||||
$otpRow = $stmtOtp->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($otpRow && !empty($otpRow['phone_number'])) {
|
||||
// $targetPhone هو الرقم المشفر من token_verification_admin (نفس القيمة المخزنة في adminUser.phone)
|
||||
$targetPhone = $otpRow['phone_number'];
|
||||
global $encryptionHelper;
|
||||
|
||||
// البحث المباشر: phone المشفر مطابق لنفس النص المشفر في adminUser.phone
|
||||
$stmtAdmin = $con->prepare("SELECT * FROM adminUser WHERE phone = :p1 OR id = :p2 LIMIT 1");
|
||||
$stmtAdmin->execute([':p1' => $targetPhone, ':p2' => $targetPhone]);
|
||||
$admin = $stmtAdmin->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// مسار احتياطي: فك التشفير لمقارنة القيم (ضروري لـ AES-GCM حيث التشفير غير حتمي)
|
||||
if (!$admin) {
|
||||
$decTarget = ($encryptionHelper && !empty($targetPhone)) ? $encryptionHelper->decryptData($targetPhone) : null;
|
||||
$stmtAll = $con->query("SELECT * FROM adminUser");
|
||||
while ($row = $stmtAll->fetch(PDO::FETCH_ASSOC)) {
|
||||
// مقارنة مباشرة للنصوص المشفرة (نفس ciphertext)
|
||||
if ($targetPhone === $row['phone']) {
|
||||
$admin = $row;
|
||||
break;
|
||||
}
|
||||
// مقارنة عبر فك التشفير (AES-GCM: ciphertexts مختلفة لنفس النص)
|
||||
if ($decTarget) {
|
||||
$decPhone = ($encryptionHelper && !empty($row['phone'])) ? $encryptionHelper->decryptData($row['phone']) : $row['phone'];
|
||||
if ($decTarget === $decPhone) {
|
||||
$admin = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$admin) {
|
||||
jsonError("المسؤول غير موجود أو رمز التحقق غير صالح.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. رقم الهاتف المشفر (للاستخدام في جدول OTP)
|
||||
$encryptedPhone = $admin['phone'] ?? '';
|
||||
|
||||
// فك تشفيره لو احتجنا إرساله أو عرضه، لكن هنا نحن نحتاج المشفر للبحث
|
||||
// $phone = $encryptionHelper->decryptData($encryptedPhone);
|
||||
|
||||
// 3. التحقق من الـ OTP (الهاش محسوب مسبقاً في المتغير $otpHash)
|
||||
$stmt = $con->prepare("SELECT * FROM token_verification_admin
|
||||
WHERE phone_number = ? AND token = ?
|
||||
AND expiration_time >= NOW()");
|
||||
$stmt->execute([$encryptedPhone, $otpHash]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonError("رمز التحقق غير صالح أو منتهي الصلاحية.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// حذف الرمز بعد استخدامه لمرة واحدة (باستخدام الرقم المشفر)
|
||||
$con->prepare("DELETE FROM token_verification_admin WHERE phone_number = ?")->execute([$encryptedPhone]);
|
||||
|
||||
// 4. تحديث وتأكيد بصمة المتصفح/الجهاز الحالية للمسؤول في قاعدة البيانات بعد التحقق الناجح من OTP
|
||||
$encFpRaw = ($encryptionHelper && !empty($fingerprint)) ? $encryptionHelper->encryptData($fingerprint) : $fingerprint;
|
||||
$updateFpStmt = $con->prepare("UPDATE adminUser SET fingerprint = :fp_raw, fingerprint_hash = :fp WHERE id = :id");
|
||||
$updateFpStmt->execute([
|
||||
':fp_raw' => $encFpRaw,
|
||||
':fp' => $fpHash,
|
||||
':id' => $admin['id']
|
||||
]);
|
||||
$admin['fingerprint_hash'] = $fpHash;
|
||||
|
||||
// 5. إصدار التوكن النهائي
|
||||
$jwtService = new JwtService($redis);
|
||||
$role = $admin['role'] ?? 'admin';
|
||||
|
||||
// إلغاء التوكن القديم إذا وجد في Redis (Token Revocation)
|
||||
if ($redis) {
|
||||
$oldJti = $redis->get("active_jti:" . $admin['id']);
|
||||
if ($oldJti) {
|
||||
$jwtService->revokeToken($oldJti, 3600);
|
||||
}
|
||||
}
|
||||
|
||||
$jwt = $jwtService->generateAccessToken($admin['id'], $role, $audience, $fingerprint);
|
||||
|
||||
// فك تشفير البيانات للعرض
|
||||
if ($encryptionHelper && !empty($admin['name'])) {
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
}
|
||||
unset($admin['password']);
|
||||
|
||||
printSuccess([
|
||||
"message" => "Login successful",
|
||||
"admin" => $admin,
|
||||
"jwt" => $jwt,
|
||||
"expires_in" => 3600
|
||||
]);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Admin Verify OTP Error] " . $e->getMessage() . "\nTrace: " . $e->getTraceAsString());
|
||||
jsonError("Server Error: " . $e->getMessage() . " on line " . $e->getLine());
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$phone = filterRequest("phone_number");
|
||||
$otp = filterRequest("otp");
|
||||
$deviceNumber = filterRequest("device_number");
|
||||
|
||||
if (empty($phone) || empty($otp)) {
|
||||
jsonError("رقم الهاتف أو رمز التحقق مفقود.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// التحقق من رمز التحقق (OTP)
|
||||
$stmt = $con->prepare("SELECT * FROM token_verification_admin
|
||||
WHERE phone_number = ? AND token = ?
|
||||
AND expiration_time >= NOW()");
|
||||
$stmt->execute([$phone, $otp]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// ✅ تحقق ناجح - ننتقل إلى إدخال أو تحديث سجل adminUser
|
||||
|
||||
// تحقق إن كان المستخدم موجود مسبقًا
|
||||
$checkAdmin = $con->prepare("SELECT * FROM adminUser WHERE name = ?");
|
||||
$checkAdmin->execute([$phone]);
|
||||
|
||||
$now = date("Y-m-d H:i:s");
|
||||
|
||||
if ($checkAdmin->rowCount() > 0) {
|
||||
// المستخدم موجود ✅ تحديث device_number و updated_at
|
||||
$update = $con->prepare("UPDATE adminUser
|
||||
SET device_number = ?, updated_at = ?
|
||||
WHERE name = ?");
|
||||
$update->execute([$deviceNumber, $now, $phone]);
|
||||
jsonSuccess(["message" => "verified and updated existing admin"]);
|
||||
} else {
|
||||
// المستخدم غير موجود ✅ إدخال جديد
|
||||
$insert = $con->prepare("INSERT INTO adminUser (device_number, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)");
|
||||
$insert->execute([$deviceNumber, $phone, $now, $now]);
|
||||
jsonSuccess(["message" => "verified and new admin created"]);
|
||||
}
|
||||
|
||||
} else {
|
||||
// ❌ رمز التحقق غير صالح
|
||||
jsonError("رمز التحقق غير صالح أو منتهي.");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// التحقق من الصلاحيات: مسموح فقط للأدمن والسوبر أدمن
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
-- العدادات العامة
|
||||
(SELECT COUNT(*) FROM passengers) AS countPassengers,
|
||||
(SELECT COUNT(*) FROM driver) AS countDriver,
|
||||
(SELECT COUNT(*) FROM ride) AS countRide,
|
||||
|
||||
-- إحصائيات الشهر الحالي
|
||||
(SELECT COUNT(*) FROM passengers WHERE created_at BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE())) AS countPassengersThisMonth,
|
||||
(SELECT COUNT(*) FROM driver WHERE created_at BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE())) AS countDriverThisMonth,
|
||||
(SELECT COUNT(*) FROM ride WHERE created_at BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE())) AS countRideThisMonth,
|
||||
(SELECT COUNT(*) FROM CarRegistration WHERE created_at BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE())) AS countCarRegistrationThisMonth,
|
||||
|
||||
-- شكاوى
|
||||
(SELECT COUNT(*) FROM complaint WHERE date_filed BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE())) AS countComplaintThisMonth,
|
||||
(SELECT COUNT(*) FROM complaint WHERE date_filed BETWEEN DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 6 DAY)) AS countComplaintThisWeek,
|
||||
(SELECT COUNT(*) FROM complaint WHERE DATE(date_filed) = CURDATE()) AS countComplaintToday,
|
||||
|
||||
-- المحافظ والتحويلات
|
||||
|
||||
-- إحصائيات وقت ومسافة الرحلات
|
||||
-- تُستثنى الفروق السالبة (رحلات سجّلت وقت نهاية أقدم من البداية) لأنها
|
||||
-- كانت تُنتج متوسط مدة سالباً.
|
||||
(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,
|
||||
|
||||
-- أرباح السائق والشركة
|
||||
-- ملاحظة: خط الرحلات الحالي يكتب '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,
|
||||
|
||||
-- توزيع الرحلات حسب الوقت
|
||||
(SELECT COUNT(*) FROM ride WHERE HOUR(created_at) BETWEEN 6 AND 11) AS morning_ride_count,
|
||||
(SELECT COUNT(*) FROM ride WHERE HOUR(created_at) BETWEEN 12 AND 17) AS evening_ride_count,
|
||||
(SELECT COUNT(*) FROM ride WHERE HOUR(created_at) BETWEEN 18 AND 23 OR HOUR(created_at) BETWEEN 0 AND 5) AS night_ride_count,
|
||||
|
||||
-- أنواع الرحلات
|
||||
(SELECT COUNT(*) FROM ride WHERE carType = 'Comfort') AS comfort,
|
||||
(SELECT COUNT(*) FROM ride WHERE carType = 'Speed') AS speed,
|
||||
(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,
|
||||
(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,
|
||||
|
||||
-- التحويلات البنكية
|
||||
0 AS transfer_from_count
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($result) {
|
||||
jsonSuccess($result);
|
||||
} else {
|
||||
jsonError("No dashboard data found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: هذه النقطة تحذف سجلاً نهائياً من قاعدة البيانات.
|
||||
// connect.php يتحقق من صحة التوكن فقط، فبدون هذا الفحص كان أي توكن صالح
|
||||
// (سائق أو راكب) قادراً على حذف السائقين.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$driver_id = filterRequest("driver_id");
|
||||
$phone = filterRequest("phone");
|
||||
$reason = filterRequest("reason"); // يمكن أن يأتي من البارامتر أو نخليه افتراضي
|
||||
|
||||
if (empty($driver_id) || empty($phone)) {
|
||||
jsonError("Driver ID and phone are required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// تشفير رقم الهاتف
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
|
||||
// حذف السائق من جدول driver
|
||||
$sqlDel = "DELETE FROM driver WHERE id = :id";
|
||||
$stmtDel = $con->prepare($sqlDel);
|
||||
$stmtDel->bindParam(':id', $driver_id, PDO::PARAM_INT);
|
||||
$stmtDel->execute();
|
||||
|
||||
if ($stmtDel->rowCount() > 0) {
|
||||
// إضافة بيانات السائق المحذوف إلى البلاك ليست
|
||||
$sqlInsert = "INSERT INTO blacklist_driver (driver_id, phone, reason)
|
||||
VALUES (:driver_id, :phone, :reason)";
|
||||
$stmtInsert = $con->prepare($sqlInsert);
|
||||
$stmtInsert->execute([
|
||||
'driver_id' => $driver_id,
|
||||
'phone' => $encPhone,
|
||||
'reason' => !empty($reason) ? $reason : "Deleted & blacklisted by admin"
|
||||
]);
|
||||
|
||||
jsonSuccess(null, "Driver deleted and blacklisted successfully.");
|
||||
} else {
|
||||
jsonError("No driver found with the provided ID.");
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[deleteCaptain.php] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$driver_id = filterRequest("driver_id");
|
||||
|
||||
// Prepare the DELETE query
|
||||
$sql = "DELETE FROM `car_locations` WHERE driver_id = :driver_id";
|
||||
$stmt = $con->prepare($sql);
|
||||
|
||||
// Bind the driver_id parameter
|
||||
$stmt->bindParam(':driver_id', $driver_id, PDO::PARAM_STR);
|
||||
|
||||
try {
|
||||
// Execute the query
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// Success response
|
||||
jsonSuccess(null, "Record(s) deleted successfully.");
|
||||
} else {
|
||||
// Failure response: no records found to delete
|
||||
jsonError("No records found for the provided driver ID.");
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
// Handle any SQL errors
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$phone = filterRequest("phone");
|
||||
|
||||
if (empty($phone)) {
|
||||
jsonError("Phone number is required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
/**
|
||||
* البحث عبر الفهرس الأعمى أولاً (phone_bidx): مطابقة تامة عبر فهرس مُهيأ
|
||||
* ولا تعتمد على كون التشفير حتمياً، فتظل تعمل بعد النقل إلى AES-GCM.
|
||||
*
|
||||
* يُبقى المسار القديم (مقارنة النص المشفّر) كاحتياط حتى ينتهي تشغيل
|
||||
* scripts/backfill_blind_index.php، وإلا لتوقّف البحث بين الترحيل والتعبئة.
|
||||
*/
|
||||
global $blindIndex;
|
||||
$driver = null;
|
||||
|
||||
if ($blindIndex) {
|
||||
$bidx = $blindIndex->index('driver.phone', $phone);
|
||||
if ($bidx) {
|
||||
$stmt = $con->prepare("SELECT * FROM driver WHERE phone_bidx = :bidx LIMIT 1");
|
||||
$stmt->execute([':bidx' => $bidx]);
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$driver) {
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
$stmt = $con->prepare("SELECT * FROM driver WHERE phone = :phone LIMIT 1");
|
||||
$stmt->execute([':phone' => $encPhone]);
|
||||
}
|
||||
|
||||
if (!$driver) {
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
if ($driver) {
|
||||
// ✅ الحقول المشفرة اللي لازم تنفك:
|
||||
$encryptedFields = [
|
||||
'phone',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'national_number',
|
||||
'address','gender','site',
|
||||
'birthdate',
|
||||
'name_arabic',
|
||||
];
|
||||
|
||||
foreach ($encryptedFields as $field) {
|
||||
if (!empty($driver[$field])) {
|
||||
$driver[$field] = $encryptionHelper->decryptData($driver[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ احذف كلمة المرور من النتيجة
|
||||
unset($driver['password']);
|
||||
|
||||
jsonSuccess($driver);
|
||||
|
||||
} else {
|
||||
jsonError("No driver found with this phone.");
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[find_driver_by_phone.php] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(`car_locations`.driver_id) AS driver_count,
|
||||
driver.id,
|
||||
driver.phone,
|
||||
driver.name_arabic,
|
||||
MAX(dt.token) AS token
|
||||
FROM
|
||||
`car_locations`
|
||||
LEFT JOIN driver ON driver.id = car_locations.driver_id
|
||||
LEFT JOIN driverToken dt ON dt.captain_id = driver.id
|
||||
WHERE
|
||||
`car_locations`.created_at > TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY))
|
||||
GROUP BY
|
||||
driver.id
|
||||
ORDER BY
|
||||
driver_count DESC
|
||||
LIMIT 19;
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك التشفير للحقول الحساسة
|
||||
foreach ($rows as &$row) {
|
||||
if (!empty($row['phone'])) {
|
||||
$row['phone'] = $encryptionHelper->decryptData($row['phone']);
|
||||
}
|
||||
if (!empty($row['name_arabic'])) {
|
||||
$row['name_arabic'] = $encryptionHelper->decryptData($row['name_arabic']);
|
||||
}
|
||||
if (!empty($row['token'])) {
|
||||
$row['token'] = $encryptionHelper->decryptData($row['token']);
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess($rows);
|
||||
} else {
|
||||
jsonError($message = "No recent driver location activity found");
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$phone = filterRequest("phone");
|
||||
|
||||
// تنظيف الرقم من أي مسافات أو رموز زائدة
|
||||
$phone = preg_replace('/[^0-9]/', '', $phone);
|
||||
|
||||
// احتمالات الرقم (بالصفر الدولي أو بدونه)
|
||||
$phoneVariants = [];
|
||||
$phoneVariants[] = $phone; // كما هو (مثلاً 0992952235)
|
||||
|
||||
if (str_starts_with($phone, '0')) {
|
||||
$phoneVariants[] = '963' . substr($phone, 1); // تحويل 09 إلى 9639
|
||||
} elseif (str_starts_with($phone, '963')) {
|
||||
$phoneVariants[] = '0' . substr($phone, 3); // تحويل 9639 إلى 09
|
||||
}
|
||||
|
||||
// Encrypt each variant to see if any match the encrypted column
|
||||
$encVariants = [];
|
||||
foreach ($phoneVariants as $v) {
|
||||
$encVariants[] = $encryptionHelper->encryptData($v);
|
||||
}
|
||||
|
||||
error_log("[GIFT_CHECK] Received Phone: " . $phone);
|
||||
error_log("[GIFT_CHECK] Variants: " . implode(', ', $phoneVariants));
|
||||
|
||||
// بناء استعلام يبحث عن كل الاحتمالات (المشفرة وغير المشفرة)
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($encVariants as $i => $ev) {
|
||||
$placeholders[] = "phone = :enc$i";
|
||||
$params[":enc$i"] = $ev;
|
||||
}
|
||||
foreach ($phoneVariants as $i => $pv) {
|
||||
$placeholders[] = "phone = :raw$i";
|
||||
$params[":raw$i"] = $pv;
|
||||
}
|
||||
|
||||
$sql = "SELECT * FROM `driver` WHERE " . implode(" OR ", $placeholders);
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
foreach ($params as $key => $val) {
|
||||
$stmt->bindValue($key, $val);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Decrypt sensitive fields
|
||||
foreach ($rows as &$row) {
|
||||
if (!empty($row['phone'])) {
|
||||
$row['phone'] = $encryptionHelper->decryptData($row['phone']);
|
||||
}
|
||||
if (!empty($row['name_arabic'])) {
|
||||
$row['name_arabic'] = $encryptionHelper->decryptData($row['name_arabic']);
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess($rows);
|
||||
|
||||
} else {
|
||||
jsonError("No recent driver location activity found");
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: رفع الحظر عملية إدارية، وكانت هذه النقطة بلا أي فحص دور.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$phone = filterRequest("phone");
|
||||
|
||||
if (empty($phone)) {
|
||||
jsonError("Phone number is required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// تشفير الرقم للمطابقة مع المخزن
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
|
||||
$sql = "DELETE FROM blacklist_driver WHERE phone = :phone";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':phone' => $encPhone]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess(null, "Driver removed from blacklist successfully.");
|
||||
} else {
|
||||
jsonError("No driver found in blacklist with this phone.");
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[remove_from_blacklist.php] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// 🔥 [Fix Broken Access Control] كان يتحقق من صلاحية التوكن فقط — أي مستخدم
|
||||
// مسجّل دخول كان يقدر يغيّر حالة أي سائق (تفعيل/رفض) أو رقم هاتفه.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$driver_id = filterRequest("id");
|
||||
$phone = filterRequest("phone");
|
||||
$status = filterRequest("status");
|
||||
|
||||
if (empty($driver_id)) {
|
||||
jsonError("Driver ID is required.");
|
||||
}
|
||||
|
||||
$updateFields = [];
|
||||
$params = [':id' => $driver_id];
|
||||
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$encphone = $encryptionHelper->encryptData($phone);
|
||||
$updateFields[] = "`phone` = :phone";
|
||||
$params[':phone'] = $encphone;
|
||||
|
||||
// الفهرس يُحدَّث مع الرقم نفسه حتى لا يشير إلى القيمة القديمة
|
||||
global $blindIndex;
|
||||
if ($blindIndex) {
|
||||
$updateFields[] = "`phone_bidx` = :phone_bidx";
|
||||
$params[':phone_bidx'] = $blindIndex->index('driver.phone', $phone);
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== null && $status !== '') {
|
||||
$updateFields[] = "`status` = :status";
|
||||
$params[':status'] = $status;
|
||||
}
|
||||
|
||||
if (empty($updateFields)) {
|
||||
jsonError("No parameters provided for update.");
|
||||
}
|
||||
|
||||
$sql = "UPDATE `driver` SET " . implode(", ", $updateFields) . " WHERE `id` = :id";
|
||||
$stmt = $con->prepare($sql);
|
||||
|
||||
try {
|
||||
$stmt->execute($params);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
logAudit($con, $user_id, "تعديل بيانات سائق من لوحة التحكم", "driver", $driver_id, [
|
||||
"phone" => $phone,
|
||||
"status" => $status
|
||||
]);
|
||||
|
||||
// إذا تم تفعيل السائق، نرسل له رسالة ترحيبية عبر الواتساب لتأكيد التفعيل
|
||||
if ($status === 'active' || $status === 'actives') {
|
||||
// جلب معلومات السائق لإرسال الرسالة
|
||||
$selectSql = "SELECT `phone`, `first_name` FROM `driver` WHERE `id` = :id";
|
||||
$selectStmt = $con->prepare($selectSql);
|
||||
$selectStmt->execute([':id' => $driver_id]);
|
||||
$driverData = $selectStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($driverData) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($driverData['phone']);
|
||||
$firstName = $encryptionHelper->decryptData($driverData['first_name']);
|
||||
|
||||
$supportPhones = ['0952475740', '0952475742'];
|
||||
$randomIndex = array_rand($supportPhones);
|
||||
$phoneToUse = $supportPhones[$randomIndex];
|
||||
$randomNumber = rand(1000, 999999);
|
||||
|
||||
$messageBody = "أهلاً وسهلاً كابتن $firstName 👋\n"
|
||||
. "تم تفعيل حسابك على تطبيق *سيرو*.\n"
|
||||
. "يمكنك الآن تسجيل الدخول والبدء بالعمل مباشرة.\n"
|
||||
. "للمساعدة تواصل معنا على الرقم: $phoneToUse\n"
|
||||
. "نتمنى لك عمل موفق 🚖\n\n"
|
||||
. "معرف الرسالة: $randomNumber";
|
||||
|
||||
sendWhatsAppFromServer($decryptedPhone, $messageBody);
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess(null, "Driver updated successfully.");
|
||||
} else {
|
||||
jsonError("No records updated or driver not found.");
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log("[updateDriverFromAdmin.php] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// Get the data from the request
|
||||
$name = filterRequest("name");
|
||||
$education = filterRequest("education");
|
||||
$site = filterRequest("site");
|
||||
$phone = filterRequest("phone");
|
||||
$status = filterRequest("status");
|
||||
$id=filterRequest("id");
|
||||
|
||||
// Set the current timestamp for the 'created_at' field
|
||||
$created_at = date("Y-m-d H:i:s");
|
||||
|
||||
// Prepare the SQL insert query using parameterized statements to avoid SQL injection
|
||||
$sql = "INSERT INTO `employee` (`id`,`name`, `education`, `site`, `phone`, `created_at`, `status`)
|
||||
VALUES (?,?, ?, ?, ?, ?, ?)";
|
||||
|
||||
// Prepare and execute the statement
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$id, $name, $education, $site, $phone, $created_at, $status]);
|
||||
|
||||
// Check if the query successfully inserted the record
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// If a row was inserted, print success
|
||||
jsonSuccess($message = "Employee record added successfully");
|
||||
} else {
|
||||
// If no rows were inserted, print failure
|
||||
jsonError($message = "Failed to add employee record");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// Prepare the SQL query to select all records from the employee table with a limit of 10
|
||||
$sql = "SELECT
|
||||
*
|
||||
FROM
|
||||
`employee` e
|
||||
ORDER BY
|
||||
e.created_at
|
||||
DESC
|
||||
";
|
||||
|
||||
// Prepare and execute the statement
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
|
||||
// Fetch all records as an associative array
|
||||
$employee_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Check if any records were retrieved
|
||||
if ($employee_data) {
|
||||
// If records were found, print the data as JSON
|
||||
jsonSuccess($data = $employee_data);
|
||||
} else {
|
||||
// If no records were found, print a failure message
|
||||
jsonError($message = "No employee records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// File: /v1/admin/error/error_list_last20.php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
try {
|
||||
$sql = "SELECT `id`, `error`, `userId`, `userType`, `phone`, `created_at`, `device`, `details`, `status`
|
||||
FROM `error`
|
||||
ORDER BY `created_at` DESC
|
||||
LIMIT 20";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonSuccess($rows);
|
||||
} catch (Exception $e) {
|
||||
error_log("error_list_last20.php: " . $e->getMessage());
|
||||
jsonError($message = "Failed to fetch last 20 errors");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// File: /v1/admin/error/error_search_by_phone.php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
try {
|
||||
$phone = filterRequest("phone");
|
||||
if ($phone === false || $phone === null || trim($phone) === "") {
|
||||
jsonError("Phone is required");
|
||||
exit;
|
||||
}
|
||||
|
||||
// في حال مخزّن الهاتف مشفّر، طبق نفس دالتك للتشفير هنا:
|
||||
// $enc_phone = $encryptionHelper->encryptData(trim($phone));
|
||||
// ثم بدّل الحقل في WHERE إلى phone = :ph
|
||||
$sql = "SELECT `id`, `error`, `userId`, `userType`, `phone`, `created_at`, `device`, `details`, `status`
|
||||
FROM `error`
|
||||
WHERE `phone` = :ph OR `phone` LIKE :phLike
|
||||
ORDER BY `created_at` DESC
|
||||
LIMIT 20";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
":ph" => trim($phone),
|
||||
":phLike" => '%' . trim($phone) . '%', // يسمح بجزء من الرقم إن أردت
|
||||
]);
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
jsonSuccess($rows);
|
||||
} catch (Exception $e) {
|
||||
error_log("error_search_by_phone.php: " . $e->getMessage());
|
||||
jsonError($message = "Failed to search errors by phone");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// Allow any authenticated user to report errors, but validate input
|
||||
$error = filterRequest("error");
|
||||
$userId = filterRequest("userId");
|
||||
$userType = filterRequest("userType");
|
||||
$phone = filterRequest("phone");
|
||||
$device = filterRequest("device");
|
||||
$details = filterRequest("details");
|
||||
|
||||
// Sanitize log input to prevent log injection
|
||||
$safeError = str_replace(["\r", "\n"], ' ', substr($error ?? '', 0, 500));
|
||||
$safeUserId = str_replace(["\r", "\n"], ' ', substr($userId ?? '', 0, 50));
|
||||
$safeUserType = str_replace(["\r", "\n"], ' ', substr($userType ?? '', 0, 50));
|
||||
$safeDevice = str_replace(["\r", "\n"], ' ', substr($device ?? '', 0, 200));
|
||||
$safeDetails = str_replace(["\r", "\n"], ' ', substr($details ?? '', 0, 1000));
|
||||
|
||||
$logMsg = "[$safeUserType ID: $safeUserId] Error: $safeError | Where: $safeDevice | Details: $safeDetails";
|
||||
appLog($logMsg, "APP_ERROR");
|
||||
|
||||
// جملة SQL لإدخال البيانات، مع إضافة الحقل الجديد
|
||||
// لاحظ أننا لا نرسل حقل 'status' لأنه سيأخذ القيمة الافتراضية 'new' تلقائياً في قاعدة البيانات
|
||||
$sql = "INSERT INTO `error` (`error`, `userId`, `userType`, `phone`, `device`, `details`)
|
||||
VALUES (:error, :userId, :userType, :phone, :device, :details)";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
|
||||
// ربط المتغيرات بالقيم
|
||||
$stmt->bindParam(':error', $error);
|
||||
$stmt->bindParam(':userId', $userId);
|
||||
$stmt->bindParam(':userType', $userType);
|
||||
$stmt->bindParam(':phone', $phone);
|
||||
$stmt->bindParam(':device', $device);
|
||||
$stmt->bindParam(':details', $details); // <-- ربط المتغير الجديد
|
||||
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// طباعة رسالة نجاح مع تفاصيل الخطأ لسهولة التتبع في الكونسول
|
||||
jsonSuccess($error);
|
||||
} else {
|
||||
// طباعة رسالة فشل
|
||||
jsonError("Failed to save error data");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
require_once 'vendor/autoload.php'; // Include the Composer autoloader
|
||||
|
||||
use Facebook\Facebook;
|
||||
|
||||
$appId = '$appId'; // Replace with your App ID
|
||||
$appSecret = '$appSecret'; // Replace with your App Secret
|
||||
$accessToken = '$accessToken'; // Replace with the token you want to debug
|
||||
|
||||
$fb = new Facebook([
|
||||
'app_id' => $appId,
|
||||
'app_secret' => $appSecret,
|
||||
'default_graph_version' => 'v16.0', // Adjust based on your API version
|
||||
]);
|
||||
|
||||
try {
|
||||
// Generate the app token
|
||||
$appToken = $appId . '|' . $appSecret;
|
||||
|
||||
// Debug the token
|
||||
$response = $fb->get('/debug_token?input_token=' . $accessToken, $appToken);
|
||||
$tokenData = $response->getDecodedBody();
|
||||
|
||||
// Display the token details
|
||||
echo "Token Data:\n";
|
||||
print_r($tokenData);
|
||||
|
||||
if (isset($tokenData['data']['expires_at'])) {
|
||||
echo "Expires At: " . date('Y-m-d H:i:s', $tokenData['data']['expires_at']) . "\n";
|
||||
} else {
|
||||
echo "The token does not have an expiration time.\n";
|
||||
}
|
||||
} catch (Facebook\Exceptions\FacebookResponseException $e) {
|
||||
error_log("[facebook.php] Graph API Error: " . $e->getMessage());
|
||||
echo 'An error occurred while fetching Facebook data';
|
||||
} catch (Facebook\Exceptions\FacebookSDKException $e) {
|
||||
error_log("[facebook.php] SDK Error: " . $e->getMessage());
|
||||
echo 'An error occurred while processing Facebook data';
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../connect.php'; // Includes db connection
|
||||
|
||||
$zone_name = filterRequest('zone_name');
|
||||
$latitude = filterRequest('latitude');
|
||||
$longitude = filterRequest('longitude');
|
||||
$radius_meters = filterRequest('radius_meters');
|
||||
$country_code = filterRequest('country_code');
|
||||
$priority = filterRequest('priority') ?? 1;
|
||||
|
||||
if (empty($zone_name) || empty($latitude) || empty($longitude) || empty($radius_meters) || empty($country_code)) {
|
||||
echo json_encode(["status" => "error", "message" => "Missing required fields"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Check for overlapping zones
|
||||
// Using Haversine formula directly in SQL to find any zone where distance < (new_radius + existing_radius)
|
||||
$sql = "
|
||||
SELECT id, zone_name, radius_meters,
|
||||
(
|
||||
6371000 * acos(
|
||||
cos(radians(:new_lat)) * cos(radians(latitude)) *
|
||||
cos(radians(longitude) - radians(:new_lng)) +
|
||||
sin(radians(:new_lat)) * sin(radians(latitude))
|
||||
)
|
||||
) AS distance_meters
|
||||
FROM geofence_zones
|
||||
WHERE is_active = 1 AND country_code = :country_code
|
||||
HAVING distance_meters < (radius_meters + :new_radius)
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindValue(':new_lat', (float) $latitude);
|
||||
$stmt->bindValue(':new_lng', (float) $longitude);
|
||||
$stmt->bindValue(':new_radius', (int) $radius_meters, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':country_code', $country_code);
|
||||
$stmt->execute();
|
||||
|
||||
$overlapping_zone = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($overlapping_zone) {
|
||||
echo json_encode([
|
||||
"status" => "error",
|
||||
"message" => "Zone overlaps with existing zone: " . $overlapping_zone['zone_name'],
|
||||
"overlap_details" => $overlapping_zone
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Insert new zone
|
||||
$insert_sql = "INSERT INTO geofence_zones (zone_name, latitude, longitude, radius_meters, priority, country_code)
|
||||
VALUES (:zone_name, :lat, :lng, :radius, :priority, :country)";
|
||||
|
||||
$insert_stmt = $con->prepare($insert_sql);
|
||||
$insert_stmt->bindValue(':zone_name', $zone_name);
|
||||
$insert_stmt->bindValue(':lat', (float) $latitude);
|
||||
$insert_stmt->bindValue(':lng', (float) $longitude);
|
||||
$insert_stmt->bindValue(':radius', (int) $radius_meters, PDO::PARAM_INT);
|
||||
$insert_stmt->bindValue(':priority', (int) $priority, PDO::PARAM_INT);
|
||||
$insert_stmt->bindValue(':country', $country_code);
|
||||
$insert_stmt->execute();
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"message" => "Geofence zone added successfully",
|
||||
"zone_id" => $con->lastInsertId()
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error adding geofence zone: " . $e->getMessage());
|
||||
echo json_encode(["status" => "error", "message" => "Server error"]);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* get_heatmap.php
|
||||
* ───────────────
|
||||
* تقرأ بيانات الخريطة الحرارية المجمعة من Redis
|
||||
* البيانات مقسمة حسب الدولة (عبر Bounding Boxes في الـ Cron)
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../connect.php'; // Includes functions.php which has filterRequest()
|
||||
|
||||
$days = (int)(filterRequest('days') ?? 7);
|
||||
$source = filterRequest('source') ?? 'all';
|
||||
$countryCode = strtoupper(filterRequest('country_code') ?? 'all');
|
||||
|
||||
try {
|
||||
$redis = getRedisConnection();
|
||||
$cacheJson = $redis->get('siro:cache:heatmap:data');
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Redis connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$cacheJson) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Cache not generated yet']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cacheData = json_decode($cacheJson, true);
|
||||
|
||||
if (!$cacheData || !isset($cacheData['data'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid cache data']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$limitDate = date('Y-m-d', strtotime("-$days days"));
|
||||
|
||||
$filteredLocations = [];
|
||||
$stats = ['geofence' => 0, 'app_usage' => 0, 'silent_push' => 0];
|
||||
|
||||
$dataByCountry = $cacheData['data'];
|
||||
|
||||
// تحديد الدول التي سنسحب منها
|
||||
$countriesToSearch = ($countryCode === 'ALL') ? array_keys($dataByCountry) : [$countryCode];
|
||||
|
||||
foreach ($countriesToSearch as $cc) {
|
||||
if (!isset($dataByCountry[$cc])) continue;
|
||||
|
||||
foreach ($dataByCountry[$cc] as $loc) {
|
||||
// فلتر الأيام
|
||||
if ($loc['date'] < $limitDate) continue;
|
||||
|
||||
// فلتر المصدر
|
||||
if ($source !== 'all' && $loc['source'] !== $source) continue;
|
||||
|
||||
$filteredLocations[] = [
|
||||
'latitude' => $loc['lat'],
|
||||
'longitude' => $loc['lng'],
|
||||
'source' => $loc['source']
|
||||
];
|
||||
|
||||
if (isset($stats[$loc['source']])) {
|
||||
$stats[$loc['source']]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => $filteredLocations,
|
||||
'total' => count($filteredLocations),
|
||||
'stats' => $stats,
|
||||
'source' => 'redis_cache'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// تم إزالة دالة filterRequest من هنا لتجنب خطأ Redeclaration لأنها معرفة في functions.php
|
||||
?>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
$sql = "SELECT
|
||||
`passengers`.`id`,
|
||||
`passengers`.`phone`,
|
||||
`passengers`.`email`,
|
||||
`passengers`.`gender`,
|
||||
`passengers`.`status`,
|
||||
`passengers`.`birthdate`,
|
||||
`passengers`.`site`,
|
||||
`passengers`.`first_name`,
|
||||
`passengers`.`last_name`,
|
||||
`passengers`.`sosPhone`,
|
||||
`passengers`.`education`,
|
||||
`passengers`.`employmentType`,
|
||||
`passengers`.`maritalStatus`,
|
||||
`passengers`.`created_at`,
|
||||
`passengers`.`updated_at`,
|
||||
(
|
||||
SELECT COUNT(`id`) FROM `passengers`
|
||||
) AS countPassenger,
|
||||
(
|
||||
SELECT COUNT(`id`) FROM `feedBack`
|
||||
) AS countFeedback,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10,2))
|
||||
FROM `ratingPassenger`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS ratingPassenger,
|
||||
(
|
||||
SELECT COUNT(`driverID`)
|
||||
FROM `ratingPassenger`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS countDriverRate,
|
||||
(
|
||||
SELECT COUNT(`passengerID`)
|
||||
FROM `canecl`
|
||||
WHERE `passengerID` = `passengers`.`id`
|
||||
) AS countPassengerCancel,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10,2))
|
||||
FROM `ratingDriver`
|
||||
WHERE `passenger_iD` = `passengers`.`id`
|
||||
) AS passengerAverageRating,
|
||||
(
|
||||
SELECT COUNT(`driver_id`)
|
||||
FROM `ratingDriver`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS countPassengerRate,
|
||||
(
|
||||
SELECT COUNT(`ride`.`passenger_id`)
|
||||
FROM `ride`
|
||||
WHERE `ride`.`passenger_id` = `passengers`.`id`
|
||||
) AS countPassengerRide,
|
||||
(
|
||||
SELECT `token`
|
||||
FROM `tokens`
|
||||
WHERE `tokens`.`passengerID` = `passengers`.`id`
|
||||
) AS passengerToken
|
||||
FROM
|
||||
`passengers`
|
||||
GROUP BY
|
||||
`passengers`.`id`
|
||||
ORDER BY
|
||||
countPassengerRide DESC
|
||||
LIMIT 10";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ✅ فك التشفير للحقول الحساسة
|
||||
foreach ($result as &$row) {
|
||||
$fieldsToDecrypt = [
|
||||
"phone", "email", "gender", "birthdate", "site",
|
||||
"first_name", "last_name", "sosPhone",
|
||||
"education", "employmentType", "maritalStatus", "passengerToken"
|
||||
];
|
||||
|
||||
foreach ($fieldsToDecrypt as $field) {
|
||||
if (isset($row[$field]) && $row[$field] !== null) {
|
||||
$decrypted = $encryptionHelper->decryptData($row[$field]);
|
||||
if ($decrypted !== false) {
|
||||
$row[$field] = $decrypted;
|
||||
} else {
|
||||
// سجل أو تجاهل القيم التي فشل فك تشفيرها
|
||||
$row[$field] = null; // أو احتفظ بالقيمة المشفرة
|
||||
error_log("Failed to decrypt field '$field' for passenger ID: " . $row['id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess($data = $result);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
$passengerID = filterRequest("passengerID");
|
||||
|
||||
$sql = "SELECT
|
||||
`passengers`.`id`,
|
||||
`passengers`.`phone`,
|
||||
`passengers`.`email`,
|
||||
`passengers`.`gender`,
|
||||
`passengers`.`status`,
|
||||
`passengers`.`birthdate`,
|
||||
`passengers`.`site`,
|
||||
`passengers`.`first_name`,
|
||||
`passengers`.`last_name`,
|
||||
`passengers`.`sosPhone`,
|
||||
`passengers`.`education`,
|
||||
`passengers`.`employmentType`,
|
||||
`passengers`.`maritalStatus`,
|
||||
`passengers`.`created_at`,
|
||||
`passengers`.`updated_at`,
|
||||
(
|
||||
SELECT COUNT(`id`) FROM `passengers`
|
||||
) AS countPassenger,
|
||||
(
|
||||
SELECT COUNT(`id`) FROM `feedBack`
|
||||
) AS countFeedback,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10,2))
|
||||
FROM `ratingPassenger`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS ratingPassenger,
|
||||
(
|
||||
SELECT COUNT(`driverID`)
|
||||
FROM `ratingPassenger`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS countDriverRate,
|
||||
(
|
||||
SELECT COUNT(`passengerID`)
|
||||
FROM `canecl`
|
||||
WHERE `passengerID` = `passengers`.`id`
|
||||
) AS countPassengerCancel,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10,2))
|
||||
FROM `ratingDriver`
|
||||
WHERE `passenger_iD` = `passengers`.`id`
|
||||
) AS passengerAverageRating,
|
||||
(
|
||||
SELECT COUNT(`driver_id`)
|
||||
FROM `ratingDriver`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS countPassengerRate,
|
||||
(
|
||||
SELECT COUNT(`ride`.`passenger_id`)
|
||||
FROM `ride`
|
||||
WHERE `ride`.`passenger_id` = `passengers`.`id`
|
||||
) AS countPassengerRide,
|
||||
(
|
||||
SELECT `token`
|
||||
FROM `tokens`
|
||||
WHERE `tokens`.`passengerID` = `passengers`.`id`
|
||||
) AS passengerToken
|
||||
FROM
|
||||
`passengers`
|
||||
WHERE
|
||||
passengers.id = '$passengerID'
|
||||
GROUP BY
|
||||
`passengers`.`id`
|
||||
ORDER BY
|
||||
countPassengerRide DESC";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ✅ فك تشفير الحقول الحساسة
|
||||
foreach ($result as &$row) {
|
||||
$fieldsToDecrypt = [
|
||||
"phone", "email", "gender", "birthdate", "site",
|
||||
"first_name", "last_name", "sosPhone",
|
||||
"education", "employmentType", "maritalStatus", "passengerToken"
|
||||
];
|
||||
|
||||
foreach ($fieldsToDecrypt as $field) {
|
||||
if (isset($row[$field]) && $row[$field] !== null) {
|
||||
$row[$field] = $encryptionHelper->decryptData($row[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess($result);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
$passengerEmail = $encryptionHelper->encryptData(filterRequest("passengerEmail"));
|
||||
$passengerId = filterRequest("passengerId");
|
||||
$passengerphone = $encryptionHelper->encryptData(filterRequest("passengerphone"));
|
||||
|
||||
|
||||
/**
|
||||
* الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي.
|
||||
* تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس.
|
||||
*/
|
||||
global $blindIndex;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', filterRequest("passengerEmail")) : null;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', filterRequest("passengerphone")) : null;
|
||||
|
||||
$sql = "SELECT
|
||||
`passengers`.`id`,
|
||||
`passengers`.`phone`,
|
||||
`passengers`.`email`,
|
||||
`passengers`.`gender`,
|
||||
`passengers`.`status`,
|
||||
`passengers`.`birthdate`,
|
||||
`passengers`.`site`,
|
||||
`passengers`.`first_name`,
|
||||
`passengers`.`last_name`,
|
||||
`passengers`.`sosPhone`,
|
||||
`passengers`.`education`,
|
||||
`passengers`.`employmentType`,
|
||||
`passengers`.`maritalStatus`,
|
||||
`passengers`.`created_at`,
|
||||
`passengers`.`updated_at`,
|
||||
(
|
||||
SELECT COUNT(`id`) FROM `passengers`
|
||||
) AS countPassenger,
|
||||
(
|
||||
SELECT COUNT(`id`) FROM `feedBack`
|
||||
) AS countFeedback,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2)) FROM `ratingPassenger`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS ratingPassenger,
|
||||
(
|
||||
SELECT COUNT(`driverID`) FROM `ratingPassenger`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS countDriverRate,
|
||||
(
|
||||
SELECT COUNT(`passengerID`) FROM `canecl`
|
||||
WHERE `passengerID` = `passengers`.`id`
|
||||
) AS countPassengerCancel,
|
||||
(
|
||||
SELECT CAST(AVG(`rating`) AS DECIMAL(10, 2)) FROM `ratingDriver`
|
||||
WHERE `passenger_iD` = `passengers`.`id`
|
||||
) AS passengerAverageRating,
|
||||
(
|
||||
SELECT COUNT(`driver_id`) FROM `ratingDriver`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS countPassengerRate,
|
||||
(
|
||||
SELECT COUNT(`passenger_id`) FROM `ride`
|
||||
WHERE `passenger_id` = `passengers`.`id`
|
||||
) AS countPassengerRide,
|
||||
(
|
||||
SELECT `token` FROM `tokens`
|
||||
WHERE `passengerID` = `passengers`.`id`
|
||||
) AS passengerToken
|
||||
FROM
|
||||
`passengers`
|
||||
WHERE
|
||||
passengers.email = :email OR passengers.phone = :phone OR passengers.id = :id
|
||||
OR (:email_bidx IS NOT NULL AND passengers.email_bidx = :email_bidx)
|
||||
OR (:phone_bidx IS NOT NULL AND passengers.phone_bidx = :phone_bidx)
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(":email", $passengerEmail);
|
||||
$stmt->bindParam(":phone", $passengerphone);
|
||||
$stmt->bindParam(":id", $passengerId);
|
||||
$stmt->bindParam(":email_bidx", $emailBidx);
|
||||
$stmt->bindParam(":phone_bidx", $phoneBidx);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك التشفير للحقول الحساسة
|
||||
foreach ($result as &$row) {
|
||||
$fieldsToDecrypt = [
|
||||
"phone", "email", "gender", "birthdate", "site",
|
||||
"first_name", "last_name", "sosPhone",
|
||||
"education", "employmentType", "maritalStatus"
|
||||
];
|
||||
|
||||
foreach ($fieldsToDecrypt as $field) {
|
||||
if (isset($row[$field])) {
|
||||
$row[$field] = $encryptionHelper->decryptData($row[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess($data = $result);
|
||||
} else {
|
||||
jsonError("No records found");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// Return empty list as payments table resides on the payment server
|
||||
jsonSuccess([]);
|
||||
?>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/ggg.php
|
||||
// أداة تشفير وفك تشفير للمشرفين
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// المصادقة: هذه الأداة تفك تشفير أي حقل في قاعدة البيانات، لذا تمر عبر
|
||||
// connect.php (JWT + بصمة الجهاز + Rate limiting) ثم تتطلب دور super_admin.
|
||||
//
|
||||
// سابقاً كان الإذن الوحيد هو رقم هاتف يُرسل داخل جسم الطلب نفسه — وهو ليس
|
||||
// سرّاً: أي شخص يعرف رقماً من القائمة كان يستطيع فك تشفير بيانات المنصة
|
||||
// كاملةً بلا تسجيل دخول. أُبقيت قائمة الأرقام كطبقة ثانية فوق التوكن.
|
||||
// ============================================================
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// نضمن أن الرد دائماً JSON
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($role !== 'super_admin') {
|
||||
securityLog("Unauthorized encrypt/decrypt attempt", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'role' => $role ?? 'none',
|
||||
]);
|
||||
jsonError('Forbidden. Super Admin access required.', 403);
|
||||
}
|
||||
|
||||
// 1) قراءة الـ body كـ JSON أو POST
|
||||
$action = filterRequest('action');
|
||||
$text = filterRequest('text');
|
||||
$adminPhoneParam = filterRequest('admin_phone');
|
||||
|
||||
// 2) طبقة ثانية: رقم الهاتف يجب أن يكون ضمن القائمة المصرّح لها (إن وُجدت)
|
||||
$phonesRaw = getenv('ADMIN_PHONE_NUMBERS') ?: '';
|
||||
$ALLOWED_TOOL_PHONES = array_values(
|
||||
array_filter(
|
||||
array_map(function ($p) {
|
||||
return preg_replace('/\D+/', '', $p);
|
||||
}, explode(',', $phonesRaw))
|
||||
)
|
||||
);
|
||||
|
||||
$adminPhoneParam = $adminPhoneParam ? preg_replace('/\D+/', '', $adminPhoneParam) : '';
|
||||
|
||||
if (!empty($ALLOWED_TOOL_PHONES)
|
||||
&& ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true))) {
|
||||
securityLog("Encrypt/decrypt phone not in allow-list", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'phone' => $adminPhoneParam,
|
||||
]);
|
||||
jsonError('Access denied for this admin phone.', 403);
|
||||
}
|
||||
|
||||
// 3) سجل تدقيق: كل استخدام لهذه الأداة يُسجَّل مع هوية المنفّذ
|
||||
securityLog("Encryption tool used", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'action' => $action,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
if (empty($text) || ($action !== 'encrypt' && $action !== 'decrypt')) {
|
||||
jsonError('Invalid input: need action=encrypt|decrypt and non-empty text.', 400);
|
||||
}
|
||||
|
||||
// 4) تنفيذ التشفير / الفك (التوافق مع CBC الحالي)
|
||||
try {
|
||||
if ($action === 'encrypt') {
|
||||
$result = $encryptionHelper->encryptData($text);
|
||||
} else { // decrypt
|
||||
$result = $encryptionHelper->decryptData($text);
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'action' => $action,
|
||||
'result' => (string) $result,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
securityLog("Encryption tool failed", ['error' => $e->getMessage()]);
|
||||
jsonError('Operation failed.', 500);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/jwtService.php (Customer Service Login)
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ── Rate Limiting ───────────────────────────────────────────
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'login');
|
||||
|
||||
try {
|
||||
$email = filterRequest('email') ?? '';
|
||||
$password = filterRequest('password') ?? '';
|
||||
$audience = filterRequest('aud') ?? '';
|
||||
|
||||
$allowed1 = getenv('allowedService1');
|
||||
$allowed2 = getenv('allowedService2');
|
||||
$allowedAudiences = array_values(array_filter([$allowed1, $allowed2]));
|
||||
|
||||
if (empty($email) || empty($password) || empty($audience)) {
|
||||
jsonError('Email and password are required.', 400);
|
||||
}
|
||||
|
||||
if (!in_array($audience, $allowedAudiences, true)) {
|
||||
jsonError('Invalid audience', 400);
|
||||
}
|
||||
|
||||
$con = Database::get('main');
|
||||
|
||||
// استخدام user table ويفضل استخدام password_hash لاحقا مثل admin_users
|
||||
$stmt = $con->prepare("SELECT `id`, `password`, `email` FROM `users` WHERE email = :email LIMIT 1");
|
||||
$stmt->execute([':email' => $email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// التحقق من كلمة المرور باستخدام password_hash فقط (الأمان)
|
||||
if ($user && password_verify($password, $user['password'])) {
|
||||
|
||||
$limiter->reset(RateLimiter::identifier(), 'login');
|
||||
|
||||
$jwtService = new JwtService($redis);
|
||||
$jwt = $jwtService->generateAccessToken($user['id'], 'service', $audience);
|
||||
$refresh = $jwtService->generateRefreshToken($user['id']);
|
||||
|
||||
jsonSuccess([
|
||||
'jwt' => $jwt,
|
||||
'refresh_token' => $refresh['token'],
|
||||
'expires_in' => 900 // أو 6600 كما كان في الكود الأصلي
|
||||
]);
|
||||
|
||||
} else {
|
||||
$elapsed = microtime(true) - $startTime;
|
||||
if ($elapsed < 0.1) usleep((int)((0.1 - $elapsed) * 1000000));
|
||||
|
||||
securityLog("Service login failed", ['email' => $email]);
|
||||
jsonError('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
securityLog("Service Login PDO Error", ['msg' => $e->getMessage()]);
|
||||
jsonError('Login failed: Database error', 500);
|
||||
} catch (Exception $e) {
|
||||
securityLog("Service Login Error", ['msg' => $e->getMessage()]);
|
||||
jsonError('Login failed: Server error', 500);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* ai_price_prediction.php
|
||||
* يتوقع أوقات الذروة القادمة (Surge Prediction) بناءً على تحليل الشواذ السابقة
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. Analyze the most common hours for competitor surges in the last 14 days
|
||||
$sql = "SELECT HOUR(created_at) as surge_hour, COUNT(*) as frequency
|
||||
FROM price_anomalies
|
||||
WHERE country_code = :country
|
||||
AND anomaly_type = 'opportunity'
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 14 DAY)
|
||||
GROUP BY surge_hour
|
||||
ORDER BY frequency DESC
|
||||
LIMIT 3";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':country' => strtoupper($countryCode)]);
|
||||
$peakHours = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 2. Prepare prediction message
|
||||
$predictionMessage = "لا تتوفر بيانات كافية حالياً لبناء نموذج توقع דقيق.";
|
||||
$predictedHours = [];
|
||||
|
||||
if (count($peakHours) > 0) {
|
||||
$hoursStr = [];
|
||||
foreach ($peakHours as $h) {
|
||||
$predictedHours[] = (int)$h['surge_hour'];
|
||||
$time = sprintf("%02d:00", $h['surge_hour']);
|
||||
$hoursStr[] = $time;
|
||||
}
|
||||
$predictionMessage = "بناءً على خوارزميات التوقع وتحليل 14 يوماً من البيانات السابقة، يتوقع النظام حدوث ذروة عالية لدى المنافسين في الأوقات التالية اليوم: " . implode('، ', $hoursStr) . ". يُنصح بتجهيز كباتن سيرو مسبقاً في هذه الأوقات.";
|
||||
}
|
||||
|
||||
// Optional: Could send $predictionMessage to Gemini for more conversational output.
|
||||
// For performance, we return the deterministic heuristic here.
|
||||
|
||||
jsonSuccess([
|
||||
'status' => 'success',
|
||||
'predicted_surge_hours' => $predictedHours,
|
||||
'ai_analysis_message' => $predictionMessage,
|
||||
'confidence_score' => count($peakHours) > 0 ? 85 : 0
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[ai_price_prediction] Error: " . $e->getMessage());
|
||||
jsonError("Failed to run AI prediction");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/marketing/get_campaigns_log.php
|
||||
// API Endpoint to fetch marketing campaign delivery logs for Admin dashboard
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// 1. Authorize Admin/Super Admin
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$limit = filterRequest('limit', 'int') ?? 50;
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
$sql = "SELECT l.*, p.first_name, p.last_name
|
||||
FROM marketing_campaigns_log l
|
||||
LEFT JOIN passengers p ON p.id = l.passenger_id";
|
||||
|
||||
$params = [];
|
||||
if ($countryCode) {
|
||||
$sql .= " WHERE l.country_code = :country";
|
||||
$params[':country'] = strtoupper($countryCode);
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY l.sent_at DESC LIMIT :limit";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
foreach ($params as $key => $val) {
|
||||
$stmt->bindValue($key, $val);
|
||||
}
|
||||
$stmt->execute();
|
||||
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Decrypt names since they are encrypted in the passengers table
|
||||
foreach ($logs as &$log) {
|
||||
if (!empty($log['first_name'])) {
|
||||
$decName = $encryptionHelper->decryptData($log['first_name']);
|
||||
if ($decName) $log['first_name'] = $decName;
|
||||
}
|
||||
if (!empty($log['last_name'])) {
|
||||
$decName = $encryptionHelper->decryptData($log['last_name']);
|
||||
if ($decName) $log['last_name'] = $decName;
|
||||
}
|
||||
}
|
||||
unset($log);
|
||||
|
||||
// Aggregate statistics for Dashboard charts
|
||||
$sqlStats = "SELECT message_type, COUNT(*) as count
|
||||
FROM marketing_campaigns_log";
|
||||
if ($countryCode) {
|
||||
$sqlStats .= " WHERE country_code = :country";
|
||||
$sqlStats .= " GROUP BY message_type";
|
||||
$stmtStats = $con->prepare($sqlStats);
|
||||
$stmtStats->execute([':country' => strtoupper($countryCode)]);
|
||||
} else {
|
||||
$sqlStats .= " GROUP BY message_type";
|
||||
$stmtStats = $con->prepare($sqlStats);
|
||||
$stmtStats->execute();
|
||||
}
|
||||
$stats = $stmtStats->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonSuccess([
|
||||
'logs' => $logs,
|
||||
'stats' => $stats
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[get_campaigns_log.php] Error: " . $e->getMessage());
|
||||
jsonError("Failed to fetch campaigns log: " . $e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/marketing/get_market_anomalies.php
|
||||
// API Endpoint for Admin App (Flutter) to fetch price anomalies
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// 1. Authorize role
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Fetch anomalies
|
||||
try {
|
||||
$limit = filterRequest('limit', 'int') ?? 50;
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
$sql = "SELECT * FROM price_anomalies";
|
||||
$params = [];
|
||||
|
||||
if ($countryCode) {
|
||||
$sql .= " WHERE country_code = :country";
|
||||
$params[':country'] = strtoupper($countryCode);
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY created_at DESC LIMIT :limit";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
foreach ($params as $key => $val) {
|
||||
$stmt->bindValue($key, $val);
|
||||
}
|
||||
$stmt->execute();
|
||||
$anomalies = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Fetch some recent competitor prices for context
|
||||
$sqlPrices = "SELECT * FROM scraped_competitor_prices";
|
||||
$paramsPrices = [];
|
||||
if ($countryCode) {
|
||||
$sqlPrices .= " WHERE country_code = :country";
|
||||
$paramsPrices[':country'] = strtoupper($countryCode);
|
||||
}
|
||||
$sqlPrices .= " ORDER BY created_at DESC LIMIT 20";
|
||||
$stmtPrices = $con->prepare($sqlPrices);
|
||||
foreach ($paramsPrices as $key => $val) {
|
||||
$stmtPrices->bindValue($key, $val);
|
||||
}
|
||||
$stmtPrices->execute();
|
||||
$recentPrices = $stmtPrices->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonSuccess([
|
||||
'anomalies' => $anomalies,
|
||||
'recent_prices' => $recentPrices
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[get_market_anomalies.php] Error: " . $e->getMessage());
|
||||
jsonError("Failed to fetch market anomalies: " . $e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* get_market_share_analytics.php
|
||||
* جلب بيانات الحصة السوقية التاريخية لعرضها كرسوم بيانية للإدارة
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fetch up to 12 weeks of historical market health reports
|
||||
$sql = "SELECT report_date, average_pci, market_share_percent, total_anomalies, total_surge_opportunities
|
||||
FROM market_health_reports
|
||||
WHERE country_code = :country
|
||||
ORDER BY report_date ASC
|
||||
LIMIT 12";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':country' => strtoupper($countryCode)]);
|
||||
$reports = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// If no reports exist yet, we can simulate or return empty.
|
||||
// For now, we return exactly what is in the DB.
|
||||
$chartData = [];
|
||||
foreach ($reports as $row) {
|
||||
$chartData[] = [
|
||||
'date' => $row['report_date'],
|
||||
'pci' => (float)$row['average_pci'],
|
||||
'market_share' => (float)$row['market_share_percent'],
|
||||
'anomalies' => (int)$row['total_anomalies'],
|
||||
'surges' => (int)$row['total_surge_opportunities']
|
||||
];
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'status' => 'success',
|
||||
'historical_data' => $chartData
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[get_market_share_analytics] Error: " . $e->getMessage());
|
||||
jsonError("Failed to fetch analytics");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
// 1. Hourly competitor price averages (last 24h)
|
||||
$compSql = "SELECT
|
||||
DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
AVG(price_per_km) AS avg_price_per_km,
|
||||
COUNT(*) AS sample_count
|
||||
FROM scraped_competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
||||
$compParams = [];
|
||||
if ($countryCode) {
|
||||
$compSql .= " AND country_code = :country";
|
||||
$compParams[':country'] = strtoupper($countryCode);
|
||||
}
|
||||
$compSql .= " GROUP BY hour_bucket ORDER BY hour_bucket ASC LIMIT 24";
|
||||
|
||||
$stmt = $con->prepare($compSql);
|
||||
foreach ($compParams as $k => $v) {
|
||||
$stmt->bindValue($k, $v);
|
||||
}
|
||||
$stmt->execute();
|
||||
$hourlyData = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 2. PCI by region — group competitor prices by ~0.02° grid cells
|
||||
$pciSql = "SELECT
|
||||
ROUND(start_lat * 50, 0) / 50 AS lat_group,
|
||||
ROUND(start_lng * 50, 0) / 50 AS lng_group,
|
||||
competitor_name,
|
||||
AVG(price_per_km) AS avg_price_per_km,
|
||||
COUNT(*) AS samples
|
||||
FROM scraped_competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
|
||||
$pciParams = [];
|
||||
if ($countryCode) {
|
||||
$pciSql .= " AND country_code = :country2";
|
||||
$pciParams[':country2'] = strtoupper($countryCode);
|
||||
}
|
||||
$pciSql .= " GROUP BY lat_group, lng_group, competitor_name
|
||||
ORDER BY samples DESC LIMIT 20";
|
||||
|
||||
$stmtPci = $con->prepare($pciSql);
|
||||
foreach ($pciParams as $k => $v) {
|
||||
$stmtPci->bindValue($k, $v);
|
||||
}
|
||||
$stmtPci->execute();
|
||||
$pciData = $stmtPci->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 3. Siro base prices by category (from kazan table)
|
||||
$siroSql = "SELECT speedPrice, comfortPrice, awfarPrice, ladyPrice, electricPrice, vanPrice
|
||||
FROM kazan WHERE country = :country3 LIMIT 1";
|
||||
$countryNameMap = ['SY' => 'Syria', 'JO' => 'Jordan', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||||
$siroCountry = $countryNameMap[strtoupper($countryCode ?: 'SY')] ?? 'Syria';
|
||||
|
||||
$stmtSiro = $con->prepare($siroSql);
|
||||
$stmtSiro->execute([':country3' => $siroCountry]);
|
||||
$siroPrices = $stmtSiro->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonSuccess([
|
||||
'hourly_competitor_prices' => $hourlyData,
|
||||
'pci_regions' => $pciData,
|
||||
'siro_base_prices' => $siroPrices ?: [],
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[get_price_comparison.php] Error: " . $e->getMessage());
|
||||
jsonError("Failed to fetch price comparison: " . $e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* get_price_gap_heatmap.php
|
||||
* يجلب بيانات الخريطة الحرارية (Price Gap Heatmap) لعرضها في تطبيق Flutter
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine current Siro speed price
|
||||
$sqlKazan = "SELECT speedPrice FROM kazan WHERE country = :country LIMIT 1";
|
||||
$stmtKazan = $con->prepare($sqlKazan);
|
||||
$countryNameMap = ['SY' => 'Syria', 'JO' => 'Jordan', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||||
$stmtKazan->execute([':country' => $countryNameMap[strtoupper($countryCode)] ?? 'Syria']);
|
||||
$kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC);
|
||||
$currentSpeedPrice = $kazanRow ? (float)$kazanRow['speedPrice'] : 0;
|
||||
|
||||
if ($currentSpeedPrice <= 0) {
|
||||
jsonError("Siro base price not configured for this country.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Aggregate competitor data by geographical grid (approx 1.5km x 1.5km)
|
||||
$sql = "SELECT
|
||||
ROUND(start_lat * 74, 0) / 74 AS lat_group,
|
||||
ROUND(start_lng * 74, 0) / 74 AS lng_group,
|
||||
AVG(price_per_km) as avg_competitor_price_per_km,
|
||||
COUNT(*) as trip_count
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country
|
||||
AND price_per_km > 0
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
GROUP BY lat_group, lng_group
|
||||
HAVING trip_count >= 3"; // Require at least 3 trips for a reliable heatmap point
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':country' => strtoupper($countryCode)]);
|
||||
$grids = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$heatmapData = [];
|
||||
|
||||
foreach ($grids as $grid) {
|
||||
$compPricePerKm = (float)$grid['avg_competitor_price_per_km'];
|
||||
if ($compPricePerKm <= 0) continue;
|
||||
|
||||
// Calculate PCI for this specific grid
|
||||
// PCI < 1 means we are cheaper. PCI > 1 means we are more expensive.
|
||||
$pci = round($currentSpeedPrice / $compPricePerKm, 2);
|
||||
|
||||
// Calculate the "weight" for the heatmap renderer
|
||||
// E.g. -1 (We are 100% cheaper) to +1 (We are 100% more expensive)
|
||||
$weight = round($pci - 1.0, 2);
|
||||
// Clamp between -1 and 1
|
||||
$weight = max(-1.0, min(1.0, $weight));
|
||||
|
||||
$heatmapData[] = [
|
||||
'lat' => (float)$grid['lat_group'],
|
||||
'lng' => (float)$grid['lng_group'],
|
||||
'pci' => $pci,
|
||||
'weight' => $weight, // Negative = Green (Cheaper), Positive = Red (More expensive)
|
||||
'sample_size' => (int)$grid['trip_count']
|
||||
];
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'total_heatmap_points' => count($heatmapData),
|
||||
'current_siro_price_per_km' => $currentSpeedPrice,
|
||||
'heatmap_data' => $heatmapData
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[get_price_gap_heatmap] Error: " . $e->getMessage());
|
||||
jsonError("Failed to generate heatmap data");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// get_pricing_stability_log.php
|
||||
// شاشة مراجعة محرك الثبات (Shadow Mode) — يعرض سجل التصنيفات
|
||||
// والإجراءات المقترحة بدون ما يكون أي منها مطبّق فعلياً على kazan
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
$limit = filterRequest('limit', 'int') ?? 100;
|
||||
|
||||
$sql = "SELECT * FROM pricing_stability_log";
|
||||
$params = [];
|
||||
|
||||
if ($countryCode) {
|
||||
$sql .= " WHERE country_code = :country";
|
||||
$params[':country'] = strtoupper($countryCode);
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY evaluated_at DESC LIMIT :limit";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
foreach ($params as $key => $val) {
|
||||
$stmt->bindValue($key, $val);
|
||||
}
|
||||
$stmt->execute();
|
||||
$log = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ملخص سريع لآخر تصنيف لكل دولة
|
||||
$stmtLatest = $con->query("
|
||||
SELECT l1.* FROM pricing_stability_log l1
|
||||
INNER JOIN (
|
||||
SELECT country_code, MAX(evaluated_at) AS max_time
|
||||
FROM pricing_stability_log
|
||||
GROUP BY country_code
|
||||
) l2 ON l1.country_code = l2.country_code AND l1.evaluated_at = l2.max_time
|
||||
");
|
||||
$latestPerCountry = $stmtLatest->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonSuccess([
|
||||
'log' => $log,
|
||||
'latest_per_country' => $latestPerCountry,
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[get_pricing_stability_log.php] Error: " . $e->getMessage());
|
||||
jsonError("Failed to fetch pricing stability log: " . $e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
$countSql = "SELECT COUNT(*) FROM marketing_campaigns_log";
|
||||
$params = [];
|
||||
if ($countryCode) {
|
||||
$countSql .= " WHERE country_code = :country";
|
||||
$params[':country'] = strtoupper($countryCode);
|
||||
}
|
||||
$stmt = $con->prepare($countSql);
|
||||
foreach ($params as $key => $val) {
|
||||
$stmt->bindValue($key, $val);
|
||||
}
|
||||
$stmt->execute();
|
||||
$campaignCount = (int)$stmt->fetchColumn();
|
||||
|
||||
$estTokensPerCampaign = 3250;
|
||||
$estCostPerCampaign = 0.00048;
|
||||
$totalTokens = $campaignCount * $estTokensPerCampaign;
|
||||
$estimatedCost = $campaignCount * $estCostPerCampaign;
|
||||
|
||||
$anomalySql = "SELECT COUNT(*) FROM price_anomalies";
|
||||
$anomalyParams = [];
|
||||
if ($countryCode) {
|
||||
$anomalySql .= " WHERE country_code = :country2";
|
||||
$anomalyParams[':country2'] = strtoupper($countryCode);
|
||||
}
|
||||
$stmtAnomaly = $con->prepare($anomalySql);
|
||||
foreach ($anomalyParams as $key => $val) {
|
||||
$stmtAnomaly->bindValue($key, $val);
|
||||
}
|
||||
$stmtAnomaly->execute();
|
||||
$anomalyCount = (int)$stmtAnomaly->fetchColumn();
|
||||
|
||||
jsonSuccess([
|
||||
'api_requests_count' => $campaignCount,
|
||||
'total_tokens_used' => $totalTokens,
|
||||
'estimated_cost_usd' => round($estimatedCost, 6),
|
||||
'campaigns_count' => $campaignCount,
|
||||
'anomalies_count' => $anomalyCount,
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[get_telemetry.php] Error: " . $e->getMessage());
|
||||
jsonError("Failed to fetch telemetry: " . $e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
/**
|
||||
* surge_opportunity_index.php
|
||||
* مؤشر فرصة الذروة — يكشف المناطق اللي كل المنافسين فيها رافعيين الأسعار
|
||||
*
|
||||
* المنطق:
|
||||
* 1. لكل منطقة grid (~1.5km)، لكل منافس
|
||||
* 2. baseline = متوسط price_per_km آخر 7 أيام (بدون آخر 6 ساعات)
|
||||
* 3. current = متوسط price_per_km آخر ساعتين
|
||||
* 4. إذا current > baseline × 1.2 → المنافس في surge
|
||||
* 5. إذا كل المنافسين النشطين في zone في surge → فرصة ذروة ✅
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
$where = '';
|
||||
$params = [];
|
||||
if ($countryCode) {
|
||||
$where = 'AND cp.country_code = :country';
|
||||
$params[':country'] = strtoupper($countryCode);
|
||||
}
|
||||
|
||||
// 1. حساب الـ baseline (آخر 7 أيام، بدون آخر 6 ساعات)
|
||||
// و current (آخر ساعتين) لكل منافس في كل خلية grid
|
||||
$sql = "SELECT
|
||||
ROUND(cp.start_lat * 74, 0) / 74 AS lat_group,
|
||||
ROUND(cp.start_lng * 74, 0) / 74 AS lng_group,
|
||||
cp.competitor_name,
|
||||
cp.country_code,
|
||||
AVG(CASE WHEN cp.created_at < DATE_SUB(NOW(), INTERVAL 6 HOUR)
|
||||
THEN cp.price_per_km END) AS baseline_avg,
|
||||
AVG(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR)
|
||||
THEN cp.price_per_km END) AS current_avg,
|
||||
COUNT(*) AS total_samples,
|
||||
SUM(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) THEN 1 ELSE 0 END) AS recent_samples
|
||||
FROM scraped_competitor_prices cp
|
||||
WHERE cp.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
AND cp.price_per_km > 0
|
||||
$where
|
||||
GROUP BY lat_group, lng_group, cp.competitor_name, cp.country_code
|
||||
HAVING recent_samples >= 2
|
||||
ORDER BY lat_group, lng_group, cp.competitor_name";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
if ($countryCode) {
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v);
|
||||
}
|
||||
}
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 2. تجميع البيانات لكل zone
|
||||
$zones = [];
|
||||
foreach ($rows as $row) {
|
||||
$zoneKey = $row['lat_group'] . '_' . $row['lng_group'];
|
||||
|
||||
$baseline = (float)$row['baseline_avg'];
|
||||
$current = (float)$row['current_avg'];
|
||||
|
||||
$surgeRatio = ($baseline > 0) ? round($current / $baseline, 2) : 1.0;
|
||||
$isSurging = $baseline > 0 && $surgeRatio >= 1.2;
|
||||
|
||||
if (!isset($zones[$zoneKey])) {
|
||||
$zones[$zoneKey] = [
|
||||
'lat' => (float)$row['lat_group'],
|
||||
'lng' => (float)$row['lng_group'],
|
||||
'country_code' => $row['country_code'],
|
||||
'competitors' => [],
|
||||
'total_active' => 0,
|
||||
'total_surging' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$zones[$zoneKey]['competitors'][] = [
|
||||
'name' => $row['competitor_name'],
|
||||
'baseline' => round($baseline, 2),
|
||||
'current' => round($current, 2),
|
||||
'surge_ratio' => $surgeRatio,
|
||||
'is_surging' => $isSurging,
|
||||
];
|
||||
$zones[$zoneKey]['total_active']++;
|
||||
if ($isSurging) {
|
||||
$zones[$zoneKey]['total_surging']++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. تحديد فرص الذروة
|
||||
$opportunities = [];
|
||||
$gridSurgeZones = [];
|
||||
|
||||
foreach ($zones as $key => &$zone) {
|
||||
$zone['opportunity'] = (
|
||||
$zone['total_active'] >= 1 &&
|
||||
$zone['total_surging'] === $zone['total_active']
|
||||
);
|
||||
|
||||
if ($zone['opportunity']) {
|
||||
// حساب متوسط نسبة surge للمنافسين
|
||||
$avgRatio = 0;
|
||||
foreach ($zone['competitors'] as $c) {
|
||||
$avgRatio += $c['surge_ratio'];
|
||||
}
|
||||
$avgRatio /= count($zone['competitors']);
|
||||
|
||||
// اقتراح multiplier لـ Siro (أقل من المنافسين بفارق بسيط)
|
||||
$suggestedMultiplier = round(1.0 + ($avgRatio - 1.0) * 0.6, 2);
|
||||
if ($suggestedMultiplier < 1.0) $suggestedMultiplier = 1.0;
|
||||
|
||||
$zone['suggested_multiplier'] = $suggestedMultiplier;
|
||||
|
||||
$opportunities[] = [
|
||||
'lat' => $zone['lat'],
|
||||
'lng' => $zone['lng'],
|
||||
'country_code' => $zone['country_code'],
|
||||
'surging_competitors' => array_column(
|
||||
array_filter($zone['competitors'], fn($c) => $c['is_surging']),
|
||||
'name'
|
||||
),
|
||||
'avg_competitor_surge_ratio' => round($avgRatio, 2),
|
||||
'suggested_siro_multiplier' => $suggestedMultiplier,
|
||||
];
|
||||
|
||||
// حفظ المنطقة في Redis (للقراءة من get.php بعدين)
|
||||
$gridSurgeZones[$key] = $suggestedMultiplier;
|
||||
}
|
||||
}
|
||||
unset($zone);
|
||||
|
||||
// 4. تخزين فرص الذروة في Redis بصلاحية 10 دقائق
|
||||
if (!empty($gridSurgeZones) && isset($redis) && $redis !== null) {
|
||||
$redisKey = 'surge:opportunities';
|
||||
$redis->setex($redisKey, 600, json_encode($gridSurgeZones));
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'total_zones' => count($zones),
|
||||
'opportunities_count' => count($opportunities),
|
||||
'opportunities' => $opportunities,
|
||||
'zone_details' => array_values($zones),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[surge_opportunity_index] Error: " . $e->getMessage());
|
||||
jsonError("Failed to calculate surge opportunity index");
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/marketing/trigger_campaign.php
|
||||
// API Endpoint to trigger Gemini AI campaign generation and dispatch
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../../core/Services/SiroGeminiService.php';
|
||||
|
||||
// 1. Authorize Admin/Super Admin
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Filter inputs
|
||||
$countryCode = filterRequest('country_code') ?? 'SY';
|
||||
$regionName = filterRequest('region_name');
|
||||
|
||||
if (empty($regionName)) {
|
||||
if ($countryCode === 'JO') $regionName = 'Amman';
|
||||
elseif ($countryCode === 'EG') $regionName = 'Cairo';
|
||||
elseif ($countryCode === 'IQ') $regionName = 'Baghdad';
|
||||
else $regionName = 'Damascus';
|
||||
}
|
||||
$siroBasePrice = filterRequest('siro_base_price', 'float') ?? 10000.0;
|
||||
|
||||
try {
|
||||
// 3. Fetch recent competitor prices for this region to supply context to Gemini
|
||||
$sqlPrices = "SELECT competitor_name, price_amount AS total_price, (price_amount / price_per_km) AS distance_km
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country AND price_per_km > 0
|
||||
ORDER BY created_at DESC LIMIT 10";
|
||||
$stmtPrices = $con->prepare($sqlPrices);
|
||||
$stmtPrices->execute([':country' => strtoupper($countryCode)]);
|
||||
$competitorPrices = $stmtPrices->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($competitorPrices)) {
|
||||
// Fallback mock context if no competitor pricing has been scraped yet
|
||||
$competitorPrices = [
|
||||
['competitor_name' => 'yallago', 'total_price' => 12000, 'distance_km' => 5],
|
||||
['competitor_name' => 'zaken', 'total_price' => 11500, 'distance_km' => 5]
|
||||
];
|
||||
}
|
||||
|
||||
// 4. Initialize Gemini AI service and run market analysis
|
||||
$geminiService = new SiroGeminiService();
|
||||
$aiCampaign = $geminiService->analyzeMarketAndDraftCampaign(
|
||||
$competitorPrices,
|
||||
$siroBasePrice,
|
||||
$regionName,
|
||||
$countryCode
|
||||
);
|
||||
|
||||
if (!$aiCampaign) {
|
||||
jsonError("Failed to generate campaign via Gemini AI service.");
|
||||
}
|
||||
|
||||
// Check if campaign is recommended
|
||||
$opportunityDetected = $aiCampaign['opportunity_detected'] ?? false;
|
||||
if (!$opportunityDetected) {
|
||||
jsonSuccess([
|
||||
'campaign_created' => false,
|
||||
'reason' => 'Gemini AI determined no marketing opportunity is present based on current pricing structures.',
|
||||
'ai_analysis' => $aiCampaign
|
||||
]);
|
||||
}
|
||||
|
||||
$promoCode = $aiCampaign['promo_code'] ?? 'SIROGO10';
|
||||
$discountVal = $aiCampaign['discount_percentage'] ?? 10;
|
||||
$pushTitle = $aiCampaign['push_title'] ?? 'خصومات مميزة من سيرو!';
|
||||
$pushBody = $aiCampaign['push_body'] ?? 'وفر أكثر على رحلتك القادمة معنا.';
|
||||
$smsBody = $aiCampaign['sms_body'] ?? 'اشتقنا لك! عد إلينا ووفر أكثر مع الرمز الترويجي الخاص بك.';
|
||||
|
||||
// 5. Target Passengers in the specified country
|
||||
// Since phone numbers are encrypted, we fetch all passengers, decrypt, and filter by country prefix.
|
||||
$sqlTarget = "SELECT id AS passenger_id, phone FROM passengers";
|
||||
$stmtTarget = $con->prepare($sqlTarget);
|
||||
$stmtTarget->execute();
|
||||
$allPassengers = $stmtTarget->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$targets = [];
|
||||
$debugCounts = ['JO' => 0, 'SY' => 0, 'EG' => 0, 'IQ' => 0, 'UNKNOWN' => 0, 'DECRYPT_FAIL' => 0];
|
||||
foreach ($allPassengers as $p) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($p['phone']);
|
||||
if (!$decryptedPhone) {
|
||||
$debugCounts['DECRYPT_FAIL']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cleanPhone = preg_replace('/[^0-9]/', '', $decryptedPhone);
|
||||
$pCountry = 'UNKNOWN';
|
||||
if (strpos($cleanPhone, '962') === 0 || strpos($cleanPhone, '07') === 0) $pCountry = 'JO';
|
||||
elseif (strpos($cleanPhone, '963') === 0 || (strpos($cleanPhone, '09') === 0 && strlen($cleanPhone) == 10)) $pCountry = 'SY';
|
||||
elseif (strpos($cleanPhone, '20') === 0 || (strpos($cleanPhone, '01') === 0 && strlen($cleanPhone) == 11)) $pCountry = 'EG';
|
||||
elseif (strpos($cleanPhone, '964') === 0) $pCountry = 'IQ';
|
||||
|
||||
$debugCounts[$pCountry]++;
|
||||
|
||||
if ($pCountry === strtoupper($countryCode)) {
|
||||
$targets[] = ['passenger_id' => $p['passenger_id'], 'decrypted_phone' => $decryptedPhone];
|
||||
}
|
||||
}
|
||||
|
||||
$sentFcm = 0;
|
||||
$sentSms = 0;
|
||||
$sentWhatsApp = 0;
|
||||
$dispatchedPassengers = [];
|
||||
$fcmErrors = [];
|
||||
|
||||
// 5.5 وضع المعاينة: يُرجع ما ستفعله الحملة (النص، الكود، حجم الجمهور)
|
||||
// دون إنشاء كود ترويجي ودون إرسال أي إشعار. الحملة تُنشئ خصماً حقيقياً
|
||||
// وتصل كل ركاب الدولة، فوجود معاينة قبل الإطلاق ضروري.
|
||||
if (filterRequest('dry_run') === '1') {
|
||||
jsonSuccess([
|
||||
'dry_run' => true,
|
||||
'campaign_created' => false,
|
||||
'promo_code' => $promoCode,
|
||||
'discount_percent' => $discountVal,
|
||||
'region' => $regionName,
|
||||
'country_code' => strtoupper($countryCode),
|
||||
'audience_size' => count($targets),
|
||||
'ai_analysis' => $aiCampaign,
|
||||
], 'Preview only — no promo code was created and no notification was sent.');
|
||||
}
|
||||
|
||||
// 6. Save broadcast promo for this campaign (Option 1 - promos table adjustment)
|
||||
$sqlPromo = "INSERT INTO promos
|
||||
(promo_code, amount, description, passengerID, source, validity_start_date, validity_end_date)
|
||||
VALUES (:code, :amount, :desc, 'all', 'ai_generated', CURDATE(), DATE_ADD(CURDATE(), INTERVAL 7 DAY))";
|
||||
$stmtPromo = $con->prepare($sqlPromo);
|
||||
$stmtPromo->execute([
|
||||
':code' => $promoCode,
|
||||
':amount' => (string)$discountVal,
|
||||
':desc' => "AI Dynamic Promo: $promoCode ($discountVal%)"
|
||||
]);
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$passengerId = $target['passenger_id'];
|
||||
|
||||
// Enforce anti-spam: check if passenger received any SMS/WhatsApp campaign in the last 24 hours
|
||||
$sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log
|
||||
WHERE passenger_id = :pid
|
||||
AND message_type IN ('sms', 'whatsapp')
|
||||
AND sent_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
||||
$stmtSpam = $con->prepare($sqlSpamCheck);
|
||||
$stmtSpam->execute([':pid' => $passengerId]);
|
||||
$spamCount = intval($stmtSpam->fetchColumn());
|
||||
|
||||
// Check if passenger has active FCM token
|
||||
$sqlToken = "SELECT token FROM tokens WHERE passengerID = :pid ORDER BY id DESC LIMIT 1";
|
||||
$stmtToken = $con->prepare($sqlToken);
|
||||
$stmtToken->execute([':pid' => $passengerId]);
|
||||
$fcmToken = $stmtToken->fetchColumn();
|
||||
|
||||
$pushSent = false;
|
||||
if ($fcmToken) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($fcmToken);
|
||||
if ($decryptedToken) {
|
||||
// Send FCM Push Notification (Free channel - no anti-spam restriction needed)
|
||||
$fcmData = [
|
||||
'type' => 'marketing_campaign',
|
||||
'promo_code' => $promoCode,
|
||||
'discount' => (string)$discountVal
|
||||
];
|
||||
|
||||
$fcmResult = sendFcmNotification(
|
||||
$decryptedToken,
|
||||
$pushTitle,
|
||||
$pushBody,
|
||||
$fcmData,
|
||||
'Marketing',
|
||||
'notification'
|
||||
);
|
||||
|
||||
if ($fcmResult['status'] === 'success') {
|
||||
$sentFcm++;
|
||||
// Log campaign dispatch
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'push', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
$pushSent = true;
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => $fcmResult];
|
||||
}
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => 'Token decryption failed'];
|
||||
}
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => 'No token in DB'];
|
||||
}
|
||||
|
||||
if (!$pushSent) {
|
||||
// Fallback: Churned user (No token) OR Push failed -> Send WhatsApp or SMS
|
||||
// Check anti-spam first to prevent unnecessary marketing cost
|
||||
if ($spamCount === 0) {
|
||||
// Fetch and decrypt passenger phone number
|
||||
$sqlUser = "SELECT phone FROM passengers WHERE id = :pid LIMIT 1";
|
||||
$stmtUser = $con->prepare($sqlUser);
|
||||
$stmtUser->execute([':pid' => $passengerId]);
|
||||
$encPhone = $stmtUser->fetchColumn();
|
||||
|
||||
if ($encPhone) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($encPhone);
|
||||
if ($decryptedPhone) {
|
||||
// Send WhatsApp (or fallback to SMS simulation)
|
||||
$waResult = sendWhatsAppFromServer($decryptedPhone, $smsBody);
|
||||
|
||||
if ($waResult && ($waResult['status'] ?? '') === 'success') {
|
||||
$sentWhatsApp++;
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'whatsapp', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
} else {
|
||||
// Fallback to SMS simulation
|
||||
$sentSms++;
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'sms', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log the audit event for Admin action
|
||||
logAudit(
|
||||
$con,
|
||||
$user_id ?? 'admin_system',
|
||||
'trigger_marketing_campaign',
|
||||
'promos',
|
||||
$promoCode,
|
||||
['promo_code' => $promoCode, 'targets_count' => count($dispatchedPassengers)]
|
||||
);
|
||||
|
||||
jsonSuccess([
|
||||
'campaign_created' => true,
|
||||
'promo_code' => $promoCode,
|
||||
'discount_percentage' => $discountVal,
|
||||
'push_notification' => [
|
||||
'title' => $pushTitle,
|
||||
'body' => $pushBody,
|
||||
'sent_count' => $sentFcm
|
||||
],
|
||||
'whatsapp_sms' => [
|
||||
'body' => $smsBody,
|
||||
'whatsapp_sent_count' => $sentWhatsApp,
|
||||
'sms_sent_count' => $sentSms
|
||||
],
|
||||
'total_dispatched' => count($dispatchedPassengers),
|
||||
'debug_info' => [
|
||||
'requested_country' => $countryCode,
|
||||
'total_passengers_in_db' => count($allPassengers),
|
||||
'matched_targets' => count($targets),
|
||||
'distribution' => $debugCounts,
|
||||
'fcm_errors' => $fcmErrors ?? []
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[trigger_campaign.php] Error: " . $e->getMessage());
|
||||
jsonError("Failed to trigger marketing campaign: " . $e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* what_if_simulator.php
|
||||
* يحاكي تأثير تغيير الأسعار على مؤشر التنافسية (PCI) وحصة السوق المتوقعة
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
$proposedSpeedPrice = (float)filterRequest('speed_price');
|
||||
|
||||
if (!$countryCode || $proposedSpeedPrice <= 0) {
|
||||
jsonError("Missing required parameters: country_code, speed_price");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. Fetch recent competitor trips (last 7 days, limit 500 for fast simulation)
|
||||
$sql = "SELECT (price_amount / price_per_km) AS distance_km, price_amount AS total_price, competitor_name
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country
|
||||
AND price_per_km > 0
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':country' => strtoupper($countryCode)]);
|
||||
$trips = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($trips)) {
|
||||
jsonError("No competitor data available for simulation in this country.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Run simulation
|
||||
$totalTrips = count($trips);
|
||||
$cheaperCount = 0;
|
||||
|
||||
$currentPciSum = 0;
|
||||
$simulatedPciSum = 0;
|
||||
|
||||
// We need the current active Siro price to calculate current PCI
|
||||
$sqlKazan = "SELECT speedPrice FROM kazan WHERE country = :country LIMIT 1";
|
||||
$stmtKazan = $con->prepare($sqlKazan);
|
||||
$stmtKazan->execute([':country' => $countryCode === 'SY' ? 'Syria' : ($countryCode === 'JO' ? 'Jordan' : 'Egypt')]);
|
||||
$kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC);
|
||||
$currentSpeedPrice = $kazanRow ? (float)$kazanRow['speedPrice'] : $proposedSpeedPrice;
|
||||
|
||||
foreach ($trips as $trip) {
|
||||
$distance = (float)$trip['distance_km'];
|
||||
$compPrice = (float)$trip['total_price'];
|
||||
|
||||
// Approximate current and simulated Siro prices (ignoring duration/addons for simple simulation)
|
||||
$currentSiroPrice = $distance * $currentSpeedPrice;
|
||||
$simulatedSiroPrice = $distance * $proposedSpeedPrice;
|
||||
|
||||
// Calculate PCIs for this trip (Siro / Competitor)
|
||||
$tripCurrentPci = $currentSiroPrice / $compPrice;
|
||||
$tripSimulatedPci = $simulatedSiroPrice / $compPrice;
|
||||
|
||||
$currentPciSum += $tripCurrentPci;
|
||||
$simulatedPciSum += $tripSimulatedPci;
|
||||
|
||||
// Check market share (are we cheaper?)
|
||||
if ($simulatedSiroPrice < $compPrice) {
|
||||
$cheaperCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$avgCurrentPci = round($currentPciSum / $totalTrips, 2);
|
||||
$avgSimulatedPci = round($simulatedPciSum / $totalTrips, 2);
|
||||
$simulatedMarketSharePct = round(($cheaperCount / $totalTrips) * 100, 1);
|
||||
|
||||
// Suggestion logic
|
||||
$recommendation = "neutral";
|
||||
$message = "تأثير محايد.";
|
||||
|
||||
if ($avgSimulatedPci > 1.0) {
|
||||
$recommendation = "danger";
|
||||
$message = "تحذير: السعر المقترح سيجعل سيرو أغلى من متوسط المنافسين.";
|
||||
} elseif ($avgSimulatedPci < 0.8) {
|
||||
$recommendation = "warning";
|
||||
$message = "تنبيه: السعر المقترح رخيص جداً، قد يؤدي إلى خسارة في هامش الربح رغم زيادة الطلب.";
|
||||
} elseif ($avgSimulatedPci >= 0.9 && $avgSimulatedPci <= 0.95) {
|
||||
$recommendation = "success";
|
||||
$message = "ممتاز: هذا السعر يحقق توازناً مثالياً بين التنافسية والربحية (سعر تنافسي).";
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'total_trips_simulated' => $totalTrips,
|
||||
'current_speed_price' => $currentSpeedPrice,
|
||||
'proposed_speed_price' => $proposedSpeedPrice,
|
||||
'current_pci' => $avgCurrentPci,
|
||||
'simulated_pci' => $avgSimulatedPci,
|
||||
'simulated_market_share_percent' => $simulatedMarketSharePct,
|
||||
'recommendation_status' => $recommendation,
|
||||
'recommendation_message' => $message
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[what_if_simulator] Error: " . $e->getMessage());
|
||||
jsonError("Simulation failed");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
/**
|
||||
* winback_hotspot_targets.php
|
||||
* جلب قائمة بالركاب المنقطعين عن التطبيق (أكثر من 30 يوم)
|
||||
* والذين يتواجدون حالياً بالقرب من مناطق تشهد ذروة لدى المنافسين (Hotspots)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. Fetch active surge hotspots from Redis
|
||||
$surgeKey = "surge:opportunities:{$countryCode}";
|
||||
$hotspotsJson = $redis->get($surgeKey);
|
||||
$hotspots = $hotspotsJson ? json_decode($hotspotsJson, true) : [];
|
||||
|
||||
if (empty($hotspots)) {
|
||||
jsonSuccess(['targets' => [], 'message' => 'No active competitor hotspots found right now.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Extract latitudes and longitudes of the grids
|
||||
$hotspotGrids = [];
|
||||
foreach ($hotspots as $grid => $multiplier) {
|
||||
list($lat, $lng) = explode('_', $grid);
|
||||
$hotspotGrids[] = ['lat' => (float)$lat, 'lng' => (float)$lng];
|
||||
}
|
||||
|
||||
// 2. Build geographic query to find dormant passengers near these hotspots
|
||||
// 30 days dormant = No ride in 30 days
|
||||
|
||||
$whereClauses = [];
|
||||
$params = [':country' => $countryCode];
|
||||
$i = 0;
|
||||
|
||||
foreach ($hotspotGrids as $h) {
|
||||
$lat = $h['lat'];
|
||||
$lng = $h['lng'];
|
||||
// Approx bounding box for 2km around the grid center
|
||||
$latMin = $lat - 0.018;
|
||||
$latMax = $lat + 0.018;
|
||||
$lngMin = $lng - 0.018;
|
||||
$lngMax = $lng + 0.018;
|
||||
|
||||
$whereClauses[] = "(lat BETWEEN :latMin$i AND :latMax$i AND lng BETWEEN :lngMin$i AND :lngMax$i)";
|
||||
$params[":latMin$i"] = $latMin;
|
||||
$params[":latMax$i"] = $latMax;
|
||||
$params[":lngMin$i"] = $lngMin;
|
||||
$params[":lngMax$i"] = $lngMax;
|
||||
$i++;
|
||||
}
|
||||
|
||||
$geoWhere = implode(' OR ', $whereClauses);
|
||||
|
||||
// Query passenger_opening_locations or users table
|
||||
$sql = "SELECT DISTINCT u.users_id, u.users_name, u.users_phone, p.lat, p.lng
|
||||
FROM users u
|
||||
JOIN passenger_opening_locations p ON u.users_id = p.passenger_id
|
||||
WHERE u.country_code = :country
|
||||
AND u.users_type = 1
|
||||
AND u.last_ride_date < DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
AND ($geoWhere)
|
||||
LIMIT 1000";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$targets = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonSuccess([
|
||||
'total_targets' => count($targets),
|
||||
'hotspots_count' => count($hotspotGrids),
|
||||
'targets' => $targets
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[winback_hotspot_targets] Error: " . $e->getMessage());
|
||||
jsonError("Failed to fetch targets");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Simple mocking / getting of real data if possible
|
||||
$cpuLoad = sys_getloadavg();
|
||||
$load1m = $cpuLoad ? $cpuLoad[0] : 0.5;
|
||||
$cores = 4; // Mock or try to read from /proc/cpuinfo
|
||||
$cpuPercent = min(100, ($load1m / $cores) * 100);
|
||||
|
||||
$freeDisk = disk_free_space("/");
|
||||
$totalDisk = disk_total_space("/");
|
||||
$usedDisk = $totalDisk - $freeDisk;
|
||||
$diskPercent = ($usedDisk / $totalDisk) * 100;
|
||||
|
||||
// Dummy Memory (PHP can't natively read total system memory cross-platform easily without exec)
|
||||
$memTotalGb = 16.0;
|
||||
$memUsedGb = 8.4;
|
||||
$memPercent = ($memUsedGb / $memTotalGb) * 100;
|
||||
|
||||
$response = [
|
||||
'cpu' => [
|
||||
'percent' => round($cpuPercent, 2),
|
||||
'cores' => $cores,
|
||||
'load_1m' => round($load1m, 2)
|
||||
],
|
||||
'memory' => [
|
||||
'percent' => round($memPercent, 2),
|
||||
'used_gb' => $memUsedGb,
|
||||
'total_gb' => $memTotalGb
|
||||
],
|
||||
'disk' => [
|
||||
'percent' => round($diskPercent, 2),
|
||||
'used_gb' => round($usedDisk / 1073741824, 2),
|
||||
'total_gb' => round($totalDisk / 1073741824, 2)
|
||||
],
|
||||
'services' => [
|
||||
'Nginx' => 'running',
|
||||
'MySQL' => 'running',
|
||||
'Redis' => 'running',
|
||||
'PHP-FPM' => 'running'
|
||||
],
|
||||
'top_processes' => [
|
||||
['name' => 'mysql', 'usage' => '12.4%'],
|
||||
['name' => 'nginx', 'usage' => '3.1%'],
|
||||
['name' => 'php-fpm', 'usage' => '2.5%'],
|
||||
['name' => 'redis-server', 'usage' => '1.2%']
|
||||
],
|
||||
'network' => [
|
||||
'received_mb' => rand(100, 500) + (rand(0, 99) / 100),
|
||||
'sent_mb' => rand(50, 300) + (rand(0, 99) / 100)
|
||||
],
|
||||
'uptime' => [
|
||||
'formatted' => '12 days, 4 hours, 32 mins'
|
||||
],
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
echo json_encode($response);
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin/notifications/broadcast.php
|
||||
* إرسال إشعار جماعي إلى كل السائقين أو كل الركاب.
|
||||
*
|
||||
* لماذا نقطة وسيطة بدل استدعاء ride/firebase/send_fcm.php من الواجهة؟
|
||||
* - send_fcm.php داخلية ومحمية بمفتاح سرّي (FCM_INTERNAL_API_KEY)، ولا يجوز
|
||||
* أن يحمل المتصفح هذا المفتاح لأنه سيُكشف لأي مستخدم.
|
||||
* - send_fcm.php لا تعرف من المُرسِل، فلا تستطيع تقييد الصلاحية ولا التدقيق.
|
||||
*
|
||||
* هذه النقطة تفرض JWT + بصمة الجهاز (عبر connect.php) ودور super_admin، ثم
|
||||
* تُمرّر الطلب داخلياً مع المفتاح السرّي وتسجّل العملية في سجل التدقيق.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// إشعار جماعي يصل كل مستخدمي المنصة فوراً ولا يمكن سحبه بعد الإرسال.
|
||||
if ($role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Super Admin access required to broadcast notifications.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$audience = filterRequest('audience');
|
||||
$title = filterRequest('title');
|
||||
$body = filterRequest('body');
|
||||
|
||||
// المواضيع المسموح بها فقط — يشترك بها التطبيقان (siro_driver / siro_rider).
|
||||
// قصرها على قائمة ثابتة يمنع استخدام النقطة لبثّ رسائل إلى مواضيع عشوائية
|
||||
// أو إلى توكن جهاز بعينه.
|
||||
$ALLOWED_AUDIENCES = [
|
||||
'drivers' => 'drivers',
|
||||
'passengers' => 'passengers',
|
||||
];
|
||||
|
||||
if (!isset($ALLOWED_AUDIENCES[$audience])) {
|
||||
jsonError('Invalid audience. Allowed: ' . implode(', ', array_keys($ALLOWED_AUDIENCES)), 400);
|
||||
}
|
||||
|
||||
$title = trim((string) $title);
|
||||
$body = trim((string) $body);
|
||||
|
||||
if ($title === '' || $body === '') {
|
||||
jsonError('Both title and body are required.', 400);
|
||||
}
|
||||
if (mb_strlen($title) > 120) {
|
||||
jsonError('Title is too long (max 120 characters).', 400);
|
||||
}
|
||||
if (mb_strlen($body) > 1000) {
|
||||
jsonError('Body is too long (max 1000 characters).', 400);
|
||||
}
|
||||
|
||||
$topic = $ALLOWED_AUDIENCES[$audience];
|
||||
|
||||
// سجل التدقيق قبل الإرسال: نريد أثراً حتى لو فشل النداء أو انقطع.
|
||||
securityLog("Broadcast notification requested", [
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'audience' => $audience,
|
||||
'title' => $title,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
if (function_exists('logAudit')) {
|
||||
try {
|
||||
logAudit($con, (string) ($user_id ?? 'unknown'), 'إرسال إشعار جماعي', 'notification', $topic, [
|
||||
'audience' => $audience,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Broadcast] audit log failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// الاستدعاء الداخلي لخدمة FCM
|
||||
// من داخل حاوية php لا يوجد خادم ويب على 127.0.0.1 — الويب في حاوية nginx
|
||||
// منفصلة، وتُعرف داخل شبكة Compose باسم الخدمة. هذا كان سبب فشل كل إشعار.
|
||||
$fcmUrl = getenv('FCM_INTERNAL_URL') ?: 'http://nginx/backend/ride/firebase/send_fcm.php';
|
||||
$payload = json_encode([
|
||||
'target' => $topic,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'isTopic' => true,
|
||||
'data' => ['category' => 'admin_broadcast'],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$headers = ['Content-Type: application/json; charset=UTF-8'];
|
||||
$internalKey = getenv('FCM_INTERNAL_API_KEY');
|
||||
if (!empty($internalKey)) {
|
||||
$headers[] = 'X-API-KEY: ' . $internalKey;
|
||||
}
|
||||
|
||||
$ch = curl_init($fcmUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $httpCode >= 400) {
|
||||
$reason = $curlErr ?: (is_string($response) ? substr($response, 0, 200) : 'no response');
|
||||
error_log("[Broadcast] FCM call failed (HTTP $httpCode) via $fcmUrl: $reason");
|
||||
jsonError("Notification service unreachable at $fcmUrl — $reason", 502);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $response, true);
|
||||
|
||||
jsonSuccess([
|
||||
'audience' => $audience,
|
||||
'topic' => $topic,
|
||||
'title' => $title,
|
||||
'sent_by' => $user_id ?? null,
|
||||
'sent_at' => date('Y-m-d H:i:s'),
|
||||
'fcm_status' => $decoded['status'] ?? 'unknown',
|
||||
], 'Broadcast delivered to the notification service.');
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
function normalize_phone($s) { return preg_replace('/\D+/', '', (string)$s); }
|
||||
|
||||
$id = filterRequest("id"); // أو
|
||||
$phone = filterRequest("phone"); // أحدهما مطلوب
|
||||
$reason= filterRequest("reason"); // اختياري
|
||||
$exp = filterRequest("expires_at"); // اختياري Y-m-d H:i:s
|
||||
|
||||
if (empty($id) && empty($phone)) { jsonError("Provide id or phone"); exit; }
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
// احضر السجل
|
||||
if (!empty($id)) {
|
||||
$sel = $con->prepare("SELECT id, phone FROM passengers WHERE id = :id LIMIT 1");
|
||||
$sel->execute(['id' => $id]);
|
||||
} else {
|
||||
$sel = $con->prepare("SELECT id, phone FROM passengers WHERE phone = :ph LIMIT 1");
|
||||
$sel->execute(['ph' => $phone]);
|
||||
}
|
||||
$p = $sel->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$p) { throw new Exception("Passenger not found"); }
|
||||
|
||||
$phRaw = $p['phone'];
|
||||
$phNorm= normalize_phone($phRaw);
|
||||
|
||||
// أدخِل/حدّث في البلاك ليست
|
||||
$ins = $con->prepare("
|
||||
INSERT INTO passenger_blacklist (phone, phone_normalized, reason, expires_at)
|
||||
VALUES (:ph, :phn, :r, :exp)
|
||||
ON DUPLICATE KEY UPDATE reason = VALUES(reason), expires_at = VALUES(expires_at)
|
||||
");
|
||||
$ins->execute([
|
||||
'ph' => $phRaw,
|
||||
'phn' => $phNorm,
|
||||
'r' => $reason ?: 'Deleted & blacklisted',
|
||||
'exp' => $exp ?: null
|
||||
]);
|
||||
|
||||
// حذف فعلي
|
||||
$del = $con->prepare("DELETE FROM passengers WHERE id = :id");
|
||||
$del->execute(['id' => $p['id']]);
|
||||
|
||||
$con->commit();
|
||||
jsonSuccess(null, "Passenger deleted and blacklisted");
|
||||
} catch (Throwable $e) {
|
||||
$con->rollBack();
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// حارس الصلاحيات: رفع الحظر عملية إدارية، وكانت هذه النقطة بلا أي فحص دور.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function normalize_phone($s) { return preg_replace('/\D+/', '', (string)$s); }
|
||||
|
||||
$phone = filterRequest("phone");
|
||||
if (empty($phone)) { jsonError("phone is required"); exit; }
|
||||
|
||||
$phn = normalize_phone($phone);
|
||||
$stmt = $con->prepare("DELETE FROM passenger_blacklist WHERE phone_normalized = :phn");
|
||||
$stmt->execute(['phn' => $phn]);
|
||||
|
||||
if ($stmt->rowCount() > 0) { jsonSuccess(null, "Removed from blacklist"); }
|
||||
else { jsonError("Phone was not blacklisted"); }
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = filterRequest("id"); // مفضّل
|
||||
|
||||
$first_name = filterRequest("first_name");
|
||||
$last_name = filterRequest("last_name");
|
||||
$new_phone = filterRequest("phone");
|
||||
|
||||
if (empty($id)) { jsonError("Passenger ID is required"); exit; }
|
||||
if ($first_name === null && $last_name === null && $new_phone === null) {
|
||||
jsonError("Nothing to update"); exit;
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
if ($first_name !== null) {
|
||||
$encFirst = $encryptionHelper->encryptData($first_name);
|
||||
$sets[] = "first_name = :first_name";
|
||||
$params['first_name'] = trim($encFirst);
|
||||
}
|
||||
if ($last_name !== null) {
|
||||
$encLast = $encryptionHelper->encryptData($last_name);
|
||||
$sets[] = "last_name = :last_name";
|
||||
$params['last_name'] = trim($encLast);
|
||||
}
|
||||
if ($new_phone !== null) {
|
||||
$encPhone = $encryptionHelper->encryptData($new_phone);
|
||||
$sets[] = "phone = :phone";
|
||||
$params['phone'] = trim($encPhone);
|
||||
|
||||
// منع تكرار الهاتف على راكب آخر
|
||||
$q = $con->prepare("SELECT id FROM passengers WHERE phone = :ph LIMIT 1");
|
||||
$q->execute(['ph' => $params['phone']]);
|
||||
$row = $q->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row && $row['id'] != $id) {
|
||||
jsonError("Phone already used by another passenger");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = "id = :pid";
|
||||
$whereParams = ['pid' => $id];
|
||||
|
||||
$sql = "UPDATE passengers SET ".implode(", ", $sets).", updated_at = CURRENT_TIMESTAMP WHERE $whereSql";
|
||||
$stmt = $con->prepare($sql);
|
||||
$ok = $stmt->execute(array_merge($params, $whereParams));
|
||||
|
||||
if ($ok && $stmt->rowCount() > 0) { jsonSuccess(null, "Passenger updated"); }
|
||||
else { jsonError("No change or passenger not found"); }
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// التطبيع عبر normalizePhone() الموحّدة في core/helpers.php (نفس المنطق سابقاً)
|
||||
|
||||
$phone = filterRequest('phone');
|
||||
if (!$phone) {
|
||||
error_log("[get_last_ride] Missing phone parameter");
|
||||
jsonError("Phone is required");
|
||||
exit;
|
||||
}
|
||||
|
||||
// تطبيع الرقم أولاً
|
||||
$raw = normalizePhone($phone);
|
||||
|
||||
// شَفِّر قبل الاستعلام
|
||||
$enc_raw = $encryptionHelper->encryptData($raw);
|
||||
|
||||
try {
|
||||
error_log("[get_last_ride] Searching phone normalized=$raw");
|
||||
|
||||
// 1) ابحث عن الراكب بالهاتف المشفّر
|
||||
$selP = $con->prepare("
|
||||
SELECT id, first_name, last_name, phone
|
||||
FROM passengers
|
||||
WHERE phone = :enc_raw OR (:bidx IS NOT NULL AND phone_bidx = :bidx)
|
||||
LIMIT 1
|
||||
");
|
||||
$selP->execute(['enc_raw' => $enc_raw, 'bidx' => $pBidx]);
|
||||
$passenger = $selP->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// 2) ابحث عن السائق بالهاتف المشفّر
|
||||
$selD = $con->prepare("
|
||||
SELECT id AS driverID, first_name, last_name, phone
|
||||
FROM driver
|
||||
WHERE phone = :enc_raw OR (:bidx IS NOT NULL AND phone_bidx = :bidx)
|
||||
LIMIT 1
|
||||
");
|
||||
$selD->execute(['enc_raw' => $enc_raw, 'bidx' => $dBidx]);
|
||||
$driver = $selD->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$userId = null;
|
||||
$userType = null;
|
||||
|
||||
if ($passenger) {
|
||||
$userId = $passenger['id'];
|
||||
$userType = 'passenger';
|
||||
error_log("[get_last_ride] Passenger found id=" . $userId);
|
||||
}
|
||||
if ($driver) {
|
||||
$userId = $driver['driverID'];
|
||||
$userType = 'driver';
|
||||
error_log("[get_last_ride] Driver found id=" . $userId);
|
||||
}
|
||||
|
||||
if (!$userId) {
|
||||
error_log("[get_last_ride] User not found (phone=$raw)");
|
||||
jsonError('Phone number not found in system');
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3) تحديد حقل البحث في الرحلة
|
||||
$userField = ($userType === 'driver') ? 'r.driver_id' : 'r.passenger_id';
|
||||
|
||||
// فلترة حسب الحالة إذا أُرسلت
|
||||
$filterStatus = filterRequest('status');
|
||||
$whereExtra = '';
|
||||
$params = [':uid' => $userId];
|
||||
|
||||
if (!empty($filterStatus) && $filterStatus !== 'all') {
|
||||
$whereExtra = "AND r.status = :filter_status";
|
||||
$params[':filter_status'] = $filterStatus;
|
||||
}
|
||||
|
||||
// 4) آخر 20 رحلة لهذا المستخدم
|
||||
$rideStmt = $con->prepare("
|
||||
SELECT
|
||||
r.id,
|
||||
r.start_location,
|
||||
r.end_location,
|
||||
r.date,
|
||||
r.time,
|
||||
r.endtime,
|
||||
r.status,
|
||||
r.paymentMethod,
|
||||
r.carType,
|
||||
r.price,
|
||||
r.price_for_driver,
|
||||
r.price_for_passenger,
|
||||
r.distance,
|
||||
r.driver_id,
|
||||
r.passenger_id,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
r.DriverIsGoingToPassenger,
|
||||
r.rideTimeStart,
|
||||
r.rideTimeFinish,
|
||||
d.first_name AS driver_first_name,
|
||||
d.last_name AS driver_last_name,
|
||||
d.phone AS d_phone,
|
||||
p.first_name AS p_fname,
|
||||
p.last_name AS p_lname,
|
||||
p.phone AS p_phone
|
||||
FROM ride r
|
||||
LEFT JOIN driver d ON d.id = r.driver_id
|
||||
LEFT JOIN passengers p ON p.id = r.passenger_id
|
||||
WHERE $userField = :uid $whereExtra
|
||||
ORDER BY r.created_at DESC, r.id DESC
|
||||
LIMIT 20
|
||||
");
|
||||
$rideStmt->execute($params);
|
||||
$rides = $rideStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 5) فك تشفير الأسماء
|
||||
if ($passenger) {
|
||||
$passenger['first_name'] = $encryptionHelper->decryptData($passenger['first_name']);
|
||||
$passenger['last_name'] = $encryptionHelper->decryptData($passenger['last_name']);
|
||||
$passenger['phone'] = $encryptionHelper->decryptData($passenger['phone']);
|
||||
}
|
||||
if ($driver) {
|
||||
$driver['first_name'] = $encryptionHelper->decryptData($driver['first_name']);
|
||||
$driver['last_name'] = $encryptionHelper->decryptData($driver['last_name']);
|
||||
$driver['phone'] = $encryptionHelper->decryptData($driver['phone']);
|
||||
}
|
||||
|
||||
foreach ($rides as &$ride) {
|
||||
if (!empty($ride['driver_first_name'])) {
|
||||
$ride['driver_first_name'] = $encryptionHelper->decryptData($ride['driver_first_name']);
|
||||
}
|
||||
if (!empty($ride['driver_last_name'])) {
|
||||
$ride['driver_last_name'] = $encryptionHelper->decryptData($ride['driver_last_name']);
|
||||
}
|
||||
if (!empty($ride['d_phone'])) {
|
||||
$ride['d_phone'] = $encryptionHelper->decryptData($ride['d_phone']);
|
||||
}
|
||||
if (!empty($ride['p_fname'])) {
|
||||
$ride['p_fname'] = $encryptionHelper->decryptData($ride['p_fname']);
|
||||
}
|
||||
if (!empty($ride['p_lname'])) {
|
||||
$ride['p_lname'] = $encryptionHelper->decryptData($ride['p_lname']);
|
||||
}
|
||||
if (!empty($ride['p_phone'])) {
|
||||
$ride['p_phone'] = $encryptionHelper->decryptData($ride['p_phone']);
|
||||
}
|
||||
}
|
||||
unset($ride);
|
||||
|
||||
// 6) الرد
|
||||
$response = [
|
||||
'user_type' => $userType,
|
||||
'user' => $userType === 'driver' ? $driver : $passenger,
|
||||
'rides' => $rides
|
||||
];
|
||||
|
||||
error_log("[get_last_ride] Success response for " . $userType . " id=" . $userId);
|
||||
jsonSuccess($response);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log("[get_last_ride] Exception: " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
|
||||
|
||||
$rideId = filterRequest('id');
|
||||
$status = filterRequest('status');
|
||||
$reason = filterRequest('reason'); // اختياري
|
||||
|
||||
if (empty($rideId) || empty($status)) {
|
||||
jsonError("id and status are required");
|
||||
exit;
|
||||
}
|
||||
|
||||
/* whitelist للحالات المسموحة – تطابق حالات DB الفعلية */
|
||||
$allowed = [
|
||||
'New', 'waiting', 'wait', 'Apply', 'Applied',
|
||||
'Arrived', 'arrived', 'Begin', 'Finished',
|
||||
'Cancel', 'CancelFromDriver', 'CancelFromPassenger', 'TimeOut'
|
||||
];
|
||||
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
jsonError("Invalid status");
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
// إن أردت ختم وقت النهاية تلقائيًا عند الإكمال
|
||||
if ($status === 'Completed') {
|
||||
$sql = "UPDATE ride
|
||||
SET status = :st, rideTimeFinish = IFNULL(rideTimeFinish, NOW()), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id";
|
||||
} else {
|
||||
$sql = "UPDATE ride
|
||||
SET status = :st, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id";
|
||||
}
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$ok = $stmt->execute(['st' => $status, 'id' => $rideId]);
|
||||
|
||||
if (!$ok || $stmt->rowCount() === 0) {
|
||||
$con->rollBack();
|
||||
jsonError("Ride not found or no change");
|
||||
exit;
|
||||
}
|
||||
|
||||
// أعِدّ بيانات الرحلة المحدّثة (للتحديث الفوري في الواجهة)
|
||||
$fetch = $con->prepare("
|
||||
SELECT
|
||||
r.id,
|
||||
r.start_location,
|
||||
r.end_location,
|
||||
r.date,
|
||||
r.time,
|
||||
r.endtime,
|
||||
r.status,
|
||||
r.paymentMethod,
|
||||
r.carType,
|
||||
r.price,
|
||||
r.price_for_driver,
|
||||
r.price_for_passenger,
|
||||
r.distance,
|
||||
r.driver_id,
|
||||
r.passenger_id,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
r.DriverIsGoingToPassenger,
|
||||
r.rideTimeStart,
|
||||
r.rideTimeFinish,
|
||||
d.first_name AS driver_first_name,
|
||||
d.last_name AS driver_last_name
|
||||
FROM ride r
|
||||
LEFT JOIN driver d ON d.id = r.driver_id
|
||||
WHERE r.id = :id
|
||||
LIMIT 1
|
||||
");
|
||||
$fetch->execute(['id' => $rideId]);
|
||||
$ride = $fetch->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$con->commit();
|
||||
jsonSuccess(['ride' => $ride, 'message' => 'Status updated']);
|
||||
} catch (Throwable $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// =================================================================
|
||||
// ملف: get_driver_live_pos.php
|
||||
// الوظيفة: جلب الموقع اللحظي لسائق محدد (بناءً على ID)
|
||||
// =================================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php'; // تأكد أن هذا الملف يحتوي على $con_tracking
|
||||
|
||||
|
||||
header("Content-Type: application/json; charset=UTF-8");
|
||||
|
||||
try {
|
||||
// 1. استقبال معرف السائق
|
||||
$driver_id = filterRequest("driver_id");
|
||||
|
||||
if (!$driver_id) {
|
||||
jsonError("driver_id is required");
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. الاستعلام من قاعدة بيانات التتبع (car_locations)
|
||||
// نجلب أحدث إحداثيات تم تسجيلها لهذا السائق
|
||||
$sql = "
|
||||
SELECT
|
||||
latitude,
|
||||
longitude,
|
||||
heading,
|
||||
speed,
|
||||
updated_at
|
||||
FROM car_locations
|
||||
WHERE driver_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$stmt = $con_tracking->prepare($sql);
|
||||
$stmt->execute([$driver_id]);
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($data) {
|
||||
jsonSuccess($data);
|
||||
} else {
|
||||
// السائق ليس له موقع مسجل (ربما لم يشغل التطبيق بعد)
|
||||
jsonError("No location found for this driver");
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[get_driver_live_pos.php] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$statusFilter = filterRequest("status");
|
||||
// القيم المتوقعة من التطبيق: 'All', 'Begin', 'New', 'Completed', 'Canceled'
|
||||
if (!$statusFilter) $statusFilter = "Begin";
|
||||
|
||||
$params = [];
|
||||
$whereClause = "";
|
||||
|
||||
// --- منطق ترجمة الحالات (Mapping Logic) - مصحح ليطابق حالات DB الفعلية ---
|
||||
switch ($statusFilter) {
|
||||
case 'All':
|
||||
$whereClause = ""; // لا يوجد شرط، اجلب الكل
|
||||
break;
|
||||
|
||||
// ملاحظة: قاعدة البيانات تحتوي عائلتين من الحالات — القديمة بصيغة
|
||||
// CamelCase ('Finished','Begin','CancelFromPassenger') والجديدة التي
|
||||
// يكتبها خط الرحلات الحالي بأحرف صغيرة ('completed','accepted',
|
||||
// 'cancelled_by_passenger'). المقارنة تتم بـ LOWER() لتغطية الاثنتين.
|
||||
case 'Pending':
|
||||
// الرحلات المعلقة/الجديدة: بانتظار سائق
|
||||
$whereClause = "WHERE LOWER(r.status) IN ('new','nothing','waiting','wait','pending','searching')";
|
||||
break;
|
||||
|
||||
case 'Begin':
|
||||
// الرحلات الجارية: من قبول السائق إلى بدء التشغيل
|
||||
$whereClause = "WHERE LOWER(r.status) IN ('apply','applied','arrived','begin','accepted','started','claimed')";
|
||||
break;
|
||||
|
||||
case 'Completed':
|
||||
// الرحلات المكتملة
|
||||
$whereClause = "WHERE LOWER(r.status) IN ('finished','completed')";
|
||||
break;
|
||||
|
||||
case 'Canceled':
|
||||
// جميع أنواع الإلغاء
|
||||
$whereClause = "WHERE LOWER(r.status) IN (
|
||||
'cancel','cancelfromdriver','cancelfromdriverafterapply','cancelfrompassenger',
|
||||
'timeout','refused','cancelled_by_passenger','cancelled_by_driver',
|
||||
'cancelled_no_driver_found'
|
||||
)";
|
||||
break;
|
||||
|
||||
default:
|
||||
// في حال تم إرسال حالة محددة غير المذكورين
|
||||
$whereClause = "WHERE LOWER(r.status) = LOWER(?)";
|
||||
$params[] = $statusFilter;
|
||||
break;
|
||||
}
|
||||
|
||||
// --- الاستعلام ---
|
||||
$sql = "
|
||||
SELECT
|
||||
r.*,
|
||||
-- بيانات السائق
|
||||
d.first_name as d_fname, d.last_name as d_lname, d.phone as d_phone, d.id as driver_real_id,
|
||||
-- إحصائيات السائق (نحسب المكتمل والملغي بشكل أدق)
|
||||
(SELECT COUNT(*) FROM ride WHERE driver_id = d.id AND status = 'Finished') as d_completed,
|
||||
(SELECT COUNT(*) FROM ride WHERE driver_id = d.id AND status LIKE 'Cancel%') as d_canceled,
|
||||
|
||||
-- بيانات الراكب
|
||||
p.first_name as p_fname, p.last_name as p_lname, p.phone as p_phone,
|
||||
-- إحصائيات الراكب
|
||||
(SELECT COUNT(*) FROM ride WHERE passenger_id = p.id AND status = 'Finished') as p_completed,
|
||||
|
||||
-- سبب الإلغاء
|
||||
-- نحاول جلبه من جدول driver_orders (ملاحظات السائق)
|
||||
-- نستخدم COALESCE لجلب 'لا يوجد سبب' إذا كانت القيمة فارغة
|
||||
COALESCE(
|
||||
(SELECT notes FROM driver_orders WHERE order_id = r.id LIMIT 1),
|
||||
'لا يوجد سبب مسجل'
|
||||
) as cancel_reason
|
||||
|
||||
FROM ride r
|
||||
LEFT JOIN driver d ON r.driver_id = d.id
|
||||
LEFT JOIN passengers p ON r.passenger_id = p.id
|
||||
$whereClause
|
||||
ORDER BY r.id DESC
|
||||
LIMIT 100
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rides = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($rides as $row) {
|
||||
// فك التشفير
|
||||
try { $row['d_fname'] = $encryptionHelper->decryptData($row['d_fname']); } catch(Exception $e){}
|
||||
try { $row['d_lname'] = $encryptionHelper->decryptData($row['d_lname']); } catch(Exception $e){}
|
||||
try { $row['d_phone'] = $encryptionHelper->decryptData($row['d_phone']); } catch(Exception $e){}
|
||||
|
||||
try { $row['p_fname'] = $encryptionHelper->decryptData($row['p_fname']); } catch(Exception $e){}
|
||||
try { $row['p_lname'] = $encryptionHelper->decryptData($row['p_lname']); } catch(Exception $e){}
|
||||
try { $row['p_phone'] = $encryptionHelper->decryptData($row['p_phone']); } catch(Exception $e){}
|
||||
|
||||
$row['driver_full_name'] = trim($row['d_fname'] . ' ' . $row['d_lname']);
|
||||
$row['passenger_full_name'] = trim($row['p_fname'] . ' ' . $row['p_lname']);
|
||||
|
||||
if(empty($row['driver_full_name'])) $row['driver_full_name'] = "Unknown Driver";
|
||||
if(empty($row['passenger_full_name'])) $row['passenger_full_name'] = "Unknown Passenger";
|
||||
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
jsonSuccess($data);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[get_rides_by_status.php] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// التطبيع عبر normalizePhone() الموحّدة في core/helpers.php (نفس المنطق سابقاً)
|
||||
|
||||
// 1. تسجيل بداية الطلب
|
||||
$phone = filterRequest("phone");
|
||||
error_log("[MONITOR_RIDE] ---------------- START REQUEST ----------------");
|
||||
error_log("[MONITOR_RIDE] 1. Received Phone: " . $phone);
|
||||
|
||||
// تطبيع الرقم
|
||||
$phone = normalizePhone($phone);
|
||||
error_log("[MONITOR_RIDE] 1.5 Normalized Phone: " . $phone);
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// 1) البحث عن الهاتف أولاً في جدول السائق ثم جدول الراكب
|
||||
//------------------------------------------------------------------------
|
||||
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
|
||||
// فهرس البحث لكل جدول على حدة (النطاقات معزولة عمداً)
|
||||
global $blindIndex;
|
||||
$dBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
|
||||
$pBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
|
||||
error_log("[MONITOR_RIDE] 2. Encrypted Phone: " . $encPhone);
|
||||
|
||||
// Check Driver Table
|
||||
$driverQuery = $con->prepare("SELECT id AS driverID FROM driver WHERE phone = :phone OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
|
||||
$driverQuery->execute([':phone' => $encPhone, ':bidx' => $dBidx]);
|
||||
$driver = $driverQuery->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Check Passenger Table
|
||||
$customerQuery = $con->prepare("SELECT id AS customerID FROM passengers WHERE phone = :phone OR (:bidx IS NOT NULL AND phone_bidx = :bidx) LIMIT 1");
|
||||
$customerQuery->execute([':phone' => $encPhone, ':bidx' => $pBidx]);
|
||||
$customer = $customerQuery->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// حدد نوع المستخدم
|
||||
$userType = '';
|
||||
$driverID = null;
|
||||
$customerID = null;
|
||||
|
||||
if ($driver) {
|
||||
$userType = 'driver';
|
||||
$driverID = $driver['driverID'];
|
||||
error_log("[MONITOR_RIDE] 3. User Found: Type = DRIVER, ID = " . $driverID);
|
||||
} elseif ($customer) {
|
||||
$userType = 'customer';
|
||||
$customerID = $customer['customerID'];
|
||||
error_log("[MONITOR_RIDE] 3. User Found: Type = CUSTOMER, ID = " . $customerID);
|
||||
} else {
|
||||
error_log("[MONITOR_RIDE] 3. FAILURE: Phone number not found in Driver or Passenger tables.");
|
||||
jsonError("رقم الهاتف غير موجود في النظام.");
|
||||
exit;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// 2) جلب آخر رحلة حالتها نشطة (Apply, Applied, Arrived, Begin)
|
||||
//------------------------------------------------------------------------
|
||||
|
||||
$activeStatuses = "'Apply','Applied','Arrived','arrived','Begin'";
|
||||
|
||||
if ($userType == 'driver') {
|
||||
error_log("[MONITOR_RIDE] 4. Searching for active ride for Driver ID: " . $driverID);
|
||||
$rideQuery = $con->prepare("
|
||||
SELECT * FROM ride
|
||||
WHERE driver_id = :driverID AND status IN ($activeStatuses)
|
||||
ORDER BY id DESC LIMIT 1
|
||||
");
|
||||
$rideQuery->execute([':driverID' => $driverID]);
|
||||
} else {
|
||||
error_log("[MONITOR_RIDE] 4. Searching for active ride for Customer ID: " . $customerID);
|
||||
$rideQuery = $con->prepare("
|
||||
SELECT * FROM ride
|
||||
WHERE passenger_id = :customerID AND status IN ($activeStatuses)
|
||||
ORDER BY id DESC LIMIT 1
|
||||
");
|
||||
$rideQuery->execute([':customerID' => $customerID]);
|
||||
}
|
||||
|
||||
$ride = $rideQuery->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$ride) {
|
||||
error_log("[MONITOR_RIDE] 4. FAILURE: No active ride found.");
|
||||
jsonError("لا توجد رحلة نشطة لهذا المستخدم.");
|
||||
exit;
|
||||
} else {
|
||||
error_log("[MONITOR_RIDE] 4. SUCCESS: Active Ride Found. Ride ID: " . $ride['id'] . " Status: " . $ride['status']);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// 3) جلب معلومات السائق من الرحلة
|
||||
//------------------------------------------------------------------------
|
||||
|
||||
$rideDriverID = $ride['driverID'] ?? $ride['driver_id'];
|
||||
error_log("[MONITOR_RIDE] 5. Fetching info for Driver ID from Ride: " . $rideDriverID);
|
||||
|
||||
$driverInfoQuery = $con->prepare("
|
||||
SELECT id, first_name, last_name, phone
|
||||
FROM driver
|
||||
WHERE id = :driverID
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$driverInfoQuery->execute([':driverID' => $rideDriverID]);
|
||||
$driverInfo = $driverInfoQuery->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($driverInfo) {
|
||||
$driverInfo['phone'] = $encryptionHelper->decryptData($driverInfo['phone']);
|
||||
$driverInfo['first_name'] = $encryptionHelper->decryptData($driverInfo['first_name']);
|
||||
$driverInfo['last_name'] = $encryptionHelper->decryptData($driverInfo['last_name']);
|
||||
$fullName = $driverInfo['first_name'] . " " . $driverInfo['last_name'];
|
||||
$driverInfo['fullname'] = $fullName;
|
||||
error_log("[MONITOR_RIDE] 5. Driver Info Found: " . $fullName);
|
||||
} else {
|
||||
error_log("[MONITOR_RIDE] 5. WARNING: Driver info not found for ID " . $rideDriverID);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// 4) جلب آخر موقع للسائق من جدول car_locations بشرط الحالة ON
|
||||
//------------------------------------------------------------------------
|
||||
|
||||
error_log("[MONITOR_RIDE] 6. Querying Tracking DB for Driver ID: " . $rideDriverID);
|
||||
|
||||
$locationQuery = $con_tracking->prepare("
|
||||
SELECT latitude, longitude, speed, heading, updated_at
|
||||
FROM car_locations
|
||||
WHERE driver_id = :driverID AND status = 'ON'
|
||||
ORDER BY updated_at DESC LIMIT 1
|
||||
");
|
||||
$locationQuery->execute([':driverID' => $rideDriverID]);
|
||||
$location = $locationQuery->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($location) {
|
||||
error_log("[MONITOR_RIDE] 6. Location Found: Lat=" . $location['latitude'] . " Lng=" . $location['longitude']);
|
||||
} else {
|
||||
error_log("[MONITOR_RIDE] 6. WARNING: No live location found.");
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------
|
||||
// 5) تجهيز البيانات للرد
|
||||
//------------------------------------------------------------------------
|
||||
|
||||
$response = [
|
||||
"ride_details" => $ride,
|
||||
"driver_details" => $driverInfo,
|
||||
"driver_location" => $location ?: "No live location"
|
||||
];
|
||||
|
||||
error_log("[MONITOR_RIDE] 7. Sending Success Response.");
|
||||
jsonSuccess($response);
|
||||
error_log("[MONITOR_RIDE] 7. Sending Success Response.");
|
||||
jsonSuccess($response);
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
// File: send_payment_received_email.php
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
$driverID = filterRequest('driverID');
|
||||
$totalAmount = filterRequest('total_amount');
|
||||
$driverPhone = filterRequest('phone');
|
||||
$driverArabicName = filterRequest('name_arabic');
|
||||
$accountBank = filterRequest('accountBank');
|
||||
$driverEmail = filterRequest('email');
|
||||
|
||||
// لغة الإيميل (تلقائي إنجليزي حالياً، يمكن تعيينها لاحقًا حسب المستخدم)
|
||||
$language = 'en';
|
||||
|
||||
// عنوان واسم التطبيق الرسمي
|
||||
$appName = "tripz"; // الاسم الجديد مع حرف "Z"
|
||||
$domain = "https://tripz-egypt.com";
|
||||
|
||||
// محتوى الإيميل - باللغة الإنجليزية
|
||||
$bodyEmail = "<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #333; background: #f9f9f9; padding: 20px; }
|
||||
.container { background: #fff; padding: 30px; border-radius: 8px; max-width: 600px; margin: auto; }
|
||||
h1 { color: #007bff; }
|
||||
p { font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='container'>
|
||||
<img src='$domain/assets/logo.png' alt='$appName Logo' style='width: 150px; margin: 20px auto; display: block;'>
|
||||
<h1>Payment Sent - $appName</h1>
|
||||
<p>Thank you for being a valued driver on the $appName platform.</p>
|
||||
<p>We have sent a payment of <strong>$totalAmount EGP</strong> to your account <strong>$accountBank</strong>.</p>
|
||||
<p>Please note that it may take a few days for your bank to process this transaction.</p>
|
||||
<p>We appreciate your efforts and are proud to have you on board with $appName.</p>
|
||||
<p style='margin-top: 40px;'>Regards,<br><strong>tripz Team</strong></p>
|
||||
<p style='font-size: 12px; color: #888;'>tripz, Egypt | $domain</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
// محتوى الإيميل - باللغة العربية
|
||||
$bodyEmailAr = "<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: 'Cairo', sans-serif; color: #333; background: #f9f9f9; padding: 20px; direction: rtl; }
|
||||
.container { background: #fff; padding: 30px; border-radius: 8px; max-width: 600px; margin: auto; text-align: right; }
|
||||
h1 { color: #007bff; }
|
||||
p { font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='container'>
|
||||
<img src='$domain/assets/logo.png' alt='$appName' style='width: 150px; margin: 20px auto; display: block;'>
|
||||
<h1>تم إرسال الدفعة - $appName</h1>
|
||||
<p>شكرًا لك لكونك سائقًا مميزًا على منصة $appName.</p>
|
||||
<p>لقد تم إرسال دفعة قدرها <strong>$totalAmount جنيه</strong> إلى حسابك <strong>$accountBank</strong>.</p>
|
||||
<p>يرجى ملاحظة أن عملية التحويل قد تستغرق بضعة أيام حسب إجراءات البنك.</p>
|
||||
<p>نقدّر جهودك ونتطلع إلى استمرار الشراكة معك على تطبيق $appName.</p>
|
||||
<p style='margin-top: 40px;'>مع التحية،<br><strong>فريق $appName</strong></p>
|
||||
<p style='font-size: 12px; color: #888;'>$appName - مصر | $domain</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
// إعدادات الإيميل
|
||||
$supportEmail = 'support@tripz-egypt.com';
|
||||
$headers = "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
$headers .= "From: tripz Egypt <$supportEmail>\r\n";
|
||||
|
||||
// إرسال الإيميل إن وُجد عنوان صالح
|
||||
if (!empty($driverEmail)) {
|
||||
$subject = "Payment Sent - $appName";
|
||||
$message = ($language === 'ar') ? $bodyEmailAr : $bodyEmail;
|
||||
|
||||
if (mail($driverEmail, $subject, $message, $headers)) {
|
||||
jsonSuccess(null, "Email sent successfully to $driverEmail");
|
||||
} else {
|
||||
jsonError("Failed to send email to $driverEmail");
|
||||
}
|
||||
} else {
|
||||
jsonError("Invalid or missing driver email address.");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
// File: send_whatsapp_message.php
|
||||
// هذا السكربت يرسل رسالة واتساب فقط باستخدام RaseelPlus API
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
error_log("--- [send_whatsapp_message.php] Script execution started ---");
|
||||
|
||||
// استقبال المعطيات من POST
|
||||
$receiver = filterRequest("receiver"); // رقم الهاتف
|
||||
$message = filterRequest("message"); // نص الرسالة
|
||||
|
||||
if (empty($receiver) || empty($message)) {
|
||||
error_log("[send_whatsapp_message.php] Error: Missing receiver or message.");
|
||||
jsonError('Phone number and message are required.');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate phone number format (basic international format)
|
||||
if (!preg_match('/^\+?[1-9]\d{6,14}$/', $receiver)) {
|
||||
jsonError('Invalid phone number format.');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Limit message length to prevent abuse
|
||||
if (strlen($message) > 4096) {
|
||||
jsonError('Message too long. Maximum 4096 characters.');
|
||||
exit();
|
||||
}
|
||||
|
||||
// بيانات Raseel
|
||||
$instanceId = getenv("RASEEL_DRIVER_INSTANCE_ID");
|
||||
$accessToken = getenv("RASEEL_DRIVER_ACCESS_TOKEN");
|
||||
|
||||
// API URL
|
||||
$apiUrl = 'https://raseelplus.com/api/send';
|
||||
|
||||
// تجهيز البيانات للإرسال
|
||||
$payload = [
|
||||
"number" => $receiver,
|
||||
"type" => "text",
|
||||
"message" => $message,
|
||||
"instance_id" => $instanceId,
|
||||
"access_token"=> $accessToken
|
||||
];
|
||||
|
||||
error_log("[send_whatsapp_message.php] Sending payload: " . json_encode($payload));
|
||||
|
||||
// إرسال الطلب
|
||||
$response = callAPI("POST", $apiUrl, json_encode($payload));
|
||||
error_log("[send_whatsapp_message.php] Raw response: " . print_r($response, true));
|
||||
|
||||
// فحص الاستجابة
|
||||
if ($response && !isset($response->error) && (isset($response->status) && $response->status == 'success' || isset($response->message))) {
|
||||
jsonSuccess(null, "Message sent successfully.");
|
||||
} else {
|
||||
$errorMessage = isset($response->message) ? $response->message : "Unknown error.";
|
||||
error_log("[send_whatsapp_message.php] Failed to send: $errorMessage");
|
||||
jsonError("Failed to send message: $errorMessage");
|
||||
}
|
||||
|
||||
// دالة cURL
|
||||
function callAPI($method, $url, $data)
|
||||
{
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => "",
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Content-Type: application/json",
|
||||
"Accept: application/json"
|
||||
],
|
||||
]);
|
||||
$response = curl_exec($curl);
|
||||
$err = curl_error($curl);
|
||||
curl_close($curl);
|
||||
|
||||
if ($err) {
|
||||
error_log("[callAPI] cURL Error: $err");
|
||||
return null;
|
||||
} else {
|
||||
return json_decode($response);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// Admin/transit/org/admin_add.php — فريق سيرو يضيف مشرفاً جديداً لمؤسسة قائمة
|
||||
// POST: org_id, name, phone, role? (owner|transport_manager|dispatcher)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
requireTransitFields(['org_id', 'name', 'phone']);
|
||||
|
||||
$orgId = filterRequest('org_id', 'int');
|
||||
$name = filterRequest('name');
|
||||
$phone = normalizePhone(filterRequest('phone'));
|
||||
$adminRole = filterRequest('role') ?: 'transport_manager';
|
||||
|
||||
$allowedRoles = ['owner', 'transport_manager', 'dispatcher'];
|
||||
if (!in_array($adminRole, $allowedRoles)) jsonError('Invalid role', 400);
|
||||
|
||||
$chkOrg = $transit_con->prepare("SELECT id FROM transit_orgs WHERE id=? LIMIT 1");
|
||||
$chkOrg->execute([$orgId]);
|
||||
if (!$chkOrg->fetch()) jsonError('Organization not found', 404);
|
||||
|
||||
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||
|
||||
$chkDup = $transit_con->prepare(
|
||||
"SELECT id FROM transit_org_admins WHERE org_id=? AND phone=? LIMIT 1"
|
||||
);
|
||||
$chkDup->execute([$orgId, $phoneEnc]);
|
||||
if ($chkDup->fetch()) jsonError('An admin with this phone already exists for this organization', 409);
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_org_admins (org_id, name, phone, role, is_active) VALUES (?,?,?,?,1)"
|
||||
)->execute([$orgId, $name, $phoneEnc, $adminRole]);
|
||||
|
||||
appLog("[ADMIN][TRANSIT][ORG][admin_add] org={$orgId} name={$name} role={$adminRole}");
|
||||
|
||||
jsonSuccess(['admin_id' => (int)$transit_con->lastInsertId()], 'Admin added successfully');
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// Admin/transit/org/admin_toggle.php — تفعيل/تعليق مشرف مؤسسة (لفريق سيرو)
|
||||
// POST: admin_id, is_active (1|0)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
$adminId = filterRequest('admin_id', 'int');
|
||||
$isActive = filterRequest('is_active', 'int');
|
||||
|
||||
if (!$adminId || $isActive === null) jsonError('admin_id and is_active are required', 400);
|
||||
|
||||
$st = $transit_con->prepare("SELECT id, org_id FROM transit_org_admins WHERE id=? LIMIT 1");
|
||||
$st->execute([$adminId]);
|
||||
$admin = $st->fetch();
|
||||
if (!$admin) jsonError('Admin not found', 404);
|
||||
|
||||
$transit_con->prepare("UPDATE transit_org_admins SET is_active=? WHERE id=?")
|
||||
->execute([$isActive ? 1 : 0, $adminId]);
|
||||
|
||||
// إبطال جلساته الحالية فوراً عند التعليق
|
||||
if (!$isActive) {
|
||||
$sessions = $transit_con->prepare("SELECT token_hash FROM transit_sessions WHERE admin_id=?");
|
||||
$sessions->execute([$adminId]);
|
||||
foreach ($sessions->fetchAll(PDO::FETCH_COLUMN) as $hash) {
|
||||
if ($redis) $redis->del("transit:session:{$hash}");
|
||||
}
|
||||
$transit_con->prepare("DELETE FROM transit_sessions WHERE admin_id=?")->execute([$adminId]);
|
||||
}
|
||||
|
||||
appLog("[ADMIN][TRANSIT][ORG][admin_toggle] admin={$adminId} is_active={$isActive}");
|
||||
|
||||
jsonSuccess(['admin_id' => $adminId, 'is_active' => (bool)$isActive]);
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
// Admin/transit/org/admins_list.php — قائمة مشرفي مؤسسة (لفريق سيرو)
|
||||
// POST/GET: org_id
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
$orgId = filterRequest('org_id', 'int');
|
||||
if (!$orgId) jsonError('org_id is required', 400);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, name, phone, role, is_active, created_at
|
||||
FROM transit_org_admins WHERE org_id=? ORDER BY created_at ASC"
|
||||
);
|
||||
$st->execute([$orgId]);
|
||||
$admins = $st->fetchAll();
|
||||
|
||||
foreach ($admins as &$a) {
|
||||
if (!empty($a['phone'])) {
|
||||
$a['phone'] = $encryptionHelper->decryptData($a['phone']) ?: null;
|
||||
}
|
||||
}
|
||||
unset($a);
|
||||
|
||||
jsonSuccess(['admins' => $admins]);
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
// Admin/transit/org/create.php — فريق سيرو يضيف مؤسسة جديدة (جامعة/مدرسة/فندق/شركة/ناقل)
|
||||
// + ينشئ أول مشرف (owner) لها مباشرة
|
||||
// POST: type, country, city, name_ar, name_en, admin_name, admin_phone, ...
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
requireTransitFields(['type', 'country', 'city', 'name_ar', 'name_en', 'admin_name', 'admin_phone']);
|
||||
|
||||
$type = filterRequest('type');
|
||||
$country = strtoupper(substr(filterRequest('country'), 0, 2));
|
||||
$city = filterRequest('city');
|
||||
$nameAr = filterRequest('name_ar');
|
||||
$nameEn = filterRequest('name_en');
|
||||
$adminName = filterRequest('admin_name');
|
||||
$adminPhone = normalizePhone(filterRequest('admin_phone'));
|
||||
$adminRole = filterRequest('admin_role') ?: 'owner';
|
||||
$trialEndsAt = filterRequest('trial_ends_at') ?: date('Y-m-d', strtotime('+90 days'));
|
||||
|
||||
$allowedTypes = ['university', 'school', 'hotel', 'company', 'transporter'];
|
||||
if (!in_array($type, $allowedTypes)) {
|
||||
jsonError('Invalid type. Allowed: ' . implode(', ', $allowedTypes));
|
||||
}
|
||||
|
||||
$chk = $transit_con->prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1");
|
||||
$chk->execute([$nameAr, $country]);
|
||||
if ($chk->fetch()) jsonError('Organization already exists', 409);
|
||||
|
||||
$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone);
|
||||
$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? '');
|
||||
$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null;
|
||||
|
||||
$transit_con->beginTransaction();
|
||||
try {
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_orgs
|
||||
(type, country, city, name_ar, name_en, contact_phone, contact_email, website, contract_status, trial_ends_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,'active',?)"
|
||||
)->execute([
|
||||
$type, $country, $city, $nameAr, $nameEn,
|
||||
$contactPhoneEnc, filterRequest('contact_email'), filterRequest('website'),
|
||||
$trialEndsAt,
|
||||
]);
|
||||
|
||||
$orgId = (int)$transit_con->lastInsertId();
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)"
|
||||
)->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]);
|
||||
|
||||
$transit_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$transit_con->rollBack();
|
||||
appLog('[ADMIN][TRANSIT][ORG][create] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to create organization', 500);
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'org_id' => $orgId,
|
||||
'name_ar' => $nameAr,
|
||||
'type' => $type,
|
||||
'country' => $country,
|
||||
], 'Organization created successfully');
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
// Admin/transit/org/details.php — تفاصيل وتحليلات مؤسسة واحدة (لفريق سيرو)
|
||||
// POST/GET: org_id
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
$orgId = filterRequest('org_id', 'int');
|
||||
if (!$orgId) jsonError('org_id is required', 400);
|
||||
|
||||
$st = $transit_con->prepare("SELECT * FROM transit_orgs WHERE id=? LIMIT 1");
|
||||
$st->execute([$orgId]);
|
||||
$org = $st->fetch();
|
||||
if (!$org) jsonError('Organization not found', 404);
|
||||
|
||||
// فك تشفير هاتف التواصل للعرض الإداري فقط
|
||||
if (!empty($org['contact_phone'])) {
|
||||
$org['contact_phone'] = $encryptionHelper->decryptData($org['contact_phone']) ?: null;
|
||||
}
|
||||
|
||||
// ── العدّادات الأساسية ───────────────────────────────────────
|
||||
$counts = [
|
||||
'drivers_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId")->fetchColumn(),
|
||||
'drivers_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId AND status='active'")->fetchColumn(),
|
||||
'vehicles_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId")->fetchColumn(),
|
||||
'vehicles_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId AND is_active=1")->fetchColumn(),
|
||||
'routes_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId")->fetchColumn(),
|
||||
'routes_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId AND status='active'")->fetchColumn(),
|
||||
'enrollments_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='active'")->fetchColumn(),
|
||||
'enrollments_pending' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='pending'")->fetchColumn(),
|
||||
];
|
||||
|
||||
// ── الرحلات: اليوم / هذا الأسبوع / هذا الشهر / إجمالي ──────────
|
||||
$today = date('Y-m-d');
|
||||
$weekStart = date('Y-m-d', strtotime('monday this week'));
|
||||
$monthStart = date('Y-m-01');
|
||||
|
||||
$stTrips = $transit_con->prepare(
|
||||
"SELECT
|
||||
SUM(trip_date = ?) AS today_count,
|
||||
SUM(trip_date >= ?) AS week_count,
|
||||
SUM(trip_date >= ?) AS month_count,
|
||||
COUNT(*) AS total_count,
|
||||
SUM(status='completed') AS completed_count,
|
||||
SUM(status='cancelled') AS cancelled_count,
|
||||
SUM(status='no_show') AS no_show_count,
|
||||
AVG(CASE WHEN status='completed' THEN delay_minutes END) AS avg_delay_minutes,
|
||||
SUM(CASE WHEN status='completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(MINUTE, started_at, completed_at) ELSE 0 END) AS total_minutes_driven
|
||||
FROM transit_trips WHERE org_id = ?"
|
||||
);
|
||||
$stTrips->execute([$today, $weekStart, $monthStart, $orgId]);
|
||||
$tripStats = $stTrips->fetch();
|
||||
|
||||
$trips = [
|
||||
'today' => (int)($tripStats['today_count'] ?? 0),
|
||||
'this_week' => (int)($tripStats['week_count'] ?? 0),
|
||||
'this_month' => (int)($tripStats['month_count'] ?? 0),
|
||||
'total' => (int)($tripStats['total_count'] ?? 0),
|
||||
'completed' => (int)($tripStats['completed_count'] ?? 0),
|
||||
'cancelled' => (int)($tripStats['cancelled_count'] ?? 0),
|
||||
'no_show' => (int)($tripStats['no_show_count'] ?? 0),
|
||||
'avg_delay_minutes' => round((float)($tripStats['avg_delay_minutes'] ?? 0), 1),
|
||||
'total_hours_driven' => round(((int)($tripStats['total_minutes_driven'] ?? 0)) / 60, 1),
|
||||
];
|
||||
|
||||
// ── الخطوط مع ملخص لكل خط ─────────────────────────────────────
|
||||
$stRoutes = $transit_con->prepare(
|
||||
"SELECT r.id, r.name_ar, r.status, r.distance_km,
|
||||
(SELECT COUNT(*) FROM transit_stops s WHERE s.route_id = r.id) AS stops_count,
|
||||
(SELECT COUNT(*) FROM transit_trips t WHERE t.route_id = r.id AND t.status='completed') AS completed_trips
|
||||
FROM transit_routes r WHERE r.org_id = ? ORDER BY r.name_ar ASC"
|
||||
);
|
||||
$stRoutes->execute([$orgId]);
|
||||
$routes = $stRoutes->fetchAll();
|
||||
|
||||
jsonSuccess([
|
||||
'org' => $org,
|
||||
'counts' => $counts,
|
||||
'trips' => $trips,
|
||||
'routes' => $routes,
|
||||
]);
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
// Admin/transit/org/list.php — قائمة كل مؤسسات مواصلاتي (لفريق سيرو)
|
||||
// GET/POST: country?, type?, contract_status?, search?, page?, per_page?
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
$country = filterRequest('country');
|
||||
$type = filterRequest('type');
|
||||
$contractStatus = filterRequest('contract_status');
|
||||
$search = filterRequest('search');
|
||||
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
|
||||
$perPage = min(100, max(10, (int)(filterRequest('per_page', 'int') ?? 30)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
|
||||
if ($country) { $where .= ' AND country = ?'; $params[] = strtoupper(substr($country, 0, 2)); }
|
||||
if ($type) { $where .= ' AND type = ?'; $params[] = $type; }
|
||||
if ($contractStatus) { $where .= ' AND contract_status = ?'; $params[] = $contractStatus; }
|
||||
if ($search) { $where .= ' AND (name_ar LIKE ? OR name_en LIKE ?)'; $params[] = "%$search%"; $params[] = "%$search%"; }
|
||||
|
||||
$countSt = $transit_con->prepare("SELECT COUNT(*) FROM transit_orgs WHERE $where");
|
||||
$countSt->execute($params);
|
||||
$total = (int)$countSt->fetchColumn();
|
||||
|
||||
$params[] = $perPage;
|
||||
$params[] = $offset;
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, type, country, city, name_ar, name_en, logo_url,
|
||||
contract_status, trial_ends_at, created_at
|
||||
FROM transit_orgs
|
||||
WHERE $where
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
$st->execute($params);
|
||||
$orgs = $st->fetchAll();
|
||||
|
||||
if ($orgs) {
|
||||
$ids = implode(',', array_map('intval', array_column($orgs, 'id')));
|
||||
|
||||
$drivers = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_drivers WHERE org_id IN ($ids) GROUP BY org_id")
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$vehicles = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_vehicles WHERE org_id IN ($ids) GROUP BY org_id")
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$routes = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_routes WHERE org_id IN ($ids) AND status='active' GROUP BY org_id")
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$enrollments = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_enrollments WHERE org_id IN ($ids) AND status='active' GROUP BY org_id")
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
foreach ($orgs as &$o) {
|
||||
$id = $o['id'];
|
||||
$o['drivers_count'] = (int)($drivers[$id] ?? 0);
|
||||
$o['vehicles_count'] = (int)($vehicles[$id] ?? 0);
|
||||
$o['active_routes'] = (int)($routes[$id] ?? 0);
|
||||
$o['active_enrollments'] = (int)($enrollments[$id] ?? 0);
|
||||
}
|
||||
unset($o);
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'orgs' => $orgs,
|
||||
'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage],
|
||||
]);
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
// Admin/transit/org/update.php — تعديل بيانات مؤسسة + إدارة حالة العقد
|
||||
// POST: org_id, [contract_status], [city], [contact_email], [website], [trial_ends_at]
|
||||
//
|
||||
// عند التعليق (suspended) أو الإنهاء (terminated):
|
||||
// يُبطل جميع جلسات مشرفي المؤسسة فوراً (Redis + MySQL)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
$orgId = filterRequest('org_id', 'int');
|
||||
if (!$orgId) jsonError('org_id is required', 400);
|
||||
|
||||
$st = $transit_con->prepare("SELECT id, contract_status FROM transit_orgs WHERE id=? LIMIT 1");
|
||||
$st->execute([$orgId]);
|
||||
$org = $st->fetch();
|
||||
if (!$org) jsonError('Organization not found', 404);
|
||||
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
// حقول يمكن تحديثها
|
||||
if (($v = filterRequest('city')) !== null) { $updates[] = 'city=?'; $params[] = $v; }
|
||||
if (($v = filterRequest('contact_email')) !== null) { $updates[] = 'contact_email=?'; $params[] = $v; }
|
||||
if (($v = filterRequest('website')) !== null) { $updates[] = 'website=?'; $params[] = $v; }
|
||||
if (($v = filterRequest('trial_ends_at')) !== null) { $updates[] = 'trial_ends_at=?'; $params[] = $v; }
|
||||
|
||||
$newStatus = null;
|
||||
if (($v = filterRequest('contract_status')) !== null) {
|
||||
$allowed = ['active', 'trial', 'suspended', 'terminated'];
|
||||
if (!in_array($v, $allowed)) {
|
||||
jsonError('Invalid contract_status. Allowed: ' . implode(', ', $allowed), 400);
|
||||
}
|
||||
$newStatus = $v;
|
||||
$updates[] = 'contract_status=?';
|
||||
$params[] = $v;
|
||||
}
|
||||
|
||||
if (empty($updates)) jsonError('No fields to update', 400);
|
||||
|
||||
$updates[] = 'updated_at=NOW()';
|
||||
$params[] = $orgId;
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_orgs SET " . implode(', ', $updates) . " WHERE id=?"
|
||||
)->execute($params);
|
||||
|
||||
// إبطال جلسات المشرفين عند التعليق أو الإنهاء
|
||||
if ($newStatus && in_array($newStatus, ['suspended', 'terminated'])) {
|
||||
$admins = $transit_con->prepare("SELECT id FROM transit_org_admins WHERE org_id=?");
|
||||
$admins->execute([$orgId]);
|
||||
|
||||
foreach ($admins->fetchAll(PDO::FETCH_COLUMN) as $adminId) {
|
||||
$sessions = $transit_con->prepare("SELECT token_hash FROM transit_sessions WHERE admin_id=?");
|
||||
$sessions->execute([$adminId]);
|
||||
foreach ($sessions->fetchAll(PDO::FETCH_COLUMN) as $hash) {
|
||||
if ($redis) $redis->del("transit:session:{$hash}");
|
||||
}
|
||||
$transit_con->prepare("DELETE FROM transit_sessions WHERE admin_id=?")->execute([$adminId]);
|
||||
}
|
||||
|
||||
appLog("[ADMIN][TRANSIT][ORG][update] org={$orgId} contract_status={$newStatus} — all admin sessions invalidated");
|
||||
} else {
|
||||
appLog("[ADMIN][TRANSIT][ORG][update] org={$orgId} updated=" . implode(',', $updates));
|
||||
}
|
||||
|
||||
jsonSuccess(['org_id' => $orgId, 'contract_status' => $newStatus ?? $org['contract_status']]);
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
// Admin/transit/route/approve.php — فريق سيرو يعتمد أو يوقف خطاً
|
||||
// POST: route_id, action (approve|suspend|reject)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
$action = filterRequest('action');
|
||||
|
||||
if (!$routeId) jsonError('route_id is required', 400);
|
||||
|
||||
$allowed = ['approve', 'suspend', 'reject'];
|
||||
if (!in_array($action, $allowed)) jsonError('Invalid action. Allowed: ' . implode(', ', $allowed), 400);
|
||||
|
||||
$st = $transit_con->prepare("SELECT id, org_id, name_ar, status FROM transit_routes WHERE id=? LIMIT 1");
|
||||
$st->execute([$routeId]);
|
||||
$route = $st->fetch();
|
||||
if (!$route) jsonError('Route not found', 404);
|
||||
|
||||
$statusMap = [
|
||||
'approve' => 'active',
|
||||
'suspend' => 'suspended',
|
||||
'reject' => 'rejected',
|
||||
];
|
||||
$newStatus = $statusMap[$action];
|
||||
|
||||
if ($route['status'] === $newStatus) {
|
||||
jsonError("Route is already in status: {$newStatus}", 409);
|
||||
}
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_routes
|
||||
SET status=?, approved_by=?, approved_at=NOW(), updated_at=NOW()
|
||||
WHERE id=?"
|
||||
)->execute([$newStatus, (string)$user_id, $routeId]);
|
||||
|
||||
appLog("[TRANSIT][ROUTE] route #{$routeId} org#{$route['org_id']} → {$newStatus} by admin #{$user_id}", 'INFO');
|
||||
|
||||
// كتابة في Redis للسوكيت: transit:route_org:{routeId} → org_id
|
||||
// يُستخدم في passenger_socket لتحقق العضوية
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$redisLocation->set("transit:route_org:{$routeId}", (string)$route['org_id']);
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'route_id' => $routeId,
|
||||
'route_name' => $route['name_ar'],
|
||||
'new_status' => $newStatus,
|
||||
], "Route {$action}d successfully");
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// Admin/transit/route/pending.php — قائمة الخطوط المسودة بانتظار الاعتماد
|
||||
// POST: — (اختياري: org_id للفلترة)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
$orgIdFilter = filterRequest('org_id', 'int');
|
||||
|
||||
$sql = "SELECT r.id, r.org_id, r.name_ar, r.name_en, r.direction, r.distance_km,
|
||||
r.duration_min, r.status, r.created_at,
|
||||
o.name_ar AS org_name, o.type AS org_type, o.country,
|
||||
(SELECT COUNT(*) FROM transit_stops s WHERE s.route_id = r.id) AS stops_count
|
||||
FROM transit_routes r
|
||||
JOIN transit_orgs o ON o.id = r.org_id
|
||||
WHERE r.status = 'draft'";
|
||||
|
||||
$params = [];
|
||||
if ($orgIdFilter) {
|
||||
$sql .= " AND r.org_id = ?";
|
||||
$params[] = $orgIdFilter;
|
||||
}
|
||||
$sql .= " ORDER BY r.created_at DESC LIMIT 100";
|
||||
|
||||
$st = $transit_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$routes = $st->fetchAll();
|
||||
|
||||
// جلب محطات كل خط للمعاينة
|
||||
$stStops = $transit_con->prepare(
|
||||
"SELECT id, sequence, name_ar, latitude, longitude, is_major
|
||||
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
|
||||
);
|
||||
foreach ($routes as &$r) {
|
||||
$stStops->execute([$r['id']]);
|
||||
$r['stops'] = $stStops->fetchAll();
|
||||
}
|
||||
unset($r);
|
||||
|
||||
jsonSuccess(['routes' => $routes, 'total' => count($routes)]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
* dashboard_data.php
|
||||
* API موحّد للداشبورد التحليلي — يقرأ من ملفات JSON المؤرشفة + بيانات حيّة من Redis.
|
||||
*
|
||||
* Parameters:
|
||||
* date (optional) — YYYY-MM-DD, default: today
|
||||
* section (optional) — realtime|gap|heatmap|pricing|revenue|growth|market|complaints|funnel|hourly|weekly|zones|retention|all
|
||||
* default: all
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$requestedDate = filterRequest('date') ?: date('Y-m-d');
|
||||
$section = filterRequest('section') ?: 'all';
|
||||
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $requestedDate)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid date format']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cacheBase = __DIR__ . '/../../../cache/analytics';
|
||||
$dayDir = "$cacheBase/$requestedDate";
|
||||
|
||||
$response = [
|
||||
'status' => 'success',
|
||||
'date' => $requestedDate,
|
||||
'section' => $section,
|
||||
'data' => [],
|
||||
];
|
||||
|
||||
function loadSnapshot(string $dir, string $name): ?array {
|
||||
$path = "$dir/$name.json";
|
||||
if (!file_exists($path)) return null;
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
function loadLatestRealtime(string $dir): ?array {
|
||||
$files = glob("$dir/realtime_*.json");
|
||||
if (empty($files)) return null;
|
||||
sort($files);
|
||||
$latest = end($files);
|
||||
$data = json_decode(file_get_contents($latest), true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
$sectionMap = [
|
||||
'realtime' => fn() => loadLatestRealtime($dayDir),
|
||||
'gap' => fn() => loadSnapshot($dayDir, 'supply_demand_gap'),
|
||||
'heatmap' => fn() => loadSnapshot($dayDir, 'heatmap'),
|
||||
'pricing' => fn() => loadSnapshot($dayDir, 'pricing_grids'),
|
||||
'demand' => fn() => loadSnapshot($dayDir, 'predictive_demand'),
|
||||
'revenue' => fn() => loadSnapshot($dayDir, 'revenue_30d'),
|
||||
'growth' => fn() => loadSnapshot($dayDir, 'growth_30d'),
|
||||
'market' => fn() => loadSnapshot($dayDir, 'market_health'),
|
||||
'complaints' => fn() => loadSnapshot($dayDir, 'complaints_open'),
|
||||
'funnel' => fn() => loadSnapshot($dayDir, 'ride_funnel'),
|
||||
'hourly' => fn() => loadSnapshot($dayDir, 'hourly_pattern'),
|
||||
'weekly' => fn() => loadSnapshot($dayDir, 'weekly_comparison'),
|
||||
'zones' => fn() => loadSnapshot($dayDir, 'top_zones'),
|
||||
'retention' => fn() => loadSnapshot($dayDir, 'retention_cohort'),
|
||||
'competitor' => fn() => loadSnapshot($dayDir, 'competitor_prices_24h'),
|
||||
];
|
||||
|
||||
try {
|
||||
if (!is_dir($dayDir)) {
|
||||
$response['data'] = null;
|
||||
$response['note'] = "No snapshot data for $requestedDate";
|
||||
|
||||
$indexPath = "$cacheBase/index.json";
|
||||
if (file_exists($indexPath)) {
|
||||
$idx = json_decode(file_get_contents($indexPath), true);
|
||||
$response['available_dates'] = $idx['available_dates'] ?? [];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($section === 'all') {
|
||||
foreach ($sectionMap as $key => $loader) {
|
||||
$result = $loader();
|
||||
if ($result !== null) {
|
||||
$response['data'][$key] = $result;
|
||||
}
|
||||
}
|
||||
} elseif (isset($sectionMap[$section])) {
|
||||
$response['data'] = $sectionMap[$section]();
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => "Unknown section: $section",
|
||||
'available' => array_keys($sectionMap),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$indexPath = "$cacheBase/index.json";
|
||||
if (file_exists($indexPath)) {
|
||||
$idx = json_decode(file_get_contents($indexPath), true);
|
||||
$response['available_dates'] = $idx['available_dates'] ?? [];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log("[dashboard_data.php] " . $e->getMessage());
|
||||
echo json_encode(['status' => 'error', 'message' => 'Internal error']);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// Admin/v2/analytics/driver_ranking.php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// أفضل 10 كباتن حسب عدد الرحلات المكتملة
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
d.id, d.first_name, d.last_name, d.phone,
|
||||
COUNT(r.id) as completed_rides,
|
||||
SUM(r.price) as total_revenue
|
||||
FROM driver d
|
||||
JOIN ride r ON d.id = r.driver_id
|
||||
WHERE LOWER(r.status) IN ('finished','completed')
|
||||
GROUP BY d.id, d.first_name, d.last_name, d.phone
|
||||
ORDER BY completed_rides DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$stmt->execute();
|
||||
$top_drivers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير الأسماء
|
||||
foreach ($top_drivers as &$driver) {
|
||||
$driver['first_name'] = $encryptionHelper->decryptData($driver['first_name']);
|
||||
$driver['last_name'] = $encryptionHelper->decryptData($driver['last_name']);
|
||||
$driver['phone'] = $encryptionHelper->decryptData($driver['phone']);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => $top_drivers
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log("[driver_ranking.php] " . $e->getMessage());
|
||||
echo json_encode(['status' => 'error', 'message' => 'An internal error occurred']);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
// Admin/v2/analytics/growth.php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// نمو الركاب لآخر 30 يوم
|
||||
$stmt = $con->prepare("
|
||||
SELECT DATE(created_at) as date, COUNT(*) as new_passengers
|
||||
FROM passengers
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
");
|
||||
$stmt->execute();
|
||||
$passenger_growth = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// نمو السائقين لآخر 30 يوم
|
||||
$stmt = $con->prepare("
|
||||
SELECT DATE(created_at) as date, COUNT(*) as new_drivers
|
||||
FROM driver
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
");
|
||||
$stmt->execute();
|
||||
$driver_growth = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// إجمالي الأعداد الحالية
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM passengers");
|
||||
$stmt->execute();
|
||||
$total_passengers = $stmt->fetchColumn();
|
||||
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM driver");
|
||||
$stmt->execute();
|
||||
$total_drivers = $stmt->fetchColumn();
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'passenger_daily' => $passenger_growth,
|
||||
'driver_daily' => $driver_growth,
|
||||
'totals' => [
|
||||
'passengers' => (int)$total_passengers,
|
||||
'drivers' => (int)$total_drivers
|
||||
]
|
||||
]
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log("[growth.php] " . $e->getMessage());
|
||||
echo json_encode(['status' => 'error', 'message' => 'An internal error occurred']);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
// Admin/v2/analytics/revenue.php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// إحصائيات الإيرادات لآخر 30 يوم
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
SUM(price) as total_revenue,
|
||||
SUM(price - price_for_driver) as company_profit,
|
||||
COUNT(*) as total_rides
|
||||
FROM ride
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
");
|
||||
$stmt->execute();
|
||||
$daily_stats = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ملخص عام
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
SUM(price) as total_revenue_all,
|
||||
SUM(price - price_for_driver) as total_profit_all,
|
||||
AVG(price) as avg_ride_price
|
||||
FROM ride
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
");
|
||||
$stmt->execute();
|
||||
$summary = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'daily' => $daily_stats,
|
||||
'summary' => $summary
|
||||
]
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log("[revenue.php] " . $e->getMessage());
|
||||
echo json_encode(['status' => 'error', 'message' => 'An internal error occurred']);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
// Admin/v2/financial/settlements.php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// جلب السائقين الذين لديهم مستحقات أو مديونية
|
||||
// الحسبة: إجمالي (price_for_driver) من الرحلات المكتملة
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
d.id, d.first_name, d.last_name, d.phone,
|
||||
SUM(r.price_for_driver) as total_earned,
|
||||
COUNT(r.id) as total_rides
|
||||
FROM driver d
|
||||
LEFT JOIN ride r ON d.id = r.driver_id AND LOWER(r.status) IN ('finished','completed')
|
||||
GROUP BY d.id
|
||||
HAVING total_earned > 0
|
||||
ORDER BY total_earned DESC
|
||||
LIMIT 50
|
||||
");
|
||||
$stmt->execute();
|
||||
$drivers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك تشفير البيانات
|
||||
foreach ($drivers as &$driver) {
|
||||
$driver['first_name'] = $encryptionHelper->decryptData($driver['first_name']);
|
||||
$driver['last_name'] = $encryptionHelper->decryptData($driver['last_name']);
|
||||
$driver['phone'] = $encryptionHelper->decryptData($driver['phone']);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => $drivers
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log("[settlements.php] " . $e->getMessage());
|
||||
echo json_encode(['status' => 'error', 'message' => 'An internal error occurred']);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
// Admin/v2/financial/stats.php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// إحصائيات مالية عامة
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
SUM(price_for_passenger) as total_revenue,
|
||||
SUM(price_for_driver) as total_driver_pay,
|
||||
SUM(price_for_passenger - price_for_driver) as total_platform_commission,
|
||||
0 as cash_payments,
|
||||
0 as digital_payments
|
||||
FROM ride
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
");
|
||||
$stmt->execute();
|
||||
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => $stats
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log("[stats.php] " . $e->getMessage());
|
||||
echo json_encode(['status' => 'error', 'message' => 'An internal error occurred']);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
// 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'; // إذا كان متاحاً، وإلا سننفذ الإدخال مباشرة
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError("Unauthorized", 403);
|
||||
}
|
||||
|
||||
$action_type = filterRequest('action_type') ?: 'get_all';
|
||||
|
||||
try {
|
||||
if ($action_type === 'get_all') {
|
||||
// جلب قائمة السائقين المحظورين
|
||||
$stmt_drivers = $con->prepare("
|
||||
SELECT id, driver_id, phone, reason, created_at, 'driver' as type
|
||||
FROM blacklist_driver
|
||||
ORDER BY created_at DESC
|
||||
");
|
||||
$stmt_drivers->execute();
|
||||
$blocked_drivers = $stmt_drivers->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// جلب قائمة الركاب المحظورين
|
||||
$stmt_passengers = $con->prepare("
|
||||
SELECT id, phone, phone_normalized, reason, expires_at, created_at, 'passenger' as type
|
||||
FROM passenger_blacklist
|
||||
ORDER BY created_at DESC
|
||||
");
|
||||
$stmt_passengers->execute();
|
||||
$blocked_passengers = $stmt_passengers->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// فك التشفير عن الأرقام إذا كانت مشفرة
|
||||
foreach ($blocked_drivers as &$bd) {
|
||||
$decrypted_phone = $encryptionHelper->decryptData($bd['phone']);
|
||||
if ($decrypted_phone) $bd['phone'] = $decrypted_phone;
|
||||
}
|
||||
|
||||
foreach ($blocked_passengers as &$bp) {
|
||||
$decrypted_phone = $encryptionHelper->decryptData($bp['phone']);
|
||||
if ($decrypted_phone) $bp['phone'] = $decrypted_phone;
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'drivers' => $blocked_drivers,
|
||||
'passengers' => $blocked_passengers
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action_type === 'unblock_driver') {
|
||||
$phone = filterRequest('phone');
|
||||
if (!$phone) jsonError("Phone is required");
|
||||
|
||||
$enc_phone = $encryptionHelper->encryptData($phone);
|
||||
|
||||
$stmt = $con->prepare("DELETE FROM blacklist_driver WHERE phone = ? OR phone = ?");
|
||||
$stmt->execute([$phone, $enc_phone]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// تسجيل في الـ Audit Log
|
||||
$log_stmt = $con->prepare("INSERT INTO admin_audit_log (admin_id, admin_phone, action, table_name, entity_type, details) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
$log_stmt->execute([
|
||||
$user_id, 'Admin', 'unblock_driver', 'blacklist_driver', 'driver',
|
||||
json_encode(['phone' => $phone, 'action' => 'Unblocked driver'])
|
||||
]);
|
||||
|
||||
jsonSuccess(null, "Driver unblocked successfully");
|
||||
} else {
|
||||
jsonError("Driver not found in blacklist");
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action_type === 'unblock_passenger') {
|
||||
$phone_normalized = filterRequest('phone_normalized');
|
||||
if (!$phone_normalized) jsonError("Normalized Phone is required");
|
||||
|
||||
$stmt = $con->prepare("DELETE FROM passenger_blacklist WHERE phone_normalized = ?");
|
||||
$stmt->execute([$phone_normalized]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// تسجيل في الـ Audit Log
|
||||
$log_stmt = $con->prepare("INSERT INTO admin_audit_log (admin_id, admin_phone, action, table_name, entity_type, details) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
$log_stmt->execute([
|
||||
$user_id, 'Admin', 'unblock_passenger', 'passenger_blacklist', 'passenger',
|
||||
json_encode(['phone_normalized' => $phone_normalized, 'action' => 'Unblocked passenger'])
|
||||
]);
|
||||
|
||||
jsonSuccess(null, "Passenger unblocked successfully");
|
||||
} else {
|
||||
jsonError("Passenger not found in blacklist");
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
jsonError("Invalid action_type", 400);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[blacklist_manager.php] " . $e->getMessage());
|
||||
jsonError("Blacklist action failed. Please try again later.", 500);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
// Admin/v2/quality/driver_scorecard.php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
// require_once __DIR__ . '/../../../encrypt_decrypt.php';
|
||||
|
||||
// التحقق من الصلاحيات
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError("Unauthorized", 403);
|
||||
}
|
||||
|
||||
$driver_id = filterRequest('driver_id');
|
||||
if (!$driver_id) {
|
||||
jsonError("Missing driver_id", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$scorecard = [];
|
||||
|
||||
// 1. البيانات الأساسية للسائق
|
||||
$stmt = $con->prepare("
|
||||
SELECT id, first_name, last_name, phone, status, created_at, expiry_date
|
||||
FROM driver
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$driver_id]);
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$driver) {
|
||||
jsonError("Driver not found", 404);
|
||||
}
|
||||
|
||||
// فك التشفير للبيانات الأساسية
|
||||
if (!empty($driver['first_name'])) $driver['first_name'] = $encryptionHelper->decryptData($driver['first_name']) ?: $driver['first_name'];
|
||||
if (!empty($driver['last_name'])) $driver['last_name'] = $encryptionHelper->decryptData($driver['last_name']) ?: $driver['last_name'];
|
||||
if (!empty($driver['phone'])) $driver['phone'] = $encryptionHelper->decryptData($driver['phone']) ?: $driver['phone'];
|
||||
|
||||
$scorecard['basic_info'] = $driver;
|
||||
|
||||
// 2. إحصائيات الرحلات (نسبة الإنجاز والإلغاء)
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
COUNT(*) as total_rides,
|
||||
SUM(CASE WHEN LOWER(status) IN ('finished','completed') THEN 1 ELSE 0 END) as completed_rides,
|
||||
SUM(CASE WHEN status = 'cancel' AND cancel_by = 'driver' THEN 1 ELSE 0 END) as driver_cancellations,
|
||||
SUM(CASE WHEN status = 'cancel' AND cancel_by = 'passenger' THEN 1 ELSE 0 END) as passenger_cancellations
|
||||
FROM ride
|
||||
WHERE driver_id = ?
|
||||
");
|
||||
$stmt->execute([$driver_id]);
|
||||
$rides = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// حساب نسبة الإنجاز
|
||||
$total = (int)$rides['total_rides'];
|
||||
$completed = (int)$rides['completed_rides'];
|
||||
$rides['completion_rate'] = $total > 0 ? round(($completed / $total) * 100, 2) : 0;
|
||||
|
||||
$scorecard['rides_stats'] = $rides;
|
||||
|
||||
// 3. التقييمات
|
||||
$stmt = $con->prepare("SELECT IFNULL(AVG(rating_driver), 0) as avg_rating FROM ride WHERE driver_id = ? AND rating_driver > 0");
|
||||
$stmt->execute([$driver_id]);
|
||||
$scorecard['rating'] = round($stmt->fetchColumn(), 2);
|
||||
|
||||
// 4. تحليل السلوك (Behavior)
|
||||
// نستخدم جدول driver_behavior لجمع المتوسطات
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
IFNULL(AVG(behavior_score), 100) as avg_behavior_score,
|
||||
IFNULL(AVG(max_speed), 0) as avg_max_speed,
|
||||
IFNULL(SUM(hard_brakes), 0) as total_hard_brakes,
|
||||
IFNULL(SUM(rapid_accelerations), 0) as total_rapid_accel
|
||||
FROM driver_behavior
|
||||
WHERE driver_id = ?
|
||||
");
|
||||
$stmt->execute([$driver_id]);
|
||||
$scorecard['behavior'] = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// 5. الشكاوى (Complaints)
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
COUNT(*) as total_complaints,
|
||||
SUM(CASE WHEN statusComplaint = 'Open' THEN 1 ELSE 0 END) as open_complaints,
|
||||
SUM(CASE WHEN statusComplaint = 'Resolved' THEN 1 ELSE 0 END) as resolved_complaints
|
||||
FROM complaint
|
||||
WHERE driver_id = ?
|
||||
");
|
||||
$stmt->execute([$driver_id]);
|
||||
$scorecard['complaints'] = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// 6. تقييم شامل (Overall Score) من 100
|
||||
// وزن التقييم: 40% إنجاز رحلات، 30% تقييم ركاب (محول لـ 100)، 30% سلوك قيادة، وخصم للشكاوى
|
||||
$completion_score = $rides['completion_rate'] * 0.4;
|
||||
$rating_score = ($scorecard['rating'] / 5) * 100 * 0.3;
|
||||
$behavior_score = $scorecard['behavior']['avg_behavior_score'] * 0.3;
|
||||
$complaint_penalty = $scorecard['complaints']['total_complaints'] * 5; // خصم 5 نقاط عن كل شكوى
|
||||
|
||||
$overall = $completion_score + $rating_score + $behavior_score - $complaint_penalty;
|
||||
$scorecard['overall_score'] = max(0, min(100, round($overall, 1)));
|
||||
|
||||
jsonSuccess($scorecard);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[driver_scorecard.php] " . $e->getMessage());
|
||||
jsonError("Failed to fetch scorecard. Please try again later.", 500);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
// Admin/v2/realtime_dashboard.php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// التحقق من الصلاحيات
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access. Admin role required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$response = [
|
||||
'status' => 'success',
|
||||
'message' => []
|
||||
];
|
||||
|
||||
try {
|
||||
// 1. الرحلات النشطة حالياً
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM ride WHERE status IN ('wait', 'started', 'arrived')");
|
||||
$stmt->execute();
|
||||
$active_rides = $stmt->fetchColumn();
|
||||
|
||||
// 2. السائقون المتصلون حالياً (أونلاين)
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM car_locations WHERE status = 'on'");
|
||||
$stmt->execute();
|
||||
$online_drivers = $stmt->fetchColumn();
|
||||
|
||||
// 3. إيرادات اليوم
|
||||
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = CURDATE()");
|
||||
$stmt->execute();
|
||||
$revenue_today = $stmt->fetchColumn();
|
||||
|
||||
// إيرادات الأمس (للمقارنة)
|
||||
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
|
||||
$stmt->execute();
|
||||
$revenue_yesterday = $stmt->fetchColumn();
|
||||
|
||||
// 4. شكاوى جديدة اليوم
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM complaint WHERE DATE(date_filed) = CURDATE() AND statusComplaint = 'Open'");
|
||||
$stmt->execute();
|
||||
$new_complaints = $stmt->fetchColumn();
|
||||
|
||||
// 5. رخص تنتهي هذا الشهر
|
||||
$stmt = $con->prepare("SELECT COUNT(*) FROM driver WHERE expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)");
|
||||
$stmt->execute();
|
||||
$expiring_licenses = $stmt->fetchColumn();
|
||||
|
||||
$response['message'] = [
|
||||
'active_rides' => (int)$active_rides,
|
||||
'online_drivers' => (int)$online_drivers,
|
||||
'revenue_today' => (float)$revenue_today,
|
||||
'revenue_yesterday' => (float)$revenue_yesterday,
|
||||
'new_complaints' => (int)$new_complaints,
|
||||
'expiring_licenses' => (int)$expiring_licenses
|
||||
];
|
||||
|
||||
echo json_encode($response);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
error_log("[realtime_dashboard.php] " . $e->getMessage());
|
||||
echo json_encode(['status' => 'error', 'message' => 'An internal error occurred']);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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);
|
||||
|
||||
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);
|
||||
http_response_code(500);
|
||||
printFailure('loading failed', 500);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── فحص الصلاحيات ────────────────────────────────────────
|
||||
if ($role !== 'super_admin' && $role !== 'admin') {
|
||||
@file_put_contents($debugFile, " → BLOCKED: role=$role\n", FILE_APPEND);
|
||||
printFailure("Unauthorized. role=$role", 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// استعلام لجلب السجلات مع محاولة جلب الاسم من جدول الموظفين أو جدول المشرفين
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
l.id, l.admin_id, l.action, l.table_name, l.record_id, l.details, l.created_at,
|
||||
COALESCE(e.name, au.name) as admin_name_raw
|
||||
FROM admin_audit_log l
|
||||
LEFT JOIN employee e ON l.admin_id COLLATE utf8mb4_general_ci = e.id COLLATE utf8mb4_general_ci
|
||||
LEFT JOIN adminUser au ON l.admin_id COLLATE utf8mb4_general_ci = au.id COLLATE utf8mb4_general_ci
|
||||
OR l.admin_id COLLATE utf8mb4_general_ci = au.email COLLATE utf8mb4_general_ci
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 100
|
||||
");
|
||||
$stmt->execute();
|
||||
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// معالجة البيانات: فك تشفير الأسماء إذا كانت مشفرة
|
||||
foreach ($logs as &$log) {
|
||||
$rawName = $log['admin_name_raw'];
|
||||
if (!empty($rawName)) {
|
||||
// محاولة فك التشفير
|
||||
$decrypted = $encryptionHelper->decryptData($rawName);
|
||||
$log['admin_name'] = ($decrypted !== false) ? $decrypted : $rawName;
|
||||
} else {
|
||||
$log['admin_name'] = 'أدمن غير معروف';
|
||||
}
|
||||
unset($log['admin_name_raw']);
|
||||
}
|
||||
|
||||
$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);
|
||||
jsonError('Query failed', 500);
|
||||
}
|
||||
?>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user