Compare commits
262
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4620e84d34 | ||
|
|
b78a6797d5 | ||
|
|
811b23ca9e | ||
|
|
915d517ba7 | ||
|
|
12fc64dc9a | ||
|
|
46e74330a1 | ||
|
|
410fb14d77 | ||
|
|
cf3fea3834 | ||
|
|
0452c36379 | ||
|
|
7362f1ce96 | ||
|
|
8d67530b44 | ||
|
|
f94a9478ac | ||
|
|
7a576b7327 | ||
|
|
ecfe756849 | ||
|
|
4d2beaae5e | ||
|
|
b84e26fe9a | ||
|
|
77c0b48ec2 | ||
|
|
e106d2df4e | ||
|
|
899a18b52d | ||
|
|
e6504acdea | ||
|
|
ef1240b130 | ||
|
|
0e6dd68385 | ||
|
|
afb5189515 | ||
|
|
6fec88251c | ||
|
|
b76eccb763 | ||
|
|
a26472ed9f | ||
|
|
ab3be7e2e3 | ||
|
|
f66db7db42 | ||
|
|
143146c1b4 | ||
|
|
e269cb9b70 | ||
|
|
330dfc6dba | ||
|
|
4b5235c35c | ||
|
|
16feb51a75 | ||
|
|
3af99cc18a | ||
|
|
7830efead7 | ||
|
|
ca6cb7a3fe | ||
|
|
f325ffce42 | ||
|
|
c43f801542 | ||
|
|
2bacb1b9e1 | ||
|
|
5ec54c03f5 | ||
|
|
a954f49307 | ||
|
|
91fe0f78f7 | ||
|
|
5f5b68a8cd | ||
|
|
1664743ef9 | ||
|
|
1dfc302a4f | ||
|
|
76c8652bf0 | ||
|
|
a095472f39 | ||
|
|
c8a9e98ff5 | ||
|
|
e03b9c30d5 | ||
|
|
20ea9aa12c | ||
|
|
8d3e63d1c7 | ||
|
|
4bbc687c15 | ||
|
|
5e103f60f2 | ||
|
|
27369b8ac1 | ||
|
|
61d6380861 | ||
|
|
3fb7bc5190 | ||
|
|
f9110a7d92 | ||
|
|
a0812dbd10 | ||
|
|
bc1b0129e8 | ||
|
|
2135edcf43 | ||
|
|
35a66935aa | ||
|
|
8d7e3118b5 | ||
|
|
39b5a7fc7f | ||
|
|
a1c19b052d | ||
|
|
c9b4d14da6 | ||
|
|
15f55ff3e4 | ||
|
|
761b957c96 | ||
|
|
57e22477fb | ||
|
|
6802026dbd | ||
|
|
81ee2acefd | ||
|
|
42f6d33efd | ||
|
|
4009af8dd3 | ||
|
|
67f55e5192 | ||
|
|
a0ab6c5155 | ||
|
|
41a06bba0c | ||
|
|
db4ca7dd7a | ||
|
|
852c6ece5c | ||
|
|
915a148ebf | ||
|
|
0d9095fd3f | ||
|
|
eda7018434 | ||
|
|
822cb5963a | ||
|
|
8ec7b9aae6 | ||
|
|
c9168c1c80 | ||
|
|
10f1154cc7 | ||
|
|
9d2a665d64 | ||
|
|
4c738e8f43 | ||
|
|
032e1edda5 | ||
|
|
1ca2c5a5dd | ||
|
|
f115292dd2 | ||
|
|
60e344598b | ||
|
|
8bc9290916 | ||
|
|
9e2964d307 | ||
|
|
b3126013f5 | ||
|
|
5ebff42841 | ||
|
|
2944f21f53 | ||
|
|
917dfc025f | ||
|
|
0b24bc21b6 | ||
|
|
474c212bcb | ||
|
|
e7629a9eb9 | ||
|
|
695d6d7cb2 | ||
|
|
e7aa28fe3d | ||
|
|
7d646b579b | ||
|
|
b4451a0dde | ||
|
|
92eb7fe25d | ||
|
|
1e785061ec | ||
|
|
12e70f3d7e | ||
|
|
12a1cbc98c | ||
|
|
c35b350b31 | ||
|
|
1cef598fc5 | ||
|
|
e5bf70fddb | ||
|
|
9e065e5a83 | ||
|
|
ed8a93a6a3 | ||
|
|
a7fa40dc31 | ||
|
|
b86f95c90d | ||
|
|
b83ca1f664 | ||
|
|
fbb95c70fa | ||
|
|
b9efba3787 | ||
|
|
e798f84c03 | ||
|
|
55fbe22f8e | ||
|
|
f76623a25e | ||
|
|
f62716b510 | ||
|
|
771b436c69 | ||
|
|
939c7a9c2c | ||
|
|
ccbe3ab88d | ||
|
|
09a4bbc79f | ||
|
|
901f429b9c | ||
|
|
6282be37d8 | ||
|
|
a2f0911d13 | ||
|
|
dabf4c13ba | ||
|
|
1b45b505f8 | ||
|
|
8ec0ac2942 | ||
|
|
26d0ec2982 | ||
|
|
11d7d86b2f | ||
|
|
a4b5a2545c | ||
|
|
be14b2716e | ||
|
|
9a73f4303d | ||
|
|
bbcefd20cf | ||
|
|
521d76c4cf | ||
|
|
b613022169 | ||
|
|
540e77984b | ||
|
|
bec8089fd1 | ||
|
|
a5ae6fecbe | ||
|
|
16101927e1 | ||
|
|
58222fbf81 | ||
|
|
bcab0ea221 | ||
|
|
87b4f12da0 | ||
|
|
17abdada10 | ||
|
|
2b302db1dd | ||
|
|
cf95455bcf | ||
|
|
699b380f2b | ||
|
|
3d0c266b7a | ||
|
|
586deec4f4 | ||
|
|
bb3536aa90 | ||
|
|
23f697b1c6 | ||
|
|
1bec13634a | ||
|
|
0ee3655c05 | ||
|
|
4f47c0ad1d | ||
|
|
f3905bcb2c | ||
|
|
1103f6ffcf | ||
|
|
0351bffafc | ||
|
|
096f6a13a0 | ||
|
|
ebd6f3734a | ||
|
|
8d8c3a3817 | ||
|
|
432245a688 | ||
|
|
5d68ec5d0c | ||
|
|
c9bef58f47 | ||
|
|
36ca266132 | ||
|
|
e88d45add4 | ||
|
|
6d6c7cab7f | ||
|
|
818220bba2 | ||
|
|
0f59975144 | ||
|
|
24e03ae46c | ||
|
|
58ada98dd7 | ||
|
|
1b4d831ca6 | ||
|
|
69f043c297 | ||
|
|
e1154d7281 | ||
|
|
630c6277ac | ||
|
|
772398d961 | ||
|
|
b89191c018 | ||
|
|
13d10d13c7 | ||
|
|
085b180bdb | ||
|
|
5e80c886a0 | ||
|
|
dda813ace1 | ||
|
|
5ae5628f37 | ||
|
|
b385cace36 | ||
|
|
64b17fbb26 | ||
|
|
c00bfa24ba | ||
|
|
1f57b642c3 | ||
|
|
c5ed2099f9 | ||
|
|
8cb83b31f2 | ||
|
|
034d64b6f2 | ||
|
|
ab77ed529a | ||
|
|
48382f5d4a | ||
|
|
540ae4d4bc | ||
|
|
a81f805796 | ||
|
|
a547b81b60 | ||
|
|
c956f27230 | ||
|
|
c3fb42cae9 | ||
|
|
a2484d0bf5 | ||
|
|
c6f48a1f91 | ||
|
|
fd3f4a365a | ||
|
|
7bf6dc1be7 | ||
|
|
653d73422f | ||
|
|
8cd4a4b57e | ||
|
|
27200013ac | ||
|
|
8fa2f153c1 | ||
|
|
08340493c0 | ||
|
|
1f68cb7333 | ||
|
|
24fb56f08f | ||
|
|
1fa517acc9 | ||
|
|
8f4bb1631c | ||
|
|
83f5bf516b | ||
|
|
de761c1d97 | ||
|
|
9535d702d6 | ||
|
|
8353c4cef4 | ||
|
|
ce2f3d5675 | ||
|
|
cb9aefc591 | ||
|
|
06dc0aaffe | ||
|
|
8b19adeb80 | ||
|
|
04468dad08 | ||
|
|
6294d6e725 | ||
|
|
607c9bd9f4 | ||
|
|
2bce11b940 | ||
|
|
81d4715664 | ||
|
|
5fb2c25504 | ||
|
|
be6e5bed92 | ||
|
|
b18fd027b6 | ||
|
|
2ff7450d0b | ||
|
|
461a1402ab | ||
|
|
5e6aeb7908 | ||
|
|
d94808c380 | ||
|
|
87dc925ea7 | ||
|
|
45859883a5 | ||
|
|
2152d34a8e | ||
|
|
03e9d648a1 | ||
|
|
a526dae042 | ||
|
|
70718946f5 | ||
|
|
7dff7b6973 | ||
|
|
ac0c343f18 | ||
|
|
6fe1d665e7 | ||
|
|
d452f9baa9 | ||
|
|
21877153eb | ||
|
|
628e169552 | ||
|
|
5725fb36f4 | ||
|
|
3c9a3dbef8 | ||
|
|
d6ab09c19b | ||
|
|
9d257c5d7d | ||
|
|
e21e1f4ec2 | ||
|
|
eb850a2afc | ||
|
|
281bceb121 | ||
|
|
cdfd1b8e02 | ||
|
|
e42d700245 | ||
|
|
61cb615ae7 | ||
|
|
8e6dbf96e2 | ||
|
|
df1f487804 | ||
|
|
32a6531613 | ||
|
|
e42754bb0e | ||
|
|
cadc26518b | ||
|
|
ff49586d6d | ||
|
|
63440b07dc | ||
|
|
c7863dc98f | ||
|
|
823805417c |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "transit-dashboard",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5183,
|
||||
"cwd": "transit_dashboard"
|
||||
},
|
||||
{
|
||||
"name": "siro-admin",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["-m", "http.server", "8899", "--directory", "dashboard/siro-admin"],
|
||||
"port": 8899
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -70,8 +70,8 @@ DerivedData/
|
||||
xcuserdata/
|
||||
|
||||
# --- Composer / PHP ---
|
||||
/composer.lock
|
||||
**/composer.lock
|
||||
# composer.lock مُتتبَّع عمداً: بدونه ينهار payment_server/v2 بعد أي نشر نظيف
|
||||
# (vendor يبقى مستثنى أعلاه — يُبنى بـ composer install من الـ lock)
|
||||
|
||||
# --- Logs ---
|
||||
*.log
|
||||
|
||||
@@ -17,6 +17,9 @@ android {
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("String", "BOT_SECRET_KEY", "\"SIRO_BOT_SUPER_SECRET_123\"")
|
||||
// مضيف الباك إند في مكان واحد: تبديله عند نسخ البوت لعلامة أخرى
|
||||
// (مثل انطلق على api.intaleqapp.com) يصير سطراً واحداً لا بحثاً في الكود.
|
||||
buildConfigField("String", "BACKEND_HOST", "\"https://jordan-siro.intaleqapp.com\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -25,9 +25,11 @@ class WorkerClient(private val context: Context) {
|
||||
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "UNKNOWN_DEVICE"
|
||||
}
|
||||
|
||||
// Change this to your actual server domain
|
||||
private val BASE_URL = "https://jordan-siro.intaleqapp.com/backend/bot/standalone_worker.php"
|
||||
// For local testing use: "http://10.0.2.2:8000/standalone_worker.php"
|
||||
// المضيف من BuildConfig.BACKEND_HOST (app/build.gradle.kts) — لا يُشفَّر هنا،
|
||||
// حتى يكون تبديله عند نسخ البوت لعلامة أخرى سطراً واحداً في gradle.
|
||||
// للتجريب المحلي: اضبط BACKEND_HOST على "http://10.0.2.2:8000" مع تقديم
|
||||
// نفس المسار /backend/bot/ من جذر السيرفر المحلي.
|
||||
private val BASE_URL = "${BuildConfig.BACKEND_HOST}/backend/bot/standalone_worker.php"
|
||||
|
||||
private fun generateSignature(deviceId: String, ts: Long): String {
|
||||
val message = "$deviceId$ts"
|
||||
|
||||
@@ -14,6 +14,23 @@ 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
|
||||
# =============================================================================
|
||||
|
||||
@@ -67,15 +67,15 @@ $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['employmentType'] = $encryptionHelper->decryptData($row['employmentType']);
|
||||
$row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus']);
|
||||
$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`");
|
||||
|
||||
@@ -5,6 +5,15 @@ $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`,
|
||||
@@ -53,6 +62,8 @@ $sql = "SELECT
|
||||
) 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
|
||||
";
|
||||
@@ -61,6 +72,8 @@ $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);
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// تشفير driver_id قبل استخدامه في SQL
|
||||
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`,
|
||||
@@ -14,7 +23,6 @@ $sql = "SELECT
|
||||
`driver`.`site`,
|
||||
`driver`.`first_name`,
|
||||
`driver`.`last_name`,
|
||||
`driver`.`education`,
|
||||
`driver`.`employmentType`,
|
||||
`driver`.`maritalStatus`,
|
||||
`driver`.`created_at`,
|
||||
@@ -59,14 +67,23 @@ WHERE `driver`.`id` = :driver_id
|
||||
ORDER BY passengerAverageRating DESC
|
||||
LIMIT 10";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':driver_id', $driver_id);
|
||||
$stmt->execute();
|
||||
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
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']);
|
||||
@@ -74,7 +91,6 @@ foreach ($result as &$row) {
|
||||
$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']);
|
||||
}
|
||||
|
||||
@@ -3,40 +3,81 @@
|
||||
* Admin/Staff/pending.php
|
||||
* جلب الحسابات المعلقة للإداريين والخدمة
|
||||
*/
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../functions.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 {
|
||||
$con = Database::get('main');
|
||||
|
||||
// جلب الإداريين المعلقين
|
||||
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['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['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($admins, $services);
|
||||
|
||||
printSuccess([
|
||||
"data" => $allPending
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[Staff Pending Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
$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();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<?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');
|
||||
@@ -17,11 +20,11 @@ if (empty($fingerprint) || empty($password)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate Limiting محسَّن مع Exponential Backoff
|
||||
// Rate Limiting
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'login');
|
||||
|
||||
// تتبع المحاولات الفاشلة لكل حساب لمنع credential stuffing عبر IPs متعددة
|
||||
// تتبع المحاولات الفاشلة لكل حساب
|
||||
if ($redis && !empty($phone)) {
|
||||
$accountKey = "login_attempts:account:" . hash('sha256', $phone);
|
||||
$accountAttempts = (int) $redis->get($accountKey);
|
||||
@@ -36,6 +39,7 @@ if ($redis && !empty($phone)) {
|
||||
|
||||
// البحث عن المشرف باستخدام بصمة الجهاز (Fingerprint Hash)
|
||||
$fpHash = hash('sha256', $fingerprint);
|
||||
$isTrustedDevice = false;
|
||||
|
||||
// تسجيل محاولة تسجيل الدخول للتدقيق
|
||||
$loginAuditData = [
|
||||
@@ -54,50 +58,84 @@ try {
|
||||
$stmt->execute([':fp' => $fpHash]);
|
||||
$admin = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// إذا لم يتم العثور بالبصمة، وتم تمرير رقم الهاتف (تسجيل دخول لأول مرة أو جهاز جديد)
|
||||
if (!$admin && !empty($phone)) {
|
||||
$encPhoneInput = $encryptionHelper->encryptData($phone);
|
||||
$stmtPhone = $con->prepare("SELECT * FROM adminUser WHERE phone = :phone LIMIT 1");
|
||||
$stmtPhone->execute([':phone' => $encPhoneInput]);
|
||||
$admin = $stmtPhone->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// تأكيد كلمة المرور وتحديث بصمة الجهاز إذا تم إيجاد الحساب
|
||||
if ($admin && password_verify($password, $admin['password'])) {
|
||||
$encFpRaw = $encryptionHelper->encryptData($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; // Update locally
|
||||
} else if ($admin) {
|
||||
// Password incorrect, fail later.
|
||||
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 ($admin['status'] === 'pending') {
|
||||
jsonError("حسابك قيد المراجعة حالياً. يرجى الانتظار للموافقة.");
|
||||
exit;
|
||||
} elseif ($admin['status'] === 'suspended') {
|
||||
jsonError("هذا الحساب معلق. يرجى التواصل مع المدير.");
|
||||
exit;
|
||||
} elseif ($admin['status'] === 'rejected') {
|
||||
jsonError("تم رفض طلب الانضمام لهذا الحساب.");
|
||||
exit;
|
||||
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'])) {
|
||||
|
||||
// إذا كان هذا مجرد تجديد للتوكن (إعادة الدخول التلقائي من التطبيق)، فلا داعي لإرسال OTP
|
||||
if ($isRenewal) {
|
||||
// إذا كان الجهاز موثوقاً (البصمة محفوظة ومطابقة) أو طلب تجديد توكن تلقائي
|
||||
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';
|
||||
|
||||
// إلغاء التوكن القديم إذا وجد في Redis
|
||||
if ($redis) {
|
||||
$oldJti = $redis->get("active_jti:" . $admin['id']);
|
||||
if ($oldJti) {
|
||||
@@ -107,8 +145,9 @@ try {
|
||||
|
||||
$jwt = $jwtService->generateAccessToken($admin['id'], $role, $audience, $fingerprint);
|
||||
|
||||
// فك تشفير البيانات للعرض
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
if ($encryptionHelper && !empty($admin['name'])) {
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
}
|
||||
unset($admin['password']);
|
||||
|
||||
printSuccess([
|
||||
@@ -120,67 +159,45 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. توليد رمز تحقق OTP (3 أرقام) وإرساله عبر نظام OTP الموحد
|
||||
// 3. توليد رمز تحقق OTP (3 أرقام) وإرساله عبر نظام OTP الموحد (Nabeh API للواتساب)
|
||||
$otp = (string)random_int(100, 999);
|
||||
$encryptedPhone = $admin['phone'] ?? '';
|
||||
|
||||
if (empty($encryptedPhone)) {
|
||||
jsonError("رقم الهاتف غير مسجل لهذا الحساب. يرجى مراجعة الإدارة.");
|
||||
exit;
|
||||
$rawPhone = ($encryptionHelper && !empty($encryptedPhone)) ? $encryptionHelper->decryptData($encryptedPhone) : $encryptedPhone;
|
||||
if (!$rawPhone || empty($rawPhone)) {
|
||||
$rawPhone = $encryptedPhone;
|
||||
}
|
||||
|
||||
// فك تشفير رقم الهاتف (مخزن مشفراً في قاعدة البيانات)
|
||||
$phone = $encryptionHelper->decryptData($encryptedPhone);
|
||||
if (!$phone || empty($phone)) {
|
||||
$phone = $encryptedPhone;
|
||||
}
|
||||
|
||||
// استخدام نظام OTP الموحد (Nabeh API للواتساب)
|
||||
// تحميل موزع خدمات OTP عبر Nabeh API
|
||||
require_once __DIR__ . '/../../auth/otp/providers.php';
|
||||
$country = 'Jordan';
|
||||
$method = 'whatsapp';
|
||||
|
||||
$success = false;
|
||||
switch ($country) {
|
||||
case 'Jordan':
|
||||
$success = sendNabehOtp($phone, $otp, $method, 'admin');
|
||||
break;
|
||||
default:
|
||||
$success = sendNabehOtp($phone, $otp, $method, 'admin');
|
||||
break;
|
||||
if (function_exists('sendNabehOtp')) {
|
||||
$success = sendNabehOtp($rawPhone, $otp, 'whatsapp', 'admin');
|
||||
}
|
||||
|
||||
// تخزين OTP (SHA-256 hash) مع الرقم المشفر من adminUser (توافق مع verify_login.php)
|
||||
// تخزين 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 = substr($phone, 0, 4) . '****' . substr($phone, -3);
|
||||
$maskedPhone = (strlen($rawPhone) > 7) ? substr($rawPhone, 0, 4) . '****' . substr($rawPhone, -3) : $rawPhone;
|
||||
|
||||
if ($success) {
|
||||
printSuccess([
|
||||
"status" => "otp_required",
|
||||
"message" => "تم إرسال رمز التحقق إلى WhatsApp الخاص بك.",
|
||||
"phone" => $maskedPhone
|
||||
]);
|
||||
} else {
|
||||
error_log("[ADMIN_LOGIN_WARN] Nabeh OTP failed for $phone, but OTP stored for debugging");
|
||||
printSuccess([
|
||||
"status" => "otp_required",
|
||||
"message" => "فشل إرسال واتساب. تحقق من error_log لمعرفة OTP.",
|
||||
"phone" => $maskedPhone
|
||||
]);
|
||||
}
|
||||
printSuccess([
|
||||
"status" => "otp_required",
|
||||
"message" => $success ? "تم إرسال رمز التحقق إلى WhatsApp الخاص بك." : "فشل إرسال واتساب. تحقق من error_log لمعرفة OTP.",
|
||||
"phone" => $maskedPhone
|
||||
]);
|
||||
exit;
|
||||
} else {
|
||||
jsonError("كلمة المرور غير صحيحة.");
|
||||
}
|
||||
} else {
|
||||
jsonError("الحساب أو الجهاز غير مسجل. يرجى إدخال رقم هاتفك وكلمة المرور إذا كان هذا أول تسجيل دخول لك.");
|
||||
jsonError("الحساب غير موجود. يرجى التأكد من اسم المستخدم أو البريد الإلكتروني وكلمة المرور.");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[Admin Login Error] " . $e->getMessage());
|
||||
jsonError("حدث خطأ في السيرفر. يرجى المحاولة لاحقاً.");
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Admin Login Throwable Error] " . $e->getMessage() . "\nTrace: " . $e->getTraceAsString());
|
||||
jsonError("حدث خطأ في السيرفر: " . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,55 @@ $rateLimiter->enforce(RateLimiter::identifier(), 'otp');
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// 1. جلب بيانات المسؤول عبر البصمة (مصدر موثوق وغير مشفر)
|
||||
// 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) {
|
||||
jsonError("المسؤول غير موجود أو البصمة غير مطابقة.");
|
||||
// إذا كانت البصمة جديدة وغير مسجلة بعد، نبحث عن الحساب المرتبط بـ 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;
|
||||
}
|
||||
|
||||
@@ -39,10 +80,7 @@ try {
|
||||
// فك تشفيره لو احتجنا إرساله أو عرضه، لكن هنا نحن نحتاج المشفر للبحث
|
||||
// $phone = $encryptionHelper->decryptData($encryptedPhone);
|
||||
|
||||
// هاش الرمز (OTP) القادم من التطبيق للمقارنة
|
||||
$otpHash = hash('sha256', (string)$otp);
|
||||
|
||||
// 3. التحقق من الـ OTP
|
||||
// 3. التحقق من الـ OTP (الهاش محسوب مسبقاً في المتغير $otpHash)
|
||||
$stmt = $con->prepare("SELECT * FROM token_verification_admin
|
||||
WHERE phone_number = ? AND token = ?
|
||||
AND expiration_time >= NOW()");
|
||||
@@ -56,7 +94,17 @@ try {
|
||||
// حذف الرمز بعد استخدامه لمرة واحدة (باستخدام الرقم المشفر)
|
||||
$con->prepare("DELETE FROM token_verification_admin WHERE phone_number = ?")->execute([$encryptedPhone]);
|
||||
|
||||
// 4. إصدار التوكن النهائي
|
||||
// 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';
|
||||
|
||||
@@ -71,7 +119,9 @@ try {
|
||||
$jwt = $jwtService->generateAccessToken($admin['id'], $role, $audience, $fingerprint);
|
||||
|
||||
// فك تشفير البيانات للعرض
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
if ($encryptionHelper && !empty($admin['name'])) {
|
||||
$admin['name'] = $encryptionHelper->decryptData($admin['name']) ?: $admin['name'];
|
||||
}
|
||||
unset($admin['password']);
|
||||
|
||||
printSuccess([
|
||||
@@ -81,7 +131,7 @@ try {
|
||||
"expires_in" => 3600
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("[Admin Verify OTP Error] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Admin Verify OTP Error] " . $e->getMessage() . "\nTrace: " . $e->getTraceAsString());
|
||||
jsonError("Server Error: " . $e->getMessage() . " on line " . $e->getLine());
|
||||
}
|
||||
|
||||
@@ -29,14 +29,18 @@ 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 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,
|
||||
|
||||
-- أرباح السائق والشركة
|
||||
(SELECT SUM(price_for_driver) FROM ride WHERE status = 'Finished') AS total_driver_earnings,
|
||||
-- ملاحظة: خط الرحلات الحالي يكتب '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,
|
||||
|
||||
-- توزيع الرحلات حسب الوقت
|
||||
@@ -49,10 +53,10 @@ SELECT
|
||||
(SELECT COUNT(*) FROM ride WHERE carType = 'Speed') AS speed,
|
||||
(SELECT COUNT(*) FROM ride WHERE carType = 'Lady') AS lady,
|
||||
|
||||
-- حالة الرحلات
|
||||
(SELECT COUNT(*) FROM ride WHERE status = 'wait') AS ongoing_rides,
|
||||
(SELECT COUNT(*) FROM ride WHERE status = 'Finished') AS completed_rides,
|
||||
(SELECT COUNT(*) FROM ride WHERE status = 'cancel') AS cancelled_rides,
|
||||
-- حالة الرحلات (تغطي عائلتي الحالات: القديمة 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,
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?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"); // يمكن أن يأتي من البارامتر أو نخليه افتراضي
|
||||
|
||||
@@ -9,18 +9,34 @@ if (empty($phone)) {
|
||||
}
|
||||
|
||||
try {
|
||||
// تشفير الرقم المدخل للبحث
|
||||
$encPhone = $encryptionHelper->encryptData($phone);
|
||||
/**
|
||||
* البحث عبر الفهرس الأعمى أولاً (phone_bidx): مطابقة تامة عبر فهرس مُهيأ
|
||||
* ولا تعتمد على كون التشفير حتمياً، فتظل تعمل بعد النقل إلى AES-GCM.
|
||||
*
|
||||
* يُبقى المسار القديم (مقارنة النص المشفّر) كاحتياط حتى ينتهي تشغيل
|
||||
* scripts/backfill_blind_index.php، وإلا لتوقّف البحث بين الترحيل والتعبئة.
|
||||
*/
|
||||
global $blindIndex;
|
||||
$driver = null;
|
||||
|
||||
// احضار كل الأعمدة باستثناء كلمة المرور
|
||||
$sql = "SELECT *
|
||||
FROM driver
|
||||
WHERE phone = :phone
|
||||
LIMIT 1";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':phone' => $encPhone]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
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) {
|
||||
// ✅ الحقول المشفرة اللي لازم تنفك:
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<?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)) {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<?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");
|
||||
@@ -16,6 +24,13 @@ 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 !== '') {
|
||||
|
||||
@@ -5,6 +5,15 @@ $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`,
|
||||
@@ -59,12 +68,16 @@ 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);
|
||||
|
||||
|
||||
+31
-4
@@ -4,17 +4,33 @@
|
||||
// أداة تشفير وفك تشفير للمشرفين
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.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) التحقق من رقم هاتف الأدمن المصرّح له
|
||||
// 2) طبقة ثانية: رقم الهاتف يجب أن يكون ضمن القائمة المصرّح لها (إن وُجدت)
|
||||
$phonesRaw = getenv('ADMIN_PHONE_NUMBERS') ?: '';
|
||||
$ALLOWED_TOOL_PHONES = array_values(
|
||||
array_filter(
|
||||
@@ -26,11 +42,22 @@ $ALLOWED_TOOL_PHONES = array_values(
|
||||
|
||||
$adminPhoneParam = $adminPhoneParam ? preg_replace('/\D+/', '', $adminPhoneParam) : '';
|
||||
|
||||
if ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true)) {
|
||||
securityLog("Unauthorized encrypt/decrypt attempt", ['phone' => $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);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header("Access-Control-Allow-Origin: https://siromove.com");
|
||||
header("Access-Control-Allow-Methods: POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ── Rate Limiting ───────────────────────────────────────────
|
||||
$limiter = new RateLimiter($redis);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -109,6 +109,22 @@ try {
|
||||
$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)
|
||||
|
||||
@@ -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.');
|
||||
@@ -1,6 +1,16 @@
|
||||
<?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");
|
||||
|
||||
@@ -7,24 +7,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* تطبيع رقم الهاتف ليتوافق مع التخزين في قاعدة البيانات
|
||||
*/
|
||||
function normalizePhone($phone) {
|
||||
$clean = preg_replace('/\D+/', '', $phone);
|
||||
// Syria: 099XXXXXXX or 9639XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '09') === 0) return '963' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '963') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '9') === 0) return '963' . $clean;
|
||||
// Jordan: 079XXXXXXX or 9627XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '07') === 0) return '962' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '962') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '7') === 0) return '962' . $clean;
|
||||
// Egypt: 010XXXXXXXX or 2010XXXXXXXX
|
||||
if (strlen($clean) === 11 && strpos($clean, '01') === 0) return '20' . substr($clean, 1);
|
||||
if (strlen($clean) === 13 && strpos($clean, '20') === 0) return $clean;
|
||||
return $clean;
|
||||
}
|
||||
// التطبيع عبر normalizePhone() الموحّدة في core/helpers.php (نفس المنطق سابقاً)
|
||||
|
||||
$phone = filterRequest('phone');
|
||||
if (!$phone) {
|
||||
@@ -46,20 +29,20 @@ try {
|
||||
$selP = $con->prepare("
|
||||
SELECT id, first_name, last_name, phone
|
||||
FROM passengers
|
||||
WHERE phone = :enc_raw
|
||||
WHERE phone = :enc_raw OR (:bidx IS NOT NULL AND phone_bidx = :bidx)
|
||||
LIMIT 1
|
||||
");
|
||||
$selP->execute(['enc_raw' => $enc_raw]);
|
||||
$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
|
||||
WHERE phone = :enc_raw OR (:bidx IS NOT NULL AND phone_bidx = :bidx)
|
||||
LIMIT 1
|
||||
");
|
||||
$selD->execute(['enc_raw' => $enc_raw]);
|
||||
$selD->execute(['enc_raw' => $enc_raw, 'bidx' => $dBidx]);
|
||||
$driver = $selD->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$userId = null;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
require_once __DIR__ . '/../../connect.php'; // تأكد أن هذا الملف يحتوي على $con_tracking
|
||||
|
||||
header("Access-Control-Allow-Origin: https://siromove.com");
|
||||
|
||||
header("Content-Type: application/json; charset=UTF-8");
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
header("Access-Control-Allow-Origin: https://siromove.com");
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
@@ -18,29 +18,37 @@ try {
|
||||
$whereClause = ""; // لا يوجد شرط، اجلب الكل
|
||||
break;
|
||||
|
||||
// ملاحظة: قاعدة البيانات تحتوي عائلتين من الحالات — القديمة بصيغة
|
||||
// CamelCase ('Finished','Begin','CancelFromPassenger') والجديدة التي
|
||||
// يكتبها خط الرحلات الحالي بأحرف صغيرة ('completed','accepted',
|
||||
// 'cancelled_by_passenger'). المقارنة تتم بـ LOWER() لتغطية الاثنتين.
|
||||
case 'Pending':
|
||||
// الرحلات المعلقة/الجديدة: بانتظار سائق
|
||||
$whereClause = "WHERE r.status IN ('New','nothing','waiting','wait')";
|
||||
$whereClause = "WHERE LOWER(r.status) IN ('new','nothing','waiting','wait','pending','searching')";
|
||||
break;
|
||||
|
||||
case 'Begin':
|
||||
// الرحلات الجارية: من قبول السائق إلى بدء التشغيل
|
||||
$whereClause = "WHERE r.status IN ('Apply','Applied','Arrived','arrived','Begin')";
|
||||
$whereClause = "WHERE LOWER(r.status) IN ('apply','applied','arrived','begin','accepted','started','claimed')";
|
||||
break;
|
||||
|
||||
case 'Completed':
|
||||
// الرحلات المكتملة
|
||||
$whereClause = "WHERE r.status = 'Finished'";
|
||||
$whereClause = "WHERE LOWER(r.status) IN ('finished','completed')";
|
||||
break;
|
||||
|
||||
case 'Canceled':
|
||||
// جميع أنواع الإلغاء
|
||||
$whereClause = "WHERE r.status IN ('Cancel','CancelFromDriver','CancelFromDriverAfterApply','CancelFromPassenger','TimeOut')";
|
||||
$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 r.status = ?";
|
||||
$whereClause = "WHERE LOWER(r.status) = LOWER(?)";
|
||||
$params[] = $statusFilter;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -7,24 +7,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* تطبيع رقم الهاتف ليتوافق مع التخزين في قاعدة البيانات
|
||||
*/
|
||||
function normalizePhone($phone) {
|
||||
$clean = preg_replace('/\D+/', '', $phone);
|
||||
// Syria: 099XXXXXXX or 9639XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '09') === 0) return '963' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '963') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '9') === 0) return '963' . $clean;
|
||||
// Jordan: 079XXXXXXX or 9627XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '07') === 0) return '962' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '962') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '7') === 0) return '962' . $clean;
|
||||
// Egypt: 010XXXXXXXX or 2010XXXXXXXX
|
||||
if (strlen($clean) === 11 && strpos($clean, '01') === 0) return '20' . substr($clean, 1);
|
||||
if (strlen($clean) === 13 && strpos($clean, '20') === 0) return $clean;
|
||||
return $clean;
|
||||
}
|
||||
// التطبيع عبر normalizePhone() الموحّدة في core/helpers.php (نفس المنطق سابقاً)
|
||||
|
||||
// 1. تسجيل بداية الطلب
|
||||
$phone = filterRequest("phone");
|
||||
@@ -40,16 +23,21 @@ error_log("[MONITOR_RIDE] 1.5 Normalized Phone: " . $phone);
|
||||
//------------------------------------------------------------------------
|
||||
|
||||
$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 LIMIT 1");
|
||||
$driverQuery->execute([':phone' => $encPhone]);
|
||||
$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 LIMIT 1");
|
||||
$customerQuery->execute([':phone' => $encPhone]);
|
||||
$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);
|
||||
|
||||
// حدد نوع المستخدم
|
||||
@@ -164,5 +152,7 @@ $response = [
|
||||
"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,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']);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ try {
|
||||
SUM(r.price) as total_revenue
|
||||
FROM driver d
|
||||
JOIN ride r ON d.id = r.driver_id
|
||||
WHERE r.status = 'Finished'
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@ try {
|
||||
SUM(price - price_for_driver) as company_profit,
|
||||
COUNT(*) as total_rides
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
@@ -32,7 +32,7 @@ try {
|
||||
SUM(price - price_for_driver) as total_profit_all,
|
||||
AVG(price) as avg_ride_price
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
");
|
||||
$stmt->execute();
|
||||
|
||||
@@ -17,7 +17,7 @@ try {
|
||||
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 r.status = 'Finished'
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ try {
|
||||
0 as cash_payments,
|
||||
0 as digital_payments
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
WHERE LOWER(status) IN ('finished','completed')
|
||||
");
|
||||
$stmt->execute();
|
||||
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
$stmt = $con->prepare("
|
||||
SELECT
|
||||
COUNT(*) as total_rides,
|
||||
SUM(CASE WHEN status = 'Finished' THEN 1 ELSE 0 END) as completed_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
|
||||
|
||||
@@ -26,12 +26,12 @@ try {
|
||||
$online_drivers = $stmt->fetchColumn();
|
||||
|
||||
// 3. إيرادات اليوم
|
||||
$stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE status = 'Finished' AND DATE(created_at) = CURDATE()");
|
||||
$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 status = 'Finished' AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
|
||||
$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,7 +4,7 @@
|
||||
// ── سجل تتبع ────────────────────────────────────────────
|
||||
$debugFile = __DIR__ . '/../../../logs/audit_debug.txt';
|
||||
$logDir = dirname($debugFile);
|
||||
if (!is_dir($logDir)) @mkdir($logDir, 0777, true);
|
||||
if (!is_dir($logDir)) @mkdir($logDir, 0750, true);
|
||||
|
||||
@file_put_contents($debugFile, "[" . date('Y-m-d H:i:s') . "] === REQUEST START ===\n", FILE_APPEND);
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import os
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR = '.'
|
||||
OUTPUT_FILE = 'siro_v1_secure_latest.md'
|
||||
EXCLUDED_DIRS = {'.git', 'vendor', 'node_modules', '.gemini'}
|
||||
EXCLUDED_FILES = {OUTPUT_FILE, 'aggregate_files.py'}
|
||||
|
||||
def aggregate_files():
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write(f'# Siro V1 - Secure Latest Version\n\n')
|
||||
|
||||
for root, dirs, files in os.walk(PROJECT_DIR):
|
||||
# Prune excluded directories
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
|
||||
|
||||
for file in files:
|
||||
if file in EXCLUDED_FILES:
|
||||
continue
|
||||
|
||||
filepath = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(filepath, PROJECT_DIR)
|
||||
|
||||
# We mainly want to include code files
|
||||
if any(file.endswith(ext) for ext in ['.php', '.sql', '.ini', '.json', '.md', '.txt', '.py', '.sh']):
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as infile:
|
||||
content = infile.read()
|
||||
|
||||
outfile.write(f'## File: {rel_path}\n')
|
||||
outfile.write(f'```\n')
|
||||
outfile.write(content)
|
||||
outfile.write(f'\n```\n\n')
|
||||
print(f"Added: {rel_path}")
|
||||
except Exception as e:
|
||||
print(f"Could not read {rel_path}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
aggregate_files()
|
||||
print(f"\nDone! File created: {OUTPUT_FILE}")
|
||||
@@ -1,120 +1,100 @@
|
||||
<?php
|
||||
/**
|
||||
* get_competitor_context.php
|
||||
* ──────────────────────────
|
||||
* واجهة فائقة السرعة (Ultra-Fast API)
|
||||
* تقرأ البيانات مباشرة من الـ Redis المولد عبر الـ Cron Job.
|
||||
* زمن الاستجابة: O(1).
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../connect.php'; // Web-safe
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$lat = filterRequest('lat');
|
||||
$lng = filterRequest('lng');
|
||||
$countryCode = filterRequest('country_code');
|
||||
$lat = filterRequest('passenger_lat') ?: filterRequest('lat');
|
||||
$lng = filterRequest('passenger_lng') ?: filterRequest('lng');
|
||||
$country = filterRequest('country') ?: filterRequest('country_code');
|
||||
$distance = (float)(filterRequest('distance') ?: 0);
|
||||
$siroPrice = (float)(filterRequest('siro_price') ?: 0);
|
||||
|
||||
if (!$lat || !$lng || !$countryCode) {
|
||||
if (!$lat || !$lng || !$country) {
|
||||
echo json_encode(["status" => "error", "message" => "Missing parameters"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$lat = (float)$lat;
|
||||
$lng = (float)$lng;
|
||||
$countryCode = strtoupper($countryCode);
|
||||
$countryCode = strtoupper($country);
|
||||
if ($countryCode == 'JORDAN') $countryCode = 'JO';
|
||||
if ($countryCode == 'SYRIA') $countryCode = 'SY';
|
||||
if ($countryCode == 'EGYPT') $countryCode = 'EG';
|
||||
|
||||
$avgPricePerKm = 0;
|
||||
$topComp = 'TaxiF'; // Default
|
||||
|
||||
try {
|
||||
$redis = getRedisConnection();
|
||||
$cacheJson = $redis->get('siro:cache:pricing:grids');
|
||||
if ($cacheJson) {
|
||||
$cacheData = json_decode($cacheJson, true);
|
||||
if ($cacheData && isset($cacheData['grids'])) {
|
||||
$gridSize = 0.025;
|
||||
$gLat = round($lat / $gridSize) * $gridSize;
|
||||
$gLng = round($lng / $gridSize) * $gridSize;
|
||||
$gridKey = "{$countryCode}_" . number_format($gLat, 3) . "_" . number_format($gLng, 3);
|
||||
|
||||
$grids = $cacheData['grids'];
|
||||
if (isset($grids[$gridKey])) {
|
||||
$avgPricePerKm = (float)$grids[$gridKey]['avg_price'];
|
||||
$topComp = $grids[$gridKey]['top_competitor'] ?? 'TaxiF';
|
||||
} else {
|
||||
$fallbackKey = "{$countryCode}_FALLBACK";
|
||||
if (isset($grids[$fallbackKey])) {
|
||||
$avgPricePerKm = (float)$grids[$fallbackKey]['avg_price'];
|
||||
$topComp = $grids[$fallbackKey]['top_competitor'] ?? 'TaxiF';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(["status" => "error", "message" => "Redis connection failed"]);
|
||||
exit;
|
||||
// Continue with defaults if Redis fails
|
||||
}
|
||||
|
||||
if (!$cacheJson) {
|
||||
echo json_encode(["status" => "success", "data" => null, "message" => "Cache not generated yet"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cacheData = json_decode($cacheJson, true);
|
||||
|
||||
if (!$cacheData || !isset($cacheData['grids'])) {
|
||||
echo json_encode(["status" => "success", "data" => null, "message" => "Invalid cache data"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// محاولة إيجاد الشبكة (Grid) للراكب
|
||||
$gridSize = 0.025;
|
||||
$gLat = round($lat / $gridSize) * $gridSize;
|
||||
$gLng = round($lng / $gridSize) * $gridSize;
|
||||
$gridKey = "{$countryCode}_" . number_format($gLat, 3) . "_" . number_format($gLng, 3);
|
||||
|
||||
$grids = $cacheData['grids'];
|
||||
|
||||
if (isset($grids[$gridKey])) {
|
||||
$compData = $grids[$gridKey];
|
||||
} else {
|
||||
// إذا لم نجد، نستخدم الـ Fallback للدولة
|
||||
$fallbackKey = "{$countryCode}_FALLBACK";
|
||||
if (isset($grids[$fallbackKey])) {
|
||||
$compData = $grids[$fallbackKey];
|
||||
} else {
|
||||
echo json_encode(["status" => "success", "data" => null, "message" => "No data for this region"]);
|
||||
exit;
|
||||
// If we couldn't get a price from Redis, use a smart default based on country
|
||||
if ($avgPricePerKm <= 0) {
|
||||
if ($countryCode === 'JO') {
|
||||
$avgPricePerKm = 0.35;
|
||||
$topComp = 'TaxiF';
|
||||
} else if ($countryCode === 'SY') {
|
||||
$avgPricePerKm = 4000;
|
||||
$topComp = 'Yango';
|
||||
} else if ($countryCode === 'EG') {
|
||||
$avgPricePerKm = 15;
|
||||
$topComp = 'inDrive';
|
||||
}
|
||||
}
|
||||
|
||||
$avgPrice = $compData['avg_price'];
|
||||
$topComp = $compData['top_competitor'] ?? 'Other';
|
||||
// Calculate the competitor's total price based on distance and average market per-km rate
|
||||
// 🔥 لا يوجد أي تعديل صناعي على سعر المنافس هنا — الرقم المعروض للراكب
|
||||
// يجب أن يعكس بيانات السوق الحقيقية فقط، حتى لو لم نكن أرخص فعلياً في هذه الرحلة.
|
||||
$competitorTotalPrice = round($distance * $avgPricePerKm, 2);
|
||||
|
||||
// جلب سعر Siro للمقارنة السريعة
|
||||
$sqlKazan = "SELECT (price_km + start_price) AS siro_base
|
||||
FROM kazan
|
||||
WHERE country_code = :cc AND type = 'speed'
|
||||
LIMIT 1";
|
||||
$stmtKazan = $con->prepare($sqlKazan);
|
||||
$stmtKazan->execute([':cc' => $countryCode]);
|
||||
$kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$siroPrice = $kazanRow ? (float)$kazanRow['siro_base'] : $avgPrice * 0.9;
|
||||
// Format the labels
|
||||
$compNameAr = 'التطبيقات الأخرى';
|
||||
|
||||
// نعرض شارة "أوفر" فقط إذا كنا أرخص فعلياً حسب البيانات الحقيقية — لا تلاعب بالأرقام
|
||||
$savingsPct = 0;
|
||||
if ($avgPrice > 0 && $siroPrice < $avgPrice) {
|
||||
$savingsPct = (($avgPrice - $siroPrice) / $avgPrice) * 100;
|
||||
$savingsLabel = null;
|
||||
if ($competitorTotalPrice > 0 && $siroPrice > 0 && $siroPrice < $competitorTotalPrice) {
|
||||
$savingsPct = (($competitorTotalPrice - $siroPrice) / $competitorTotalPrice) * 100;
|
||||
$savingsLabel = "أوفر بـ " . number_format($savingsPct, 1) . "% من $compNameAr ⚡";
|
||||
}
|
||||
|
||||
$passengerMessage = null;
|
||||
$driverMessage = null;
|
||||
$extraEarnings = 0;
|
||||
|
||||
if ($savingsPct > 2) {
|
||||
$compNameAr = match(strtolower($topComp)) {
|
||||
'careem' => 'كريم',
|
||||
'uber' => 'أوبر',
|
||||
'yallago' => 'يلا غو',
|
||||
'jenny' => 'جيني',
|
||||
default => 'التطبيقات الأخرى'
|
||||
};
|
||||
|
||||
$passengerMessage = "أوفر بـ " . number_format($savingsPct, 1) . "% من $compNameAr ⚡";
|
||||
|
||||
// قراءة نسبة عمولة سيرو ديناميكياً من الإنفيرومنت، والنسبة الافتراضية 10% إذا لم تكن موجودة
|
||||
$siroCommissionRate = (float)(getenv('SIRO_COMMISSION_' . $countryCode) ?: 0.10);
|
||||
$extraEarnings = $siroPrice * $siroCommissionRate;
|
||||
|
||||
$driverMessage = "رحلة مربحة! تكسب أكثر مقارنة بـ $compNameAr 💰";
|
||||
}
|
||||
$siroCommissionRate = 0.14; // Default 14% commission
|
||||
if ($countryCode === 'JO') $siroCommissionRate = 0.14;
|
||||
$extraEarnings = $siroPrice * $siroCommissionRate;
|
||||
$driverExtraLabel = "رحلة مربحة! تكسب أكثر مقارنة بـ $compNameAr 💰";
|
||||
|
||||
// Return exactly what Dart expects in the root JSON
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"data" => [
|
||||
"competitor_avg_price" => $avgPrice,
|
||||
"top_competitor" => $topComp,
|
||||
"savings_percent" => $savingsPct,
|
||||
"passenger_badge_text" => $passengerMessage,
|
||||
"driver_badge_text" => $driverMessage,
|
||||
"driver_extra_earnings" => round($extraEarnings, 2),
|
||||
"source" => "redis_cache"
|
||||
]
|
||||
"has_competitor_data" => true,
|
||||
"competitor_avg_price" => $competitorTotalPrice,
|
||||
"top_competitor" => $topComp,
|
||||
"savings_percent" => $savingsPct,
|
||||
"savings_label" => $savingsLabel,
|
||||
"driver_extra_amount" => round($extraEarnings, 2),
|
||||
"driver_extra_label" => $driverExtraLabel
|
||||
]);
|
||||
?>
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// create_tester_driver.php
|
||||
// Script to seed/register a pre-verified tester driver.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
$email = 'driver_tester@siromove.com';
|
||||
$phone = '+962790000002';
|
||||
$password = 'SiroDriver2026!';
|
||||
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
$encryptedPhone = $encryptionHelper->encryptData($phone);
|
||||
$encryptedFirstName = $encryptionHelper->encryptData('Driver');
|
||||
$encryptedLastName = $encryptionHelper->encryptData('Tester');
|
||||
$encryptedGender = $encryptionHelper->encryptData('Male');
|
||||
$encryptedBirthdate = $encryptionHelper->encryptData('1990-01-01');
|
||||
$encryptedSite = $encryptionHelper->encryptData('Jordan');
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// 1. Check if driver exists
|
||||
$stmt = $con->prepare("SELECT id FROM driver WHERE email = :email LIMIT 1");
|
||||
$stmt->bindParam(':email', $encryptedEmail);
|
||||
$stmt->execute();
|
||||
$driver = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($driver) {
|
||||
$driverId = $driver['id'];
|
||||
$update = $con->prepare("UPDATE driver SET password = :password, phone = :phone WHERE id = :id");
|
||||
$update->bindParam(':password', $hashedPassword);
|
||||
$update->bindParam(':phone', $encryptedPhone);
|
||||
$update->bindParam(':id', $driverId);
|
||||
$update->execute();
|
||||
echo "Driver tester updated successfully.\n";
|
||||
} else {
|
||||
$driverId = bin2hex(random_bytes(10)); // 20 chars unique id
|
||||
$insert = $con->prepare("INSERT INTO driver (id, phone, email, password, gender, birthdate, site, first_name, last_name)
|
||||
VALUES (:id, :phone, :email, :password, :gender, :birthdate, :site, :first_name, :last_name)");
|
||||
$insert->bindParam(':id', $driverId);
|
||||
$insert->bindParam(':phone', $encryptedPhone);
|
||||
$insert->bindParam(':email', $encryptedEmail);
|
||||
$insert->bindParam(':password', $hashedPassword);
|
||||
$insert->bindParam(':gender', $encryptedGender);
|
||||
$insert->bindParam(':birthdate', $encryptedBirthdate);
|
||||
$insert->bindParam(':site', $encryptedSite);
|
||||
$insert->bindParam(':first_name', $encryptedFirstName);
|
||||
$insert->bindParam(':last_name', $encryptedLastName);
|
||||
$insert->execute();
|
||||
echo "Driver tester created successfully with ID: $driverId\n";
|
||||
}
|
||||
|
||||
// 2. Ensure phone_verification row exists
|
||||
$stmtPhone = $con->prepare("SELECT * FROM phone_verification WHERE phone_number = :phone LIMIT 1");
|
||||
$stmtPhone->bindParam(':phone', $encryptedPhone);
|
||||
$stmtPhone->execute();
|
||||
if ($stmtPhone->fetch()) {
|
||||
$updatePhone = $con->prepare("UPDATE phone_verification SET is_verified = 1 WHERE phone_number = :phone");
|
||||
$updatePhone->bindParam(':phone', $encryptedPhone);
|
||||
$updatePhone->execute();
|
||||
} else {
|
||||
$insertPhone = $con->prepare("INSERT INTO phone_verification (phone_number, is_verified) VALUES (:phone, 1)");
|
||||
$insertPhone->bindParam(':phone', $encryptedPhone);
|
||||
$insertPhone->execute();
|
||||
}
|
||||
|
||||
// 3. Ensure CarRegistration row exists
|
||||
$stmtCar = $con->prepare("SELECT * FROM CarRegistration WHERE driverID = :driverID LIMIT 1");
|
||||
$stmtCar->bindParam(':driverID', $driverId);
|
||||
$stmtCar->execute();
|
||||
if ($stmtCar->fetch()) {
|
||||
$updateCar = $con->prepare("UPDATE CarRegistration SET make = 'Toyota', model = 'Prius', year = '2020' WHERE driverID = :driverID");
|
||||
$updateCar->bindParam(':driverID', $driverId);
|
||||
$updateCar->execute();
|
||||
} else {
|
||||
$insertCar = $con->prepare("INSERT INTO CarRegistration (driverID, make, model, year) VALUES (:driverID, 'Toyota', 'Prius', '2020')");
|
||||
$insertCar->bindParam(':driverID', $driverId);
|
||||
$insertCar->execute();
|
||||
}
|
||||
|
||||
echo "Verification and Car Registration configured.\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
?>
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$appPlatform = filterRequest("appPlatform");
|
||||
|
||||
|
||||
$sql = "SELECT
|
||||
*
|
||||
FROM
|
||||
`testApp`
|
||||
WHERE
|
||||
appPlatform = '$appPlatform'-- AND isTest = 0;";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// Print the retrieved data
|
||||
// echo json_encode($result);
|
||||
jsonSuccess($data = $result);
|
||||
} else {
|
||||
// Print a failure message
|
||||
|
||||
jsonError($message = "No driver order data found");
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$appPlatform = filterRequest("appPlatform");
|
||||
|
||||
$sql = "UPDATE
|
||||
`testApp`
|
||||
SET
|
||||
`isTest` = '1'
|
||||
WHERE
|
||||
`testApp`.appPlatform = '$appPlatform';";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// Print a success message
|
||||
jsonSuccess($message = "Test data updated successfully");
|
||||
} else {
|
||||
// Print a failure message
|
||||
jsonError($message = "Failed to update driver order data");
|
||||
}
|
||||
?>
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$email = filterRequest('email');
|
||||
$phone = filterRequest('phone');
|
||||
$password = filterRequest('password');
|
||||
|
||||
// تشفير الحقول المطلوبة قبل الاستعلام
|
||||
$email = $encryptionHelper->encryptData($email);
|
||||
$phone = $encryptionHelper->encryptData($phone);
|
||||
|
||||
$sql = "SELECT
|
||||
driver.id,
|
||||
driver.phone,
|
||||
driver.email,
|
||||
driver.password,
|
||||
driver.gender,
|
||||
driver.birthdate,
|
||||
driver.site,
|
||||
driver.first_name,
|
||||
driver.last_name,
|
||||
driver.education,
|
||||
driver.employmentType,
|
||||
driver.maritalStatus,
|
||||
driver.created_at,
|
||||
driver.updated_at,
|
||||
email_verifications.verified
|
||||
FROM
|
||||
driver
|
||||
LEFT JOIN email_verifications ON email_verifications.email = driver.email
|
||||
WHERE
|
||||
driver.phone = :phone AND driver.email = :email";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':email', $email);
|
||||
$stmt->bindParam(':phone', $phone);
|
||||
$stmt->execute();
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$count = $stmt->rowCount();
|
||||
|
||||
if ($count > 0) {
|
||||
$stored_password = $data[0]['password'];
|
||||
if (password_verify($password, $stored_password)) {
|
||||
|
||||
// فك التشفير للحقول الحساسة
|
||||
$data[0]['phone'] = $encryptionHelper->decryptData($data[0]['phone']);
|
||||
$data[0]['email'] = $encryptionHelper->decryptData($data[0]['email']);
|
||||
$data[0]['gender'] = $encryptionHelper->decryptData($data[0]['gender']);
|
||||
$data[0]['birthdate'] = $encryptionHelper->decryptData($data[0]['birthdate']);
|
||||
$data[0]['site'] = $encryptionHelper->decryptData($data[0]['site']);
|
||||
$data[0]['first_name'] = $encryptionHelper->decryptData($data[0]['first_name']);
|
||||
$data[0]['last_name'] = $encryptionHelper->decryptData($data[0]['last_name']);
|
||||
$data[0]['education'] = $encryptionHelper->decryptData($data[0]['education']);
|
||||
$data[0]['employmentType'] = $encryptionHelper->decryptData($data[0]['employmentType']);
|
||||
$data[0]['maritalStatus'] = $encryptionHelper->decryptData($data[0]['maritalStatus']);
|
||||
|
||||
unset($data[0]['password']); // لا نرجّع الباسورد
|
||||
jsonSuccess($data);
|
||||
} else {
|
||||
jsonError("Incorrect password.");
|
||||
}
|
||||
} else {
|
||||
jsonError("User does not exist.");
|
||||
}
|
||||
?>
|
||||
@@ -1,181 +0,0 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// loginUsingCredentialsWithoutGoogle.php
|
||||
// مخصص لدخول الفاحصين (Testers) بالإيميل والباسورد
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
$email = filterRequest('email');
|
||||
$password = filterRequest('password');
|
||||
$audience = filterRequest('aud') ?? 'siro-driver-android'; // الافتراضي
|
||||
$fingerprint = filterRequest('fingerPrint') ?? filterRequest('fingerprint');
|
||||
|
||||
// 1. تطبيق حد معدل الطلبات (Rate Limiting) للفاحصين: 3 محاولات بالدقيقة لكل IP
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'tester_login');
|
||||
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// 2. التحقق من أن الحساب مخصص للفحص فقط (isTest check)
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: '';
|
||||
$allowedEmails = array_filter(array_map('trim', explode(',', $allowedTesterEmailsEnv)));
|
||||
if (empty($allowedEmails)) {
|
||||
$allowedEmails = [
|
||||
'driver_tester@siromove.com',
|
||||
'passenger_tester@siromove.com',
|
||||
];
|
||||
}
|
||||
|
||||
$cleanEmail = strtolower(trim($email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails) ||
|
||||
substr($cleanEmail, -13) === '@siromove.com' ||
|
||||
str_contains($cleanEmail, 'tester') ||
|
||||
str_contains($cleanEmail, 'reviewer');
|
||||
|
||||
// تشفير الإيميل لاستخدامه في الاستعلام
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// Auto-seed/create tester driver if it doesn't exist
|
||||
if ($cleanEmail === 'driver_tester@siromove.com') {
|
||||
$stmtCheck = $con->prepare("SELECT id FROM driver WHERE email = :email LIMIT 1");
|
||||
$stmtCheck->bindParam(':email', $encryptedEmail);
|
||||
$stmtCheck->execute();
|
||||
if (!$stmtCheck->fetch()) {
|
||||
$driverId = 'tester_driver_id_2026';
|
||||
$phone = '+962790000002';
|
||||
$hashedPassword = password_hash('SiroDriver2026!', PASSWORD_DEFAULT);
|
||||
|
||||
$encryptedPhone = $encryptionHelper->encryptData($phone);
|
||||
$encryptedFirstName = $encryptionHelper->encryptData('Driver');
|
||||
$encryptedLastName = $encryptionHelper->encryptData('Tester');
|
||||
$encryptedGender = $encryptionHelper->encryptData('Male');
|
||||
$encryptedBirthdate = $encryptionHelper->encryptData('1990-01-01');
|
||||
$encryptedSite = $encryptionHelper->encryptData('Jordan');
|
||||
|
||||
// Insert driver
|
||||
$insert = $con->prepare("INSERT INTO driver (id, phone, email, password, gender, birthdate, site, first_name, last_name)
|
||||
VALUES (:id, :phone, :email, :password, :gender, :birthdate, :site, :first_name, :last_name)");
|
||||
$insert->execute([
|
||||
':id' => $driverId,
|
||||
':phone' => $encryptedPhone,
|
||||
':email' => $encryptedEmail,
|
||||
':password' => $hashedPassword,
|
||||
':gender' => $encryptedGender,
|
||||
':birthdate' => $encryptedBirthdate,
|
||||
':site' => $encryptedSite,
|
||||
':first_name' => $encryptedFirstName,
|
||||
':last_name' => $encryptedLastName
|
||||
]);
|
||||
|
||||
// Ensure phone_verification row exists
|
||||
$stmtPhone = $con->prepare("SELECT * FROM phone_verification WHERE phone_number = :phone LIMIT 1");
|
||||
$stmtPhone->bindParam(':phone', $encryptedPhone);
|
||||
$stmtPhone->execute();
|
||||
if (!$stmtPhone->fetch()) {
|
||||
$insertPhone = $con->prepare("INSERT INTO phone_verification (phone_number, is_verified) VALUES (:phone, 1)");
|
||||
$insertPhone->bindParam(':phone', $encryptedPhone);
|
||||
$insertPhone->execute();
|
||||
} else {
|
||||
$updatePhone = $con->prepare("UPDATE phone_verification SET is_verified = 1 WHERE phone_number = :phone");
|
||||
$updatePhone->bindParam(':phone', $encryptedPhone);
|
||||
$updatePhone->execute();
|
||||
}
|
||||
|
||||
// Ensure CarRegistration row exists
|
||||
$stmtCar = $con->prepare("SELECT * FROM CarRegistration WHERE driverID = :driverID LIMIT 1");
|
||||
$stmtCar->bindParam(':driverID', $driverId);
|
||||
$stmtCar->execute();
|
||||
if (!$stmtCar->fetch()) {
|
||||
$insertCar = $con->prepare("INSERT INTO CarRegistration (driverID, vin, car_plate, make, model, year, expiration_date, color, owner, color_hex, fuel)
|
||||
VALUES (:driverID, :vin, :car_plate, 'Toyota', 'Prius', 2020, '2030-01-01', 'White', :owner, '#FFFFFF', 'Petrol')");
|
||||
$encryptedVin = $encryptionHelper->encryptData('TESTVIN1234567890');
|
||||
$encryptedPlate = $encryptionHelper->encryptData('155186');
|
||||
$encryptedOwner = $encryptionHelper->encryptData('Driver Tester');
|
||||
$insertCar->execute([
|
||||
':driverID' => $driverId,
|
||||
':vin' => $encryptedVin,
|
||||
':car_plate' => $encryptedPlate,
|
||||
':owner' => $encryptedOwner
|
||||
]);
|
||||
} else {
|
||||
$updateCar = $con->prepare("UPDATE CarRegistration SET make = 'Toyota', model = 'Prius', year = 2020 WHERE driverID = :driverID");
|
||||
$updateCar->bindParam(':driverID', $driverId);
|
||||
$updateCar->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SQL لاسترجاع المستخدم بناءً على البريد الإلكتروني المشفر
|
||||
$sql = "SELECT
|
||||
driver.*,
|
||||
phone_verification.is_verified,
|
||||
CarRegistration.make,
|
||||
CarRegistration.model,
|
||||
CarRegistration.year
|
||||
FROM driver
|
||||
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone
|
||||
LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id
|
||||
WHERE
|
||||
driver.email = :email
|
||||
LIMIT 1";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':email', $encryptedEmail);
|
||||
$stmt->execute();
|
||||
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($data) {
|
||||
// التحقق من أن الحساب معلم كحساب فحص في قاعدة البيانات أو البيئة
|
||||
$isTestInDb = (isset($data['is_test']) && $data['is_test'] == 1) || (isset($data['isTest']) && $data['isTest'] == 1);
|
||||
if (!$isTestInDb && !$isTester) {
|
||||
jsonError("Access denied. Not a tester account.");
|
||||
exit();
|
||||
}
|
||||
// فحص الباسورد (في نظامنا، يمكن أن يكون الباسورد هو HMAC أو نص عادي للفاحصين)
|
||||
// لنفترض أن الفاحص له باسورد عادي أو مشفر بـ bcrypt
|
||||
if (password_verify($password, $data['password']) || $password === $data['password']) {
|
||||
unset($data['password']);
|
||||
|
||||
// فك تشفير الحقول الحساسة
|
||||
$data['phone'] = $encryptionHelper->decryptData($data['phone']);
|
||||
$data['email'] = $encryptionHelper->decryptData($data['email']);
|
||||
$data['gender'] = $encryptionHelper->decryptData($data['gender']);
|
||||
$data['birthdate'] = $encryptionHelper->decryptData($data['birthdate']);
|
||||
$data['site'] = $encryptionHelper->decryptData($data['site']);
|
||||
$data['first_name'] = $encryptionHelper->decryptData($data['first_name']);
|
||||
$data['last_name'] = $encryptionHelper->decryptData($data['last_name']);
|
||||
if(isset($data['employmentType'])) $data['employmentType'] = $encryptionHelper->decryptData($data['employmentType']);
|
||||
if(isset($data['maritalStatus'])) $data['maritalStatus'] = $encryptionHelper->decryptData($data['maritalStatus']);
|
||||
|
||||
// توليد الـ JWT بصلاحية (tester) لتميزهم عن السائقين الفعليين
|
||||
$jwtService = new JwtService($redis);
|
||||
$jwt = $jwtService->generateAccessToken($data['id'], 'tester', $audience, $fingerprint);
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"jwt" => $jwt,
|
||||
"data" => [$data] // مطابق لنسق التطبيق الذي يتوقع مصفوفة
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
jsonError("Incorrect password.");
|
||||
}
|
||||
} else {
|
||||
jsonError("User does not exist.");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[Tester Login Error] " . $e->getMessage());
|
||||
jsonError("Server error occurred.");
|
||||
} finally {
|
||||
$stmt = null;
|
||||
$con = null;
|
||||
}
|
||||
exit();
|
||||
?>
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// استقبال القيم
|
||||
$phoneNumber = filterRequest("phone_number");
|
||||
$email = filterRequest("email");
|
||||
|
||||
// تشفير القيم المطلوبة للمقارنة داخل SQL
|
||||
$phoneNumber = $encryptionHelper->encryptData($phoneNumber);
|
||||
$email = $encryptionHelper->encryptData($email);
|
||||
|
||||
// تنفيذ الاستعلام
|
||||
$sql = "
|
||||
SELECT
|
||||
pv.*,
|
||||
p.email
|
||||
FROM
|
||||
`phone_verification_passenger` pv
|
||||
INNER JOIN
|
||||
`passengers` p ON pv.phone_number = p.phone
|
||||
WHERE
|
||||
pv.phone_number = :phoneNumber AND p.email = :email
|
||||
";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':phoneNumber', $phoneNumber, PDO::PARAM_STR);
|
||||
$stmt->bindParam(':email', $email, PDO::PARAM_STR);
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// يمكنك هنا لاحقًا تفكيك تشفير أي حقل إذا كنت ترجع phone/email مثلاً للمستخدم، لكن في حالتنا ما في حاجة.
|
||||
|
||||
jsonSuccess($rows);
|
||||
} else {
|
||||
jsonError("No Phone verified or related email found");
|
||||
}
|
||||
?>
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// استقبال وتشفير رقم الهاتف
|
||||
$phoneNumber = filterRequest("phone_number");
|
||||
$phoneNumber = $encryptionHelper->encryptData($phoneNumber);
|
||||
$phoneNumber = otpPhoneKey($phoneNumber);
|
||||
|
||||
// تجهيز الاستعلام باستخدام bindParam للحماية
|
||||
$sql = "SELECT * FROM `phone_verification` WHERE `phone_number` = :phone_number";
|
||||
@@ -1,6 +1,15 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
$driverId = filterRequest("id");
|
||||
|
||||
if (empty($driverId)) {
|
||||
+9
@@ -1,6 +1,15 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
$limit = isset($_POST['limit']) ? (int)$_POST['limit'] : (isset($_GET['limit']) ? (int)$_GET['limit'] : 10);
|
||||
$offset = isset($_POST['offset']) ? (int)$_POST['offset'] : (isset($_GET['offset']) ? (int)$_GET['offset'] : 0);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ require_once __DIR__ . '/../../../connect.php';
|
||||
$phoneNumber = filterRequest("phone_number");
|
||||
|
||||
// تشفير الرقم قبل البحث
|
||||
$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber);
|
||||
$phoneNumber_encrypted = otpPhoneKey($phoneNumber);
|
||||
|
||||
try {
|
||||
// الاستعلام عن السائق حسب رقم الهاتف وحالة التحقق
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
global $blindIndex;
|
||||
|
||||
$email = filterRequest('email');
|
||||
$phone = filterRequest('phone');
|
||||
$password = filterRequest('password');
|
||||
|
||||
if (empty($phone) && empty($email)) {
|
||||
jsonError("Phone or email is required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($phone)) {
|
||||
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||
$conditions[] = "driver.phone = :phone";
|
||||
$params[':phone'] = $phoneEnc;
|
||||
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
|
||||
if ($phoneBidx) {
|
||||
$conditions[] = "driver.phone_bidx = :phone_bidx";
|
||||
$params[':phone_bidx'] = $phoneBidx;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($email)) {
|
||||
$emailEnc = $encryptionHelper->encryptData($email);
|
||||
$conditions[] = "driver.email = :email";
|
||||
$params[':email'] = $emailEnc;
|
||||
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $email) : null;
|
||||
if ($emailBidx) {
|
||||
$conditions[] = "driver.email_bidx = :email_bidx";
|
||||
$params[':email_bidx'] = $emailBidx;
|
||||
}
|
||||
}
|
||||
|
||||
$whereClause = implode(' OR ', $conditions);
|
||||
|
||||
$sql = "SELECT
|
||||
driver.id,
|
||||
driver.phone,
|
||||
driver.email,
|
||||
driver.password,
|
||||
driver.gender,
|
||||
driver.birthdate,
|
||||
driver.site,
|
||||
driver.first_name,
|
||||
driver.last_name,
|
||||
driver.education,
|
||||
driver.employmentType,
|
||||
driver.maritalStatus,
|
||||
driver.created_at,
|
||||
driver.updated_at,
|
||||
driver.email AS _email_enc
|
||||
FROM
|
||||
driver
|
||||
WHERE
|
||||
$whereClause";
|
||||
|
||||
|
||||
/**
|
||||
* حالة توثيق البريد.
|
||||
*
|
||||
* كان الاستعلام يربط email_verifications.email بعمود البريد في الحساب، لكن
|
||||
* الأول يُخزَّن نصاً صريحاً والثاني مشفّراً — فالربط لم يكن يطابق شيئاً أصلاً
|
||||
* وكانت verified تعود NULL دائماً. نجلبها هنا بالبريد الأصلي.
|
||||
*/
|
||||
function fetchEmailVerified(PDO $con, ?string $plainEmail): ?int
|
||||
{
|
||||
if (!$plainEmail) return null;
|
||||
try {
|
||||
$st = $con->prepare("SELECT verified FROM email_verifications WHERE email = ? LIMIT 1");
|
||||
$st->execute([$plainEmail]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v === false ? null : (int) $v;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[email_verifications] ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$count = count($data);
|
||||
|
||||
if ($count > 0) {
|
||||
$plainEmail = $encryptionHelper->decryptData($data[0]['_email_enc'] ?? null) ?: null;
|
||||
$data[0]['verified'] = fetchEmailVerified($con, $plainEmail);
|
||||
unset($data[0]['_email_enc']);
|
||||
}
|
||||
|
||||
if ($count > 0) {
|
||||
$stored_password = $data[0]['password'];
|
||||
if (password_verify($password, $stored_password)) {
|
||||
|
||||
// فك التشفير للحقول الحساسة
|
||||
$data[0]['phone'] = $encryptionHelper->decryptData($data[0]['phone']);
|
||||
$data[0]['email'] = $encryptionHelper->decryptData($data[0]['email']);
|
||||
$data[0]['gender'] = $encryptionHelper->decryptData($data[0]['gender']);
|
||||
$data[0]['birthdate'] = $encryptionHelper->decryptData($data[0]['birthdate']);
|
||||
$data[0]['site'] = $encryptionHelper->decryptData($data[0]['site']);
|
||||
$data[0]['first_name'] = $encryptionHelper->decryptData($data[0]['first_name']);
|
||||
$data[0]['last_name'] = $encryptionHelper->decryptData($data[0]['last_name']);
|
||||
$data[0]['education'] = $encryptionHelper->decryptData($data[0]['education']);
|
||||
$data[0]['employmentType'] = $encryptionHelper->decryptData($data[0]['employmentType']);
|
||||
$data[0]['maritalStatus'] = $encryptionHelper->decryptData($data[0]['maritalStatus']);
|
||||
|
||||
unset($data[0]['password']); // لا نرجّع الباسورد
|
||||
jsonSuccess($data);
|
||||
} else {
|
||||
jsonError("Incorrect password.");
|
||||
}
|
||||
} else {
|
||||
jsonError("User does not exist.");
|
||||
}
|
||||
?>
|
||||
@@ -23,7 +23,7 @@ try {
|
||||
CarRegistration.make, CarRegistration.model, CarRegistration.year,
|
||||
df.is_claimed, inv.isInstall, inv.isGiftToken
|
||||
FROM driver
|
||||
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone
|
||||
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone_key
|
||||
LEFT JOIN driver_gifts df ON df.driver_id = driver.id
|
||||
LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id
|
||||
LEFT JOIN invites inv ON inv.driverId = driver.id
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// loginUsingCredentialsWithoutGoogle.php
|
||||
// مخصص لدخول الفاحصين (Testers) بالإيميل والباسورد
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
$email = filterRequest('email');
|
||||
$password = filterRequest('password');
|
||||
$audience = filterRequest('aud') ?? 'siro-driver-android'; // الافتراضي
|
||||
$fingerprint = filterRequest('fingerPrint') ?? filterRequest('fingerprint');
|
||||
|
||||
// 1. حد معدل الطلبات مطبّق على الجميع (الحد مرفوع إلى 30/دقيقة في RateLimiter)
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'tester_login');
|
||||
|
||||
// 2. قائمة بيضاء صريحة لحسابات الفحص — مطابقة تامة فقط، لا مطابقة جزئية ولا مطابقة نطاق
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: ($_ENV['ALLOWED_TESTER_EMAILS'] ?? '');
|
||||
$allowedEmails = array_filter(array_map(
|
||||
fn($e) => strtolower(trim($e)),
|
||||
explode(',', $allowedTesterEmailsEnv)
|
||||
));
|
||||
if (empty($allowedEmails)) {
|
||||
$allowedEmails = [
|
||||
'driver_tester@siromove.com',
|
||||
'passenger_tester@siromove.com',
|
||||
];
|
||||
}
|
||||
|
||||
$cleanEmail = strtolower(trim((string) $email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails, true);
|
||||
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
global $blindIndex;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $email) : null;
|
||||
|
||||
$sql = "SELECT
|
||||
driver.*,
|
||||
phone_verification.is_verified,
|
||||
CarRegistration.make,
|
||||
CarRegistration.model,
|
||||
CarRegistration.year
|
||||
FROM driver
|
||||
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone_key
|
||||
LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id
|
||||
WHERE driver.email = :email";
|
||||
|
||||
$params = [':email' => $encryptedEmail];
|
||||
|
||||
if ($emailBidx !== null) {
|
||||
$sql .= " OR driver.email_bidx = :email_bidx";
|
||||
$params[':email_bidx'] = $emailBidx;
|
||||
}
|
||||
|
||||
$sql .= " LIMIT 1";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($data) {
|
||||
$isTestInDb = (isset($data['is_test']) && $data['is_test'] == 1) || (isset($data['isTest']) && $data['isTest'] == 1);
|
||||
if (!$isTestInDb && !$isTester) {
|
||||
jsonError("Access denied. Not a tester account.");
|
||||
exit();
|
||||
}
|
||||
|
||||
if (password_verify($password, $data['password'] ?? '')) {
|
||||
unset($data['password']);
|
||||
|
||||
if(isset($data['phone'])) $data['phone'] = $encryptionHelper->decryptData($data['phone']);
|
||||
if(isset($data['email'])) $data['email'] = $encryptionHelper->decryptData($data['email']);
|
||||
if(isset($data['gender'])) $data['gender'] = $encryptionHelper->decryptData($data['gender']);
|
||||
if(isset($data['birthdate'])) $data['birthdate'] = $encryptionHelper->decryptData($data['birthdate']);
|
||||
if(isset($data['site'])) $data['site'] = $encryptionHelper->decryptData($data['site']);
|
||||
if(isset($data['first_name'])) $data['first_name'] = $encryptionHelper->decryptData($data['first_name']);
|
||||
if(isset($data['last_name'])) $data['last_name'] = $encryptionHelper->decryptData($data['last_name']);
|
||||
|
||||
$jwtService = new JwtService($redis);
|
||||
$jwt = $jwtService->generateAccessToken($data['id'], 'tester', $audience, $fingerprint);
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"jwt" => $jwt,
|
||||
"data" => [$data]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
jsonError("Incorrect password.");
|
||||
}
|
||||
} else {
|
||||
jsonError("User does not exist.");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Tester Login Error] " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
|
||||
jsonError("Server error occurred: " . $e->getMessage() . " in " . basename($e->getFile()) . ":" . $e->getLine());
|
||||
} finally {
|
||||
$stmt = null;
|
||||
$con = null;
|
||||
}
|
||||
exit();
|
||||
?>
|
||||
+57
-5
@@ -10,6 +10,11 @@ $allowRegistration = true;
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Rate Limiting: الحماية من التسجيل العشوائي وهجمات الـ Bots
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'register_driver');
|
||||
|
||||
|
||||
try {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonError("Invalid method.");
|
||||
@@ -120,6 +125,25 @@ try {
|
||||
}
|
||||
/* ================== 🔴 END PHONE FORMATTING LOGIC 🔴 ================== */
|
||||
|
||||
// ======================================================
|
||||
// Step 1.5: التحقق الفعلي من ملكية رقم الهاتف قبل إكمال المعالجة (سد الثغرة)
|
||||
// ======================================================
|
||||
require_once __DIR__ . '/../../../core/Auth/EncryptionHelper.php';
|
||||
$tempEncryptionHelper = new EncryptionHelper($redis);
|
||||
$phoneNumber_encrypted_check = $tempEncryptionHelper->encryptData($data['phone']);
|
||||
|
||||
$verifyCheckStmt = $con->prepare(
|
||||
"SELECT id FROM phone_verification_driver
|
||||
WHERE phone_number = ? AND verified = 1 AND created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
|
||||
LIMIT 1"
|
||||
);
|
||||
$verifyCheckStmt->execute([$phoneNumber_encrypted_check]);
|
||||
if ($verifyCheckStmt->rowCount() === 0) {
|
||||
error_log("[Register_Debug_driver] Error: Phone number not verified via OTP.");
|
||||
jsonError("Phone number must be verified before registration.");
|
||||
exit();
|
||||
}
|
||||
// ======================================================
|
||||
|
||||
// تجهيز تاريخ الميلاد قبل التشفير
|
||||
if (!empty($data['birthdate'])) {
|
||||
@@ -359,6 +383,18 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
|
||||
$pwdHashed = password_hash($rawSecret, PASSWORD_DEFAULT);
|
||||
|
||||
/* ================== 4) Encrypt sensitive fields ================== */
|
||||
// فهارس البحث تُحسب من القيم الخام قبل التشفير — بعده تصبح القيمة الأصلية
|
||||
// غير متاحة، وبعد الانتقال إلى GCM لا يمكن استنتاجها من النص المشفّر.
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $data['phone'] ?? null) : null;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $data['email'] ?? null) : null;
|
||||
$nameBidx = $blindIndex ? $blindIndex->index(
|
||||
'driver.name',
|
||||
trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''))
|
||||
) : null;
|
||||
// مفتاح ربط جداول التحقق — يجب أن يطابق otpPhoneKey() حرفياً
|
||||
$phoneKey = otpPhoneKey($data['phone'] ?? null);
|
||||
|
||||
$toEncryptDriver = [
|
||||
"phone","email","first_name","last_name","name_arabic","gender",
|
||||
"national_number","address","site","fullNameMaritial","birthdate"
|
||||
@@ -378,8 +414,18 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
|
||||
$con->beginTransaction();
|
||||
|
||||
/* ================== 6) Check duplicate ================== */
|
||||
$dup = $con->prepare("SELECT id FROM driver WHERE phone = :p OR email = :e");
|
||||
$dup->execute([':p' => $data['phone'], ':e' => $data['email']]);
|
||||
$dup = $con->prepare(
|
||||
"SELECT id FROM driver
|
||||
WHERE phone = :p OR email = :e
|
||||
OR (:pb IS NOT NULL AND phone_bidx = :pb)
|
||||
OR (:eb IS NOT NULL AND email_bidx = :eb)"
|
||||
);
|
||||
$dup->execute([
|
||||
':p' => $data['phone'],
|
||||
':e' => $data['email'],
|
||||
':pb' => $phoneBidx,
|
||||
':eb' => $emailBidx,
|
||||
]);
|
||||
if ($dup->rowCount() > 0) {
|
||||
$con->rollBack();
|
||||
jsonError("Phone or email already registered.");
|
||||
@@ -394,14 +440,16 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
|
||||
address, licenseIssueDate, status, birthdate, site,
|
||||
first_name, last_name, accountBank, bankCode,
|
||||
employmentType, ai_data, user_input, maritalStatus,
|
||||
fullNameMaritial, expirationDate, created_at, updated_at
|
||||
fullNameMaritial, expirationDate, created_at, updated_at,
|
||||
phone_bidx, email_bidx, name_bidx, phone_key
|
||||
) VALUES (
|
||||
:id, :phone, :email, :pwd, :gender, :license_type, :national_number,
|
||||
:name_arabic, :issue_date, :expiry_date, :license_categories,
|
||||
:address, :licenseIssueDate, :status, :birthdate, :site,
|
||||
:first_name, :last_name, :accountBank, :bankCode,
|
||||
:employmentType, :ai_data, :user_input, :maritalStatus,
|
||||
:fullNameMaritial, :expirationDate, NOW(), NOW()
|
||||
:fullNameMaritial, :expirationDate, NOW(), NOW(),
|
||||
:phone_bidx, :email_bidx, :name_bidx, :phone_key
|
||||
)
|
||||
";
|
||||
$insD = $con->prepare($sqlDriver);
|
||||
@@ -432,6 +480,10 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
|
||||
':maritalStatus' => !empty($data['maritalStatus']) ? $data['maritalStatus'] : 'yet',
|
||||
':fullNameMaritial' => !empty($data['fullNameMaritial']) ? $data['fullNameMaritial'] : 'yet',
|
||||
':expirationDate' => !empty($data['expirationDate']) ? $data['expirationDate'] : 'yet',
|
||||
':phone_bidx' => $phoneBidx,
|
||||
':email_bidx' => $emailBidx,
|
||||
':name_bidx' => $nameBidx,
|
||||
':phone_key' => $phoneKey,
|
||||
]);
|
||||
if (!$okD) {
|
||||
$con->rollBack();
|
||||
@@ -528,7 +580,7 @@ Therefore, do NOT assume a specific field is on the front or the back of a card.
|
||||
|
||||
/* ================== 11) Notification ================== */
|
||||
try {
|
||||
$fcmSendUrl = 'https://api.intaleq.xyz/siro/ride/firebase/send_fcm.php';
|
||||
$fcmSendUrl = getenv('FCM_ENDPOINT_URL') ?: 'http://nginx/backend/ride/firebase/send_fcm.php';
|
||||
|
||||
$driverFullName = $raw_first_name . ' ' . $raw_last_name;
|
||||
$notificationTitle = 'تسجيل سائق جديد';
|
||||
+2
-2
@@ -51,8 +51,8 @@ $sentOK = ($httpCode === 200 && ($decoded['success'] ?? false));
|
||||
|
||||
if ($sentOK) {
|
||||
/* 3) تشفير البيانات وحفظها في DB ----------------------------------- */
|
||||
$receiver_enc = $encryptionHelper->encryptData($receiver);
|
||||
$otp_enc = $encryptionHelper->encryptData($otp);
|
||||
$receiver_enc = otpPhoneKey($receiver);
|
||||
$otp_enc = otpPhoneKey($otp); // يجب أن يطابق صيغة المقارنة في verify_otp
|
||||
|
||||
$exp = date('Y-m-d H:i:s', strtotime('+5 minutes'));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
+3
-2
@@ -9,8 +9,9 @@ if (empty($phoneNumber) || empty($otp)) {
|
||||
exit();
|
||||
}
|
||||
|
||||
$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber);
|
||||
$otp_encrypted = $encryptionHelper->encryptData($otp);
|
||||
$phoneNumber_encrypted = otpPhoneKey($phoneNumber);
|
||||
// الرمز يُقارن بالتساوي أيضاً، فيحتاج نفس الصيغة الثابتة
|
||||
$otp_encrypted = otpPhoneKey($otp);
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare("
|
||||
+51
-3
@@ -11,16 +11,39 @@ if (empty($phone) && empty($email)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Build WHERE dynamically: support phone-only, email-only, or both
|
||||
/**
|
||||
* البحث عن الحساب.
|
||||
*
|
||||
* سابقاً كان يقارن القيمة الخام بالعمود المشفّر مباشرةً، وهو ما ينجح فقط لأن
|
||||
* التشفير الحالي حتمي (CBC بـ IV ثابت). الفهرس الأعمى يجعل هذا الاستعلام
|
||||
* مستقلاً عن أسلوب التشفير، فلا ينكسر تسجيل الدخول عند الانتقال إلى AES-GCM.
|
||||
*
|
||||
* تُبقى المقارنتان القديمتان في نفس الشرط كاحتياط للحسابات التي لم تُفهرس بعد.
|
||||
*/
|
||||
global $blindIndex;
|
||||
|
||||
$conditions = [];
|
||||
$params = [':password' => $password];
|
||||
|
||||
if (!empty($phone)) {
|
||||
$conditions[] = "passengers.phone = :phone";
|
||||
$params[':phone'] = $phone;
|
||||
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone) : null;
|
||||
if ($phoneBidx) {
|
||||
$conditions[] = "passengers.phone_bidx = :phone_bidx";
|
||||
$params[':phone_bidx'] = $phoneBidx;
|
||||
}
|
||||
}
|
||||
if (!empty($email)) {
|
||||
$conditions[] = "passengers.email = :email";
|
||||
$params[':email'] = $email;
|
||||
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null;
|
||||
if ($emailBidx) {
|
||||
$conditions[] = "passengers.email_bidx = :email_bidx";
|
||||
$params[':email_bidx'] = $emailBidx;
|
||||
}
|
||||
}
|
||||
$where = implode(' OR ', $conditions);
|
||||
|
||||
@@ -39,18 +62,43 @@ $sql = "SELECT
|
||||
passengers.`maritalStatus`,
|
||||
passengers.`created_at`,
|
||||
passengers.`updated_at`,
|
||||
email_verifications.verified
|
||||
passengers.`email` AS `_email_enc`
|
||||
FROM
|
||||
`passengers`
|
||||
LEFT JOIN email_verifications ON email_verifications.email = passengers.email
|
||||
WHERE
|
||||
$where";
|
||||
|
||||
/**
|
||||
* حالة توثيق البريد.
|
||||
*
|
||||
* كان الاستعلام يربط email_verifications.email بعمود البريد في الحساب، لكن
|
||||
* الأول يُخزَّن نصاً صريحاً والثاني مشفّراً — فالربط لم يكن يطابق شيئاً أصلاً
|
||||
* وكانت verified تعود NULL دائماً. نجلبها هنا بالبريد الأصلي.
|
||||
*/
|
||||
function fetchEmailVerified(PDO $con, ?string $plainEmail): ?int
|
||||
{
|
||||
if (!$plainEmail) return null;
|
||||
try {
|
||||
$st = $con->prepare("SELECT verified FROM email_verifications WHERE email = ? LIMIT 1");
|
||||
$st->execute([$plainEmail]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v === false ? null : (int) $v;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[email_verifications] ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$count = $stmt->rowCount();
|
||||
|
||||
if ($count > 0) {
|
||||
$plainEmail = $encryptionHelper->decryptData($data[0]['_email_enc'] ?? null) ?: null;
|
||||
$data[0]['verified'] = fetchEmailVerified($con, $plainEmail);
|
||||
unset($data[0]['_email_enc']);
|
||||
|
||||
$stored_password = $data[0]['password'];
|
||||
if (password_verify($password, $stored_password)) {
|
||||
unset($data[0]['password']);
|
||||
|
||||
@@ -72,6 +72,7 @@ function getNabehBearerToken(): ?string {
|
||||
|
||||
if (!$email || !$password) {
|
||||
$msg = "⚠️ [Nabeh Auth] Missing NABEH_EMAIL or NABEH_PASSWORD environment variables.";
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
return null;
|
||||
}
|
||||
@@ -85,6 +86,9 @@ function getNabehBearerToken(): ?string {
|
||||
$response = curlCall("POST", $apiUrl, json_encode($payload), [
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
|
||||
$debugLog = "[Nabeh Auth Debug] Request: $apiUrl | Response: $response";
|
||||
error_log($debugLog);
|
||||
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
@@ -95,6 +99,7 @@ function getNabehBearerToken(): ?string {
|
||||
if ($redis) {
|
||||
try {
|
||||
$redis->setex('nabeh_bearer_token', 86400, $token);
|
||||
error_log("[Nabeh Auth Debug] Token cached in Redis successfully.");
|
||||
} catch (Exception $e) {
|
||||
$msg = "⚠️ [Nabeh Auth Redis Cache Save] Error saving token: " . $e->getMessage();
|
||||
error_log($msg);
|
||||
@@ -102,10 +107,12 @@ function getNabehBearerToken(): ?string {
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
$msg = "❌ [Nabeh Auth] Failed to extract token from login response: " . $response;
|
||||
$msg = "❌ [Nabeh Auth Login Failed] Response: " . $response;
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
} else {
|
||||
$msg = "❌ [Nabeh Auth] Empty response from login API cURL.";
|
||||
$msg = "❌ [Nabeh Auth Login Failed] Empty response from login API.";
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
}
|
||||
return null;
|
||||
@@ -123,24 +130,17 @@ function getNabehBearerToken(): ?string {
|
||||
function sendNabehOtp(string $receiver, string $otp, string $method = '', string $user_type = 'passenger'): bool {
|
||||
$bearerToken = getNabehBearerToken();
|
||||
if (!$bearerToken) {
|
||||
$msg = "⚠️ [Nabeh OTP] Failed to obtain dynamic JWT Bearer token.";
|
||||
error_log($msg);
|
||||
if (empty($GLOBALS['last_otp_error'])) {
|
||||
$GLOBALS['last_otp_error'] = "⚠️ [Nabeh OTP] Failed to obtain dynamic JWT Bearer token.";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strip symbols for Nabeh endpoint
|
||||
$phoneRaw = preg_replace('/\D+/', '', $receiver);
|
||||
|
||||
// Map method/type (Text OTP is the default now)
|
||||
$type = 'image';
|
||||
if ($method === 'image') {
|
||||
$type = 'image';
|
||||
} elseif ($method === 'text') {
|
||||
$type = 'text';
|
||||
} else {
|
||||
// If no method specified or if 'voice' was requested, force default to 'text'
|
||||
$type = 'image';
|
||||
}
|
||||
// Map method/type (Image OTP card is default for Nabeh)
|
||||
$type = ($method === 'text') ? 'text' : (($method === 'voice') ? 'voice' : 'image');
|
||||
|
||||
$appName = 'سيرو رايدر';
|
||||
if ($user_type === 'driver') {
|
||||
@@ -151,9 +151,31 @@ function sendNabehOtp(string $receiver, string $otp, string $method = '', string
|
||||
$appName = 'سيرو للخدمات';
|
||||
}
|
||||
|
||||
// First attempt with the chosen type
|
||||
$result = _nabehOtpAttempt($phoneRaw, $type, $otp, $appName, $bearerToken);
|
||||
if ($result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: if image failed, retry with text
|
||||
if ($type === 'image') {
|
||||
error_log("ℹ️ [Nabeh OTP Fallback] Image failed, retrying with text type...");
|
||||
$result = _nabehOtpAttempt($phoneRaw, 'text', $otp, $appName, $bearerToken);
|
||||
if ($result) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: single OTP send attempt to Nabeh
|
||||
*/
|
||||
function _nabehOtpAttempt(string $phone, string $type, string $otp, string $appName, string $bearerToken): bool {
|
||||
$apiUrl = 'https://nabeh.intaleqapp.com/api/otp/send';
|
||||
$payload = [
|
||||
'phone' => $phoneRaw,
|
||||
'phone' => $phone,
|
||||
'type' => $type,
|
||||
'code' => $otp,
|
||||
'message' => "رمز التحقق الخاص بك لتطبيق {$appName} هو: *{code}* \n الرجاء عدم مشاركته مع أي شخص."
|
||||
@@ -166,13 +188,33 @@ function sendNabehOtp(string $receiver, string $otp, string $method = '', string
|
||||
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
if ($decoded && (($decoded['success'] ?? false) || ($decoded['status'] ?? '') === 'success')) {
|
||||
return true;
|
||||
error_log("ℹ️ [Nabeh OTP Response type=$type] " . $response);
|
||||
if ($decoded) {
|
||||
$statusStr = strtolower((string)($decoded['status'] ?? ''));
|
||||
$msgStr = strtolower((string)($decoded['message'] ?? ''));
|
||||
$errStr = strtolower((string)($decoded['error'] ?? ''));
|
||||
if (
|
||||
!empty($decoded['success']) ||
|
||||
in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true) ||
|
||||
($decoded['status'] ?? false) === true ||
|
||||
($decoded['code'] ?? 0) === 200 ||
|
||||
!empty($decoded['message_id']) ||
|
||||
!empty($decoded['id']) ||
|
||||
!empty($decoded['token']) ||
|
||||
strpos($msgStr, 'success') !== false ||
|
||||
strpos($msgStr, 'sent') !== false ||
|
||||
strpos($msgStr, 'تم') !== false ||
|
||||
strpos($errStr, 'via gateway') !== false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$msg = "❌ [Nabeh OTP] API returned failure response: " . $response;
|
||||
$msg = "❌ [Nabeh OTP type=$type] Response: " . $response;
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
} else {
|
||||
$msg = "❌ [Nabeh OTP] Empty response from cURL.";
|
||||
$msg = "❌ [Nabeh OTP type=$type] Empty cURL response.";
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
}
|
||||
return false;
|
||||
@@ -209,7 +251,7 @@ function sendIntaleqOtp(string $receiver, string &$otp, string $method = 'whatsa
|
||||
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
if ($decoded && ($decoded['success'] ?? false)) {
|
||||
if ($decoded && (!empty($decoded['success']) || ($decoded['status'] ?? '') === 'success')) {
|
||||
if (isset($decoded['otp'])) {
|
||||
$otp = (string)$decoded['otp'];
|
||||
}
|
||||
@@ -217,9 +259,8 @@ function sendIntaleqOtp(string $receiver, string &$otp, string $method = 'whatsa
|
||||
}
|
||||
$msg = "❌ [Intaleq OTP] API returned failure response: " . $response;
|
||||
error_log($msg);
|
||||
echo $msg; // Temporarily echo the raw error so we can see it in the app logs!
|
||||
} else {
|
||||
echo "❌ [Intaleq OTP] Empty response or cURL failed.";
|
||||
error_log("❌ [Intaleq OTP] Empty response or cURL failed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -234,8 +275,8 @@ function curlCall(string $method, string $url, string $data, array $headers): ?s
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_CONNECTTIMEOUT => 5
|
||||
CURLOPT_TIMEOUT => 35,
|
||||
CURLOPT_CONNECTTIMEOUT => 10
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
@@ -119,8 +119,11 @@ switch (strtolower($country)) {
|
||||
|
||||
// 6. DB Storage on Success
|
||||
if ($sentSuccessfully) {
|
||||
$encryptedPhone = $encryptionHelper->encryptData($receiver);
|
||||
$encryptedOtp = $encryptionHelper->encryptData($otp);
|
||||
$encryptedPhone = otpPhoneKey($receiver); // مفتاح بحث ثابت مستقل عن نمط التشفير
|
||||
// نسخة قابلة للاسترجاع: خدمة العملاء تتابع من طلب رمزاً ولم يُكمل تسجيله،
|
||||
// والمفتاح أعلاه أحادي الاتجاه فلا يُستخرج منه الرقم.
|
||||
$phoneEncStored = $encryptionHelper->encryptData($receiver);
|
||||
$encryptedOtp = $encryptionHelper->encryptDataGCM($otp); // Random GCM
|
||||
$encryptedEmail = !empty($email) ? $encryptionHelper->encryptData($email) : '';
|
||||
|
||||
try {
|
||||
@@ -135,15 +138,16 @@ if ($sentSuccessfully) {
|
||||
|
||||
$stmtIns = $con->prepare("
|
||||
INSERT INTO `phone_verification_service`
|
||||
(`phone_number`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
(`phone_number`, `phone_enc`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
|
||||
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
");
|
||||
$stmtIns->execute([
|
||||
$encryptedPhone,
|
||||
$phoneEncStored,
|
||||
$encryptedOtp
|
||||
]);
|
||||
} elseif ($user_type === 'driver') {
|
||||
if ($context === 'token_change') {
|
||||
if ($context === 'token_change' || $context === 'payout') {
|
||||
// Delete old verification attempts
|
||||
$stmtDel = $con->prepare("DELETE FROM `token_verification_driver` WHERE `phone_number` = ?");
|
||||
$stmtDel->execute([$encryptedPhone]);
|
||||
@@ -166,11 +170,12 @@ if ($sentSuccessfully) {
|
||||
// Insert new attempt
|
||||
$stmtIns = $con->prepare("
|
||||
INSERT INTO `phone_verification`
|
||||
(`phone_number`, `driverId`, `email`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
|
||||
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
(`phone_number`, `phone_enc`, `driverId`, `email`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
|
||||
VALUES (?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
");
|
||||
$stmtIns->execute([
|
||||
$encryptedPhone,
|
||||
$phoneEncStored,
|
||||
$driverId ?: '',
|
||||
$encryptedEmail,
|
||||
$encryptedOtp
|
||||
@@ -185,11 +190,12 @@ if ($sentSuccessfully) {
|
||||
// Insert new attempt
|
||||
$stmtIns = $con->prepare("
|
||||
INSERT INTO `token_verification`
|
||||
(`phone_number`, `token`, `expiration_time`, `verified`, `created_at`)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
(`phone_number`, `phone_enc`, `token`, `expiration_time`, `verified`, `created_at`)
|
||||
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
");
|
||||
$stmtIns->execute([
|
||||
$encryptedPhone,
|
||||
$phoneEncStored,
|
||||
$encryptedOtp
|
||||
]);
|
||||
} else {
|
||||
@@ -200,11 +206,12 @@ if ($sentSuccessfully) {
|
||||
// Insert new attempt
|
||||
$stmtIns = $con->prepare("
|
||||
INSERT INTO `phone_verification_passenger`
|
||||
(`phone_number`, `token`, `expiration_time`, `verified`, `created_at`)
|
||||
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
(`phone_number`, `phone_enc`, `token`, `expiration_time`, `verified`, `created_at`)
|
||||
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
||||
");
|
||||
$stmtIns->execute([
|
||||
$encryptedPhone,
|
||||
$phoneEncStored,
|
||||
$encryptedOtp
|
||||
]);
|
||||
}
|
||||
@@ -216,5 +223,6 @@ if ($sentSuccessfully) {
|
||||
jsonError("OTP sent but failed to save verification data");
|
||||
}
|
||||
} else {
|
||||
jsonError("Failed to send verification code. Please try again.");
|
||||
$errDetail = !empty($GLOBALS['last_otp_error']) ? $GLOBALS['last_otp_error'] : "Failed to send verification code. Please try again.";
|
||||
jsonError($errDetail);
|
||||
}
|
||||
|
||||
+64
-30
@@ -59,18 +59,19 @@ try {
|
||||
// 3. Encrypt data to query
|
||||
// 4. Verify based on user type
|
||||
try {
|
||||
$encryptedPhoneSearch = otpPhoneKey($phone_number);
|
||||
|
||||
if ($user_type === 'admin') {
|
||||
$sql = "SELECT * FROM token_verification_admin
|
||||
WHERE expiration_time >= NOW() AND verified = 0";
|
||||
WHERE expiration_time >= NOW() AND verified = 0 AND phone_number = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRow = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($row['phone_number']);
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedPhone === $phone_number && $decryptedToken === $token_code) {
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRow = $row;
|
||||
break;
|
||||
}
|
||||
@@ -101,16 +102,15 @@ try {
|
||||
}
|
||||
} elseif ($user_type === 'service') {
|
||||
$sql = "SELECT `id`, `phone_number`, `token_code` FROM `phone_verification_service`
|
||||
WHERE `expiration_time` > NOW() AND `is_verified` = 0";
|
||||
WHERE `expiration_time` > NOW() AND `is_verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($row['phone_number']);
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token_code']);
|
||||
if ($decryptedPhone === $phone_number && $decryptedToken === $token_code) {
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
@@ -128,16 +128,15 @@ try {
|
||||
} elseif ($user_type === 'driver') {
|
||||
if ($context === 'token_change') {
|
||||
$sql = "SELECT `id`, `phone_number`, `token` FROM `token_verification_driver`
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0";
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($row['phone_number']);
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedPhone === $phone_number && $decryptedToken === $token_code) {
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
@@ -154,16 +153,15 @@ try {
|
||||
}
|
||||
} else {
|
||||
$sql = "SELECT `id`, `phone_number`, `token_code` FROM `phone_verification`
|
||||
WHERE `expiration_time` > NOW() AND `is_verified` = 0";
|
||||
WHERE `expiration_time` > NOW() AND `is_verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($row['phone_number']);
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token_code']);
|
||||
if ($decryptedPhone === $phone_number && $decryptedToken === $token_code) {
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
@@ -179,8 +177,10 @@ try {
|
||||
$isRegistered = false;
|
||||
$driverData = null;
|
||||
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM driver WHERE phone = ?");
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM driver WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone_number) : null;
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
|
||||
$driver = $chkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Generate driverID for unregistered users (hash of phone)
|
||||
@@ -198,10 +198,25 @@ try {
|
||||
$driverID = substr(md5($phone_number), 0, 16);
|
||||
}
|
||||
|
||||
// Generate JWT tokens for driver
|
||||
$audDriver = filterRequest("aud") ?: filterRequest("audience") ?: (getenv('allowedDriver2') ?: 'driver-app:ios');
|
||||
$fpDriver = filterRequest("fingerprint") ?? filterRequest("fingerPrint") ?? ($_SERVER['HTTP_X_DEVICE_FP'] ?? null);
|
||||
if ($fpDriver === null && function_exists('getallheaders')) {
|
||||
$hdrs = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$fpDriver = $hdrs['x-device-fp'] ?? null;
|
||||
}
|
||||
$jwtSvc = new JwtService($redis);
|
||||
$accessToken = $jwtSvc->generateAccessToken($driverID, 'driver', $audDriver, $fpDriver);
|
||||
$refreshToken = $jwtSvc->generateRefreshToken($driverID, 'driver', $audDriver);
|
||||
|
||||
jsonSuccess([
|
||||
"isRegistered" => $isRegistered,
|
||||
"driver" => $driverData,
|
||||
"driverID" => $driverID
|
||||
"driverID" => $driverID,
|
||||
"jwt" => $accessToken,
|
||||
"token" => $accessToken,
|
||||
"access_token" => $accessToken,
|
||||
"refresh_token" => $refreshToken
|
||||
], "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
@@ -210,16 +225,15 @@ try {
|
||||
} else {
|
||||
if ($context === 'token_change') {
|
||||
$sql = "SELECT `id`, `phone_number`, `token` FROM `token_verification`
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0";
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($row['phone_number']);
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedPhone === $phone_number && $decryptedToken === $token_code) {
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
@@ -236,16 +250,15 @@ try {
|
||||
}
|
||||
} else {
|
||||
$sql = "SELECT `id`, `phone_number`, `token` FROM `phone_verification_passenger`
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0";
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($row['phone_number']);
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedPhone === $phone_number && $decryptedToken === $token_code) {
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
@@ -260,9 +273,12 @@ try {
|
||||
// Check registration status
|
||||
$isRegistered = false;
|
||||
$passengerData = null;
|
||||
$passengerID = '';
|
||||
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM passengers WHERE phone = ?");
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone_number) : null;
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
|
||||
$passenger = $chkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($passenger) {
|
||||
@@ -272,11 +288,29 @@ try {
|
||||
$passenger['email'] = $encryptionHelper->decryptData($passenger['email']);
|
||||
$passenger['phone'] = $encryptionHelper->decryptData($passenger['phone']);
|
||||
$passengerData = $passenger;
|
||||
$passengerID = (string)$passenger['id'];
|
||||
} else {
|
||||
$passengerID = substr(md5($phone_number), 0, 16);
|
||||
}
|
||||
|
||||
// Generate JWT tokens for passenger
|
||||
$audPass = filterRequest("aud") ?: filterRequest("audience") ?: (getenv('allowed2') ?: 'passenger-app:ios');
|
||||
$fpPass = filterRequest("fingerprint") ?? filterRequest("fingerPrint") ?? ($_SERVER['HTTP_X_DEVICE_FP'] ?? null);
|
||||
if ($fpPass === null && function_exists('getallheaders')) {
|
||||
$hdrs = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$fpPass = $hdrs['x-device-fp'] ?? null;
|
||||
}
|
||||
$jwtSvc = new JwtService($redis);
|
||||
$accessToken = $jwtSvc->generateAccessToken($passengerID, 'passenger', $audPass, $fpPass);
|
||||
$refreshToken = $jwtSvc->generateRefreshToken($passengerID, 'passenger', $audPass);
|
||||
|
||||
jsonSuccess([
|
||||
"isRegistered" => $isRegistered,
|
||||
"passenger" => $passengerData
|
||||
"passenger" => $passengerData,
|
||||
"jwt" => $accessToken,
|
||||
"token" => $accessToken,
|
||||
"access_token" => $accessToken,
|
||||
"refresh_token" => $refreshToken
|
||||
], "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// لا نستقبل id أو email من التطبيق بل نأخذهم من التوكن (JWT) لزيادة الأمان
|
||||
$platform = filterRequest("platform") ?: 'unknown';
|
||||
@@ -45,7 +45,7 @@ $sql = "SELECT
|
||||
t.fingerPrint AS fcm_fingerprint
|
||||
FROM passengers p
|
||||
LEFT JOIN phone_verification_passenger
|
||||
ON phone_verification_passenger.phone_number = p.phone
|
||||
ON phone_verification_passenger.phone_number = p.phone_key
|
||||
LEFT JOIN invitesToPassengers
|
||||
ON invitesToPassengers.inviterPassengerPhone = p.phone
|
||||
LEFT JOIN promos
|
||||
+25
-68
@@ -2,25 +2,23 @@
|
||||
// loginUsingCredentialsWithoutGooglePassenger.php
|
||||
// مسار مخصص لفاحصي التطبيق (الركاب) يعمل بدون JWT Interceptors
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
|
||||
$email = filterRequest("email");
|
||||
$password = filterRequest("password");
|
||||
$fingerprint = filterRequest('fingerPrint') ?? filterRequest('fingerprint');
|
||||
$audience = filterRequest('aud') ?: 'siro_passenger';
|
||||
|
||||
// 1. تطبيق حد معدل الطلبات (Rate Limiting) للفاحصين: 3 محاولات بالدقيقة لكل IP
|
||||
// 1. حد معدل الطلبات مطبّق على الجميع (الحد مرفوع إلى 30/دقيقة في RateLimiter)
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'tester_login');
|
||||
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// 2. التحقق من أن الحساب مخصص للفحص فقط (isTest check)
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: '';
|
||||
$allowedEmails = array_filter(array_map('trim', explode(',', $allowedTesterEmailsEnv)));
|
||||
// 2. قائمة بيضاء صريحة لحسابات الفحص — مطابقة تامة فقط، لا مطابقة جزئية ولا مطابقة نطاق
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: ($_ENV['ALLOWED_TESTER_EMAILS'] ?? '');
|
||||
$allowedEmails = array_filter(array_map(
|
||||
fn($e) => strtolower(trim($e)),
|
||||
explode(',', $allowedTesterEmailsEnv)
|
||||
));
|
||||
if (empty($allowedEmails)) {
|
||||
$allowedEmails = [
|
||||
'driver_tester@siromove.com',
|
||||
@@ -28,66 +26,23 @@ if (empty($allowedEmails)) {
|
||||
];
|
||||
}
|
||||
|
||||
$cleanEmail = strtolower(trim((string) $email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails, true);
|
||||
|
||||
$cleanEmail = strtolower(trim($email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails) ||
|
||||
substr($cleanEmail, -13) === '@siromove.com' ||
|
||||
str_contains($cleanEmail, 'tester') ||
|
||||
str_contains($cleanEmail, 'reviewer');
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// تشفير الإيميل للبحث في قاعدة البيانات
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
global $blindIndex;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null;
|
||||
|
||||
// Auto-seed/create tester passenger if it doesn't exist
|
||||
if ($cleanEmail === 'passenger_tester@siromove.com') {
|
||||
$stmtCheck = $con->prepare("SELECT id FROM passengers WHERE email = :email LIMIT 1");
|
||||
$stmtCheck->bindParam(':email', $encryptedEmail);
|
||||
$stmtCheck->execute();
|
||||
if (!$stmtCheck->fetch()) {
|
||||
$passengerId = 'tester_passenger_id_2026';
|
||||
$phone = '+962790000003';
|
||||
$hashedPassword = password_hash('SiroPassenger2026!', PASSWORD_DEFAULT);
|
||||
|
||||
$encryptedPhone = $encryptionHelper->encryptData($phone);
|
||||
$encryptedFirstName = $encryptionHelper->encryptData('Passenger');
|
||||
$encryptedLastName = $encryptionHelper->encryptData('Tester');
|
||||
$encryptedGender = $encryptionHelper->encryptData('Male');
|
||||
$encryptedBirthdate = $encryptionHelper->encryptData('1990-01-01');
|
||||
$encryptedSite = $encryptionHelper->encryptData('Jordan');
|
||||
|
||||
// Insert passenger with verified = 1 so app doesn't reject
|
||||
$insert = $con->prepare("INSERT INTO passengers (id, phone, email, password, gender, birthdate, site, first_name, last_name, is_test, verified)
|
||||
VALUES (:id, :phone, :email, :password, :gender, :birthdate, :site, :first_name, :last_name, 1, 1)");
|
||||
$insert->execute([
|
||||
':id' => $passengerId,
|
||||
':phone' => $encryptedPhone,
|
||||
':email' => $encryptedEmail,
|
||||
':password' => $hashedPassword,
|
||||
':gender' => $encryptedGender,
|
||||
':birthdate' => $encryptedBirthdate,
|
||||
':site' => $encryptedSite,
|
||||
':first_name' => $encryptedFirstName,
|
||||
':last_name' => $encryptedLastName
|
||||
]);
|
||||
|
||||
// Ensure phone_verification_passenger row exists
|
||||
$stmtPhone = $con->prepare("SELECT * FROM phone_verification_passenger WHERE phone_number = :phone LIMIT 1");
|
||||
$stmtPhone->bindParam(':phone', $encryptedPhone);
|
||||
$stmtPhone->execute();
|
||||
if (!$stmtPhone->fetch()) {
|
||||
$insertPhone = $con->prepare("INSERT INTO phone_verification_passenger (phone_number, verified) VALUES (:phone, 1)");
|
||||
$insertPhone->bindParam(':phone', $encryptedPhone);
|
||||
$insertPhone->execute();
|
||||
} else {
|
||||
$updatePhone = $con->prepare("UPDATE phone_verification_passenger SET verified = 1 WHERE phone_number = :phone");
|
||||
$updatePhone->bindParam(':phone', $encryptedPhone);
|
||||
$updatePhone->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-seed/create tester passenger logic removed for security
|
||||
|
||||
$sql = "SELECT
|
||||
p.*,
|
||||
@@ -97,21 +52,22 @@ try {
|
||||
invitesToPassengers.isGiftToken
|
||||
FROM passengers p
|
||||
LEFT JOIN phone_verification_passenger
|
||||
ON phone_verification_passenger.phone_number = p.phone
|
||||
ON phone_verification_passenger.phone_number = p.phone_key
|
||||
LEFT JOIN invitesToPassengers
|
||||
ON invitesToPassengers.inviterPassengerPhone = p.phone
|
||||
WHERE p.email = :email
|
||||
WHERE p.email = :email OR (:email_bidx IS NOT NULL AND p.email_bidx = :email_bidx)
|
||||
LIMIT 1";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':email', $encryptedEmail);
|
||||
$stmt->bindParam(':email_bidx', $emailBidx);
|
||||
$stmt->execute();
|
||||
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($data) {
|
||||
// فحص الباسورد
|
||||
if (password_verify($password, $data['password']) || $password === $data['password']) {
|
||||
if (password_verify($password, $data['password'])) {
|
||||
// التحقق من أن الحساب معلم كحساب فحص في قاعدة البيانات أو البيئة
|
||||
$isTestInDb = (isset($data['is_test']) && $data['is_test'] == 1) || (isset($data['isTest']) && $data['isTest'] == 1);
|
||||
if (!$isTestInDb && !$isTester) {
|
||||
@@ -159,11 +115,12 @@ try {
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in loginUsingCredentialsWithoutGooglePassenger: " . $e->getMessage());
|
||||
} catch (Throwable $e) {
|
||||
error_log("Error in loginUsingCredentialsWithoutGooglePassenger: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
"status" => "failure",
|
||||
"message" => "Server error"
|
||||
"message" => "Server error. Please try again."
|
||||
]);
|
||||
}
|
||||
exit();
|
||||
@@ -7,6 +7,11 @@ error_reporting(E_ALL);
|
||||
$allowRegistration = true;
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// Rate Limiting: الحماية من البرمجيات الخبيثة والتسجيل العشوائي
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'register_passenger');
|
||||
|
||||
|
||||
// تعريف بادئة للوج (Tag) لسهولة البحث عنها في ملف الأخطاء
|
||||
$logTag = "[Register_Debug_passenger]";
|
||||
|
||||
@@ -58,16 +63,40 @@ try {
|
||||
$firstName_encrypted = $encryptionHelper->encryptData($firstName);
|
||||
$lastName_encrypted = $encryptionHelper->encryptData($lastName);
|
||||
$email_encrypted = $encryptionHelper->encryptData($email);
|
||||
$password_hashed = password_hash($email, PASSWORD_DEFAULT);
|
||||
$uniqueId = substr(md5($phoneNumber), 0, 16);
|
||||
$password_hashed = password_hash($email . $uniqueId, PASSWORD_DEFAULT);
|
||||
$unknown_encrypted = $encryptionHelper->encryptData("unknown yet");
|
||||
|
||||
// ======================================================
|
||||
// Step 4.5: التحقق الفعلي من ملكية رقم الهاتف (🔥 Fix)
|
||||
// ======================================================
|
||||
// كانت هذه النقطة تسمح بإنشاء حساب راكب بأي رقم هاتف بدون إثبات
|
||||
// ملكيته فعلياً — auth/otp/verify.php يُعلّم الصف verified=1 لكن
|
||||
// register_passenger.php لم يكن يتحقق من ذلك إطلاقاً. الآن نشترط
|
||||
// وجود صف تحقق ناجح (verified=1) لنفس رقم الهاتف خلال آخر 30 دقيقة
|
||||
// (مهلة أوسع من صلاحية الرمز نفسه [5 دقائق] لإعطاء وقت كافٍ لإكمال
|
||||
// نموذج التسجيل بعد التحقق مباشرة).
|
||||
$step = 4.5;
|
||||
$otpSearchKey = otpPhoneKey($phoneNumber);
|
||||
$verifyCheckStmt = $con->prepare(
|
||||
"SELECT id FROM phone_verification_passenger
|
||||
WHERE phone_number = ? AND verified = 1 AND created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
|
||||
LIMIT 1"
|
||||
);
|
||||
$verifyCheckStmt->execute([$otpSearchKey]);
|
||||
if ($verifyCheckStmt->rowCount() === 0) {
|
||||
error_log("$logTag Step 4.5 Error: Phone number not verified via OTP.");
|
||||
jsonError("Phone number must be verified before registration.");
|
||||
exit();
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// Step 5: إنشاء ID فريد
|
||||
// ======================================================
|
||||
$step = 5;
|
||||
// $uniqueId = substr(md5(uniqid(mt_rand(), true)), 0, 20);
|
||||
|
||||
$uniqueId = substr(md5($phoneNumber_encrypted), 0, 20);
|
||||
// $uniqueId is now generated earlier
|
||||
|
||||
error_log("$logTag Step 5: Generated Unique ID: $uniqueId");
|
||||
|
||||
@@ -75,8 +104,19 @@ try {
|
||||
// Step 6: التحقق من وجود المستخدم (Database Check)
|
||||
// ======================================================
|
||||
$step = 6;
|
||||
$checkStmt = $con->prepare("SELECT id FROM passengers WHERE phone = ?");
|
||||
$checkStmt->execute([$phoneNumber_encrypted]);
|
||||
// كشف التكرار عبر الفهرس الأعمى + المقارنة القديمة: بدون الفهرس يفشل
|
||||
// الكشف بعد الانتقال إلى GCM فيُسمح بتسجيل نفس الرقم مرتين.
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phoneNumber) : null;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', $email) : null;
|
||||
$nameBidx = $blindIndex ? $blindIndex->index('passengers.name', trim("$firstName $lastName")) : null;
|
||||
// مفتاح ربط جداول التحقق — يجب أن يطابق otpPhoneKey() حرفياً
|
||||
$phoneKey = otpPhoneKey($phoneNumber);
|
||||
|
||||
$checkStmt = $con->prepare(
|
||||
"SELECT id FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)"
|
||||
);
|
||||
$checkStmt->execute([$phoneNumber_encrypted, $phoneBidx, $phoneBidx]);
|
||||
|
||||
if ($checkStmt->rowCount() > 0) {
|
||||
error_log("$logTag Step 6 Error: User already exists.");
|
||||
@@ -91,8 +131,8 @@ try {
|
||||
error_log("$logTag Step 7: Inserting into passengers table...");
|
||||
|
||||
$insertStmt = $con->prepare("
|
||||
INSERT INTO passengers (id, first_name, last_name, email, phone, password, gender, birthdate, site, sosPhone, education, employmentType, maritalStatus, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW())
|
||||
INSERT INTO passengers (id, first_name, last_name, email, phone, password, gender, birthdate, site, sosPhone, education, employmentType, maritalStatus, status, created_at, updated_at, phone_bidx, email_bidx, name_bidx, phone_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), ?, ?, ?, ?)
|
||||
");
|
||||
$success = $insertStmt->execute([
|
||||
$uniqueId,
|
||||
@@ -107,7 +147,12 @@ try {
|
||||
$unknown_encrypted,
|
||||
$unknown_encrypted,
|
||||
$unknown_encrypted,
|
||||
$unknown_encrypted
|
||||
$unknown_encrypted,
|
||||
// فهارس البحث: تُكتب مع السجل حتى يكون قابلاً للبحث فوراً
|
||||
$phoneBidx,
|
||||
$emailBidx,
|
||||
$nameBidx,
|
||||
$phoneKey
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$email = filterRequest("email");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// Ensure the caller has a valid user_id
|
||||
if (empty($user_id)) {
|
||||
+2
-2
@@ -51,8 +51,8 @@ $sentOK = ($httpCode === 200 && ($decoded['success'] ?? false));
|
||||
|
||||
if ($sentOK) {
|
||||
/* 3) حفظ الرمز في Redis + قاعدة البيانات */
|
||||
$receiver_enc = $encryptionHelper->encryptData($receiver);
|
||||
$otp_enc = $encryptionHelper->encryptData($otp);
|
||||
$receiver_enc = otpPhoneKey($receiver);
|
||||
$otp_enc = otpPhoneKey($otp); // يجب أن يطابق صيغة المقارنة في verify_otp
|
||||
|
||||
$exp = date('Y-m-d H:i:s', strtotime('+5 minutes'));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
+3
-2
@@ -18,8 +18,9 @@ if (empty($phoneNumber) || empty($otp)) {
|
||||
exit();
|
||||
}
|
||||
|
||||
$phoneNumber_encrypted = $encryptionHelper->encryptData($phoneNumber);
|
||||
$otp_encrypted = $encryptionHelper->encryptData($otp);
|
||||
$phoneNumber_encrypted = otpPhoneKey($phoneNumber);
|
||||
// الرمز يُقارن بالتساوي أيضاً، فيحتاج نفس الصيغة الثابتة
|
||||
$otp_encrypted = otpPhoneKey($otp);
|
||||
|
||||
try {
|
||||
// 1. التحقق من Redis بدلاً من MySQL
|
||||
@@ -4,9 +4,8 @@ require_once __DIR__ . '/../connect.php';
|
||||
$email = filterRequest("email");
|
||||
$token = filterRequest("token");
|
||||
|
||||
$sql = "SELECT * FROM `email_verifications` WHERE `email` = '$email'";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt = $con->prepare("SELECT * FROM `email_verifications` WHERE `email` = ?");
|
||||
$stmt->execute([$email]);
|
||||
|
||||
$rowCount = $stmt->rowCount();
|
||||
|
||||
@@ -41,9 +40,9 @@ SEFER Team.
|
||||
|
||||
if ($rowCount > 0) {
|
||||
// The email already exists, so update the data
|
||||
$sql = "UPDATE `email_verifications` SET `token` = '$token' WHERE `email` = '$email'";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
// كانت القيم تُدمج في نص الاستعلام مباشرةً — حقن SQL عبر البريد أو الرمز.
|
||||
$stmt = $con->prepare("UPDATE `email_verifications` SET `token` = ? WHERE `email` = ?");
|
||||
$stmt->execute([$token, $email]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// The update was successful
|
||||
@@ -55,9 +54,8 @@ if ($rowCount > 0) {
|
||||
}
|
||||
} else {
|
||||
// The email does not exist, so insert the data
|
||||
$sql = "INSERT INTO `email_verifications` (`email`, `token`) VALUES ('$email', '$token')";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt = $con->prepare("INSERT INTO `email_verifications` (`email`, `token`) VALUES (?, ?)");
|
||||
$stmt->execute([$email, $token]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// The insertion was successful
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
$allowRegistration = true;
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// جلب البيانات من المستخدم
|
||||
$phone = filterRequest("phone");
|
||||
$email = filterRequest("email");
|
||||
$first_name = filterRequest("first_name");
|
||||
$last_name = filterRequest("last_name");
|
||||
$password = filterRequest("password");
|
||||
$gender = filterRequest("gender");
|
||||
$birthdate = filterRequest("birthdate");
|
||||
$site = filterRequest("site");
|
||||
|
||||
// --- Input Validation ---
|
||||
if (empty($phone) || strlen(preg_replace('/\D+/', '', $phone)) < 8) {
|
||||
jsonError("Valid phone number is required.");
|
||||
exit;
|
||||
}
|
||||
if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
jsonError("Valid email address is required.");
|
||||
exit;
|
||||
}
|
||||
if (empty($password) || strlen($password) < 6) {
|
||||
jsonError("Password must be at least 6 characters.");
|
||||
exit;
|
||||
}
|
||||
if (empty($first_name) || empty($last_name)) {
|
||||
jsonError("First name and last name are required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// تشفير البيانات الحساسة
|
||||
$phone = $encryptionHelper->encryptData($phone);
|
||||
$email = $encryptionHelper->encryptData($email);
|
||||
$gender = $encryptionHelper->encryptData($gender);
|
||||
$birthdate = $encryptionHelper->encryptData($birthdate);
|
||||
$site = $encryptionHelper->encryptData($site);
|
||||
$first_name = $encryptionHelper->encryptData($first_name);
|
||||
$last_name = $encryptionHelper->encryptData($last_name);
|
||||
|
||||
// تشفير الباسورد
|
||||
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
try {
|
||||
// التحقق من وجود الإيميل أو رقم الهاتف مسبقًا
|
||||
$sql = "SELECT * FROM passengers WHERE phone = :phone OR email = :email";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(":phone", $phone);
|
||||
$stmt->bindParam(":email", $email);
|
||||
$stmt->execute();
|
||||
$results = $stmt->fetchAll();
|
||||
|
||||
if (count($results) > 0) {
|
||||
jsonError("The email or phone number is already registered.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// إدخال البيانات الجديدة (مع ID تلقائي)
|
||||
$sql = "INSERT INTO passengers (
|
||||
id, phone, email, password, gender, birthdate, site, first_name, last_name
|
||||
) VALUES (
|
||||
UUID_SHORT(), :phone, :email, :password, :gender, :birthdate, :site, :first_name, :last_name
|
||||
)";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(":phone", $phone);
|
||||
$stmt->bindParam(":email", $email);
|
||||
$stmt->bindParam(":password", $hashedPassword);
|
||||
$stmt->bindParam(":gender", $gender);
|
||||
$stmt->bindParam(":birthdate", $birthdate);
|
||||
$stmt->bindParam(":site", $site);
|
||||
$stmt->bindParam(":first_name", $first_name);
|
||||
$stmt->bindParam(":last_name", $last_name);
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess(null, "success to save passenger data");
|
||||
} else {
|
||||
jsonError("Failed to save passenger data");
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("Database Error: " . $e->getMessage());
|
||||
jsonError("An error occurred while saving the data.");
|
||||
}
|
||||
?>
|
||||
@@ -1,303 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Endpoint: register_driver_and_car.php (نسخة محدثة)
|
||||
* Method: POST (multipart/form-data أو x-www-form-urlencoded)
|
||||
* الوظيفة: إنشاء سائق + تسجيل مركبة + حفظ روابط الوثائق الموقّعة (من السيرفر السوري)
|
||||
* ملاحظة: لا نتلقّى ملفات هنا. نتلقى فقط روابط secure_image.php الموقّعة.
|
||||
*/
|
||||
$allowRegistration = true;
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonError("Invalid method.");
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ========== إعدادات عامة ========== */
|
||||
// الدومينات المسموح بها للروابط الموقّعة (السيرفر السوري)
|
||||
$ALLOWED_SIGNED_HOSTS = [
|
||||
'syria.intaleq.xyz', // عدّل حسب بيئتك
|
||||
'applink.syria', // مثال آخر إن كان لديك دومين إضافي
|
||||
];
|
||||
|
||||
// توثيق شكل الروابط: secure_image.php?driver_id=...&doc_type=...&ext=jpg|png|webp&expires=...&signature=...
|
||||
$allowedDocTypes = [
|
||||
'driver_license_front',
|
||||
'driver_license_back',
|
||||
'car_license_front',
|
||||
'car_license_back',
|
||||
];
|
||||
$allowedExts = ['jpg','png','webp'];
|
||||
|
||||
/* ========== 1) جلب الحقول ========== */
|
||||
$required = ["phone", "password", "first_name", "last_name"];
|
||||
$optional = [
|
||||
"id","email","gender","license_type","national_number",
|
||||
"name_arabic","issue_date","expiry_date","license_categories",
|
||||
"address","licenseIssueDate","status","birthdate","site",
|
||||
"employmentType","maritalStatus","fullNameMaritial","expirationDate"
|
||||
];
|
||||
|
||||
// حقول السيارة (مطلوبة)
|
||||
$carRequired = [
|
||||
"vin","car_plate","make","model","year","expiration_date",
|
||||
"color","owner","color_hex","fuel"
|
||||
];
|
||||
|
||||
// روابط الوثائق (مطلوبة)
|
||||
$docUrlKeys = [
|
||||
'driver_license_front_url',
|
||||
'driver_license_back_url',
|
||||
'car_license_front_url',
|
||||
'car_license_back_url'
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($required as $f) {
|
||||
$v = filterRequest($f);
|
||||
if ($v === null || $v === '') {
|
||||
jsonError("Missing required field: $f");
|
||||
exit;
|
||||
}
|
||||
$data[$f] = $v;
|
||||
}
|
||||
|
||||
foreach ($optional as $f) {
|
||||
$v = filterRequest($f);
|
||||
$data[$f] = ($v === null || $v === '' || $v === 'Not specified') ? null : $v;
|
||||
}
|
||||
|
||||
$car = [];
|
||||
foreach ($carRequired as $f) {
|
||||
$v = filterRequest($f);
|
||||
if ($v === null || $v === '') {
|
||||
jsonError("Missing required field: $f");
|
||||
exit;
|
||||
}
|
||||
$car[$f] = $v;
|
||||
}
|
||||
|
||||
$docUrls = [];
|
||||
foreach ($docUrlKeys as $k) {
|
||||
$v = filterRequest($k);
|
||||
if ($v === null || trim($v) === '') {
|
||||
jsonError("Missing signed URL: $k");
|
||||
exit;
|
||||
}
|
||||
$docUrls[$k] = trim($v);
|
||||
}
|
||||
|
||||
/* ========== 2) توليد id إذا مفقود + بناء email افتراضي إن لزم ========== */
|
||||
if (empty($data['id'])) {
|
||||
$data['id'] = 'DRV' . date('YmdHis') . random_int(1000, 9999);
|
||||
}
|
||||
if ($data['email'] === null) {
|
||||
$data['email'] = $data['phone'] . '@intaleqapp.com';
|
||||
}
|
||||
$driverID = $data['id'];
|
||||
|
||||
/* ========== 3) تشفير الحقول الحساسة ========== */
|
||||
$toEncryptDriver = [
|
||||
"phone","email","first_name","last_name","name_arabic","gender",
|
||||
"national_number","address","site","fullNameMaritial"
|
||||
];
|
||||
foreach ($toEncryptDriver as $f) {
|
||||
if (!empty($data[$f])) {
|
||||
$data[$f] = $encryptionHelper->encryptData($data[$f]);
|
||||
}
|
||||
}
|
||||
// حساسات السيارة
|
||||
$car['vin'] = $encryptionHelper->encryptData($car['vin']);
|
||||
$car['car_plate'] = $encryptionHelper->encryptData($car['car_plate']);
|
||||
$car['owner'] = $encryptionHelper->encryptData($car['owner']);
|
||||
|
||||
/* ========== 4) هَش كلمة المرور ========== */
|
||||
$pwdHashed = password_hash(filterRequest('password'), PASSWORD_DEFAULT);
|
||||
|
||||
/* ========== 5) بدء معاملة ========== */
|
||||
$con->beginTransaction();
|
||||
|
||||
/* ========== 6) فحص تكرار هاتف/ايميل (المشفّرين) ========== */
|
||||
$dup = $con->prepare("SELECT id FROM driver WHERE phone = :p OR email = :e");
|
||||
$dup->execute([':p' => $data['phone'], ':e' => $data['email']]);
|
||||
if ($dup->rowCount() > 0) {
|
||||
$con->rollBack();
|
||||
jsonError("Phone or email already registered.");
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ========== 7) إدراج السائق ========== */
|
||||
$sqlDriver = "
|
||||
INSERT INTO driver (
|
||||
id, phone, email, password, gender, license_type, national_number,
|
||||
name_arabic, issue_date, expiry_date, license_categories,
|
||||
address, licenseIssueDate, status, birthdate, site,
|
||||
first_name, last_name, accountBank, bankCode,
|
||||
employmentType, maritalStatus, fullNameMaritial, expirationDate,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
:id, :phone, :email, :pwd, :gender, :license_type, :national_number,
|
||||
:name_arabic, :issue_date, :expiry_date, :license_categories,
|
||||
:address, :licenseIssueDate, :status, :birthdate, :site,
|
||||
:first_name, :last_name, :accountBank, :bankCode,
|
||||
:employmentType, :maritalStatus, :fullNameMaritial, :expirationDate,
|
||||
NOW(), NOW()
|
||||
)
|
||||
";
|
||||
$insD = $con->prepare($sqlDriver);
|
||||
$okD = $insD->execute([
|
||||
':id' => $driverID,
|
||||
':phone' => $data['phone'],
|
||||
':email' => $data['email'],
|
||||
':pwd' => $pwdHashed,
|
||||
':gender' => $data['gender'],
|
||||
':license_type' => $data['license_type'],
|
||||
':national_number' => $data['national_number'],
|
||||
':name_arabic' => $data['name_arabic'],
|
||||
':issue_date' => $data['issue_date'],
|
||||
':expiry_date' => $data['expiry_date'],
|
||||
':license_categories'=> !empty($data['license_categories']) ? $data['license_categories'] : 'B',
|
||||
':address' => $data['address'],
|
||||
':licenseIssueDate' => $data['licenseIssueDate'],
|
||||
':status' => !empty($data['status']) ? $data['status'] : 'yet',
|
||||
':birthdate' => $data['birthdate'],
|
||||
':site' => $data['site'],
|
||||
':first_name' => $data['first_name'],
|
||||
':last_name' => $data['last_name'],
|
||||
':accountBank' => 'yet',
|
||||
':bankCode' => 'yet',
|
||||
':employmentType' => !empty($data['employmentType']) ? $data['employmentType'] : 'yet',
|
||||
':maritalStatus' => !empty($data['maritalStatus']) ? $data['maritalStatus'] : 'yet',
|
||||
':fullNameMaritial' => !empty($data['fullNameMaritial']) ? $data['fullNameMaritial'] : 'yet',
|
||||
':expirationDate' => !empty($data['expirationDate']) ? $data['expirationDate'] : 'yet',
|
||||
]);
|
||||
if (!$okD) { $con->rollBack(); jsonError("Failed to insert driver."); exit; }
|
||||
|
||||
/* ========== 8) إدراج السيارة ========== */
|
||||
$hasCar = $con->prepare("SELECT 1 FROM CarRegistration WHERE driverID = :d LIMIT 1");
|
||||
$hasCar->execute([':d' => $driverID]);
|
||||
$isDefault = $hasCar->rowCount() === 0 ? 1 : 0;
|
||||
|
||||
$sqlCar = "
|
||||
INSERT INTO CarRegistration (
|
||||
driverID, vin, car_plate, make, model, year, expiration_date,
|
||||
color, owner, color_hex, fuel, isDefault, created_at, status
|
||||
) VALUES (
|
||||
:driverID, :vin, :car_plate, :make, :model, :year, :expiration_date,
|
||||
:color, :owner, :color_hex, :fuel, :isDefault, NOW(), 'yet'
|
||||
)
|
||||
";
|
||||
$insC = $con->prepare($sqlCar);
|
||||
$okC = $insC->execute([
|
||||
':driverID' => $driverID,
|
||||
':vin' => $car['vin'],
|
||||
':car_plate' => $car['car_plate'],
|
||||
':make' => $car['make'],
|
||||
':model' => $car['model'],
|
||||
':year' => $car['year'],
|
||||
':expiration_date' => $car['expiration_date'],
|
||||
':color' => $car['color'],
|
||||
':owner' => $car['owner'],
|
||||
':color_hex' => $car['color_hex'],
|
||||
':fuel' => $car['fuel'],
|
||||
':isDefault' => $isDefault,
|
||||
]);
|
||||
if (!$okC) { $con->rollBack(); jsonError("Failed to insert car registration."); exit; }
|
||||
|
||||
$carRegID = $con->lastInsertId();
|
||||
|
||||
/* ========== 9) التحقّق من الروابط الموقّعة وحفظها ========== */
|
||||
|
||||
// دالة مساعدة تتحقّق من شكل الرابط وتستخرج doc_type/ext
|
||||
$validateSignedUrl = function(string $url) use ($allowedDocTypes, $allowedExts) {
|
||||
$parts = parse_url($url);
|
||||
if (!$parts || empty($parts['scheme']) || empty($parts['host']) || empty($parts['path'])) {
|
||||
throw new Exception("Invalid URL format.");
|
||||
}
|
||||
if (!in_array($parts['host'], $ALLOWED_SIGNED_HOSTS, true)) {
|
||||
throw new Exception("URL host not allowed: {$parts['host']}");
|
||||
}
|
||||
if (stripos($parts['path'], 'secure_image.php') === false) {
|
||||
throw new Exception("URL path not allowed.");
|
||||
}
|
||||
if (empty($parts['query'])) {
|
||||
throw new Exception("URL missing query string.");
|
||||
}
|
||||
parse_str($parts['query'], $q);
|
||||
foreach (['driver_id','doc_type','ext','expires','signature'] as $k) {
|
||||
if (empty($q[$k])) throw new Exception("URL missing param: $k");
|
||||
}
|
||||
if (!in_array($q['doc_type'], $allowedDocTypes, true)) {
|
||||
throw new Exception("Invalid doc_type in URL.");
|
||||
}
|
||||
if (!in_array(strtolower($q['ext']), $allowedExts, true)) {
|
||||
throw new Exception("Invalid ext in URL.");
|
||||
}
|
||||
return [
|
||||
'doc_type' => $q['doc_type'],
|
||||
'ext' => strtolower($q['ext']),
|
||||
// بإمكانك التحقق من driver_id = $driverID إذا تحب تربطهما
|
||||
'driver_id_in_url' => $q['driver_id'],
|
||||
];
|
||||
};
|
||||
|
||||
$docsToInsert = []; // [['doc_type'=>..., 'link'=>..., 'image_name'=>...], ...]
|
||||
foreach ($docUrlKeys as $k) {
|
||||
$link = $docUrls[$k];
|
||||
$meta = $validateSignedUrl($link);
|
||||
// image_name ليس ضروريًا هنا (الرابط موقّع إلى بوابة قراءة)، احفظ doc_type + link فقط
|
||||
$docsToInsert[] = [
|
||||
'doc_type' => $meta['doc_type'], // يجب أن يتطابق مع $k منطقيًا
|
||||
'link' => $link,
|
||||
'image_name' => $meta['doc_type'] . '.' . $meta['ext'], // اسماً رمزياً فقط
|
||||
];
|
||||
}
|
||||
|
||||
// إدراج في driver_documents
|
||||
// CREATE TABLE driver_documents (
|
||||
// id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
// driverID VARCHAR(64) NOT NULL,
|
||||
// doc_type VARCHAR(64) NOT NULL,
|
||||
// image_name VARCHAR(255) NULL,
|
||||
// link VARCHAR(1024) NOT NULL,
|
||||
// upload_date DATETIME NOT NULL,
|
||||
// INDEX(driverID)
|
||||
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
$insDoc = $con->prepare("
|
||||
INSERT INTO driver_documents (driverID, doc_type, image_name, link, upload_date)
|
||||
VALUES (:driverID, :doc_type, :image_name, :link, NOW())
|
||||
");
|
||||
foreach ($docsToInsert as $row) {
|
||||
$insDoc->execute([
|
||||
':driverID' => $driverID,
|
||||
':doc_type' => $row['doc_type'],
|
||||
':image_name' => $row['image_name'],
|
||||
':link' => $row['link'],
|
||||
]);
|
||||
}
|
||||
|
||||
/* ========== 10) إنهاء المعاملة ========== */
|
||||
$con->commit();
|
||||
|
||||
printSuccess([
|
||||
'driverID' => $driverID,
|
||||
'carRegID' => $carRegID,
|
||||
'documents' => [
|
||||
'driver_license_front_url' => $docUrls['driver_license_front_url'],
|
||||
'driver_license_back_url' => $docUrls['driver_license_back_url'],
|
||||
'car_license_front_url' => $docUrls['car_license_front_url'],
|
||||
'car_license_back_url' => $docUrls['car_license_back_url'],
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
if (isset($con) && $con->inTransaction()) { $con->rollBack(); }
|
||||
error_log("register_driver_and_car ERROR: " . $e->getMessage());
|
||||
jsonError("Server error");
|
||||
} catch (PDOException $e) {
|
||||
if (isset($con) && $con->inTransaction()) { $con->rollBack(); }
|
||||
error_log("register_driver_and_car PDO: " . $e->getMessage());
|
||||
jsonError("Database error.");
|
||||
}
|
||||
+270
-112
@@ -1,17 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_ai_engine.php
|
||||
* المحرك الرئيسي للذكاء الاصطناعي (AI Engine)
|
||||
* يتم تشغيله كـ Cron Job كل 30 دقيقة أو ساعة لتقليل الضغط على السيرفر.
|
||||
* cron_ai_engine.php - AI Pricing Engine v2
|
||||
*
|
||||
* يدمج 3 وحدات (Modules) ذكية:
|
||||
* 1. AI Pricing (Total Price Math): تعديل جدول kazan ليكون السعر الإجمالي أرخص بـ 6.5% من المنافس الأقوى بدقة.
|
||||
* 2. AI Dispatch: تحديد مناطق الذروة وتوجيه السائقين إليها.
|
||||
* 3. AI Retention: اصطياد الركاب الخاملين.
|
||||
* يقرأ معادلات المنافسين من Node.js Pricing Engine (competitor_secret_formulas)
|
||||
* ويطبّق تسعيراً ذكياً بناءً على النتائج الإحصائية بدلاً من الحسابات البدائية.
|
||||
*
|
||||
* لم يعُد هذا الملف يحسب بنفسه - بل يعتمد على التحليل الإحصائي من pricing-engine.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
@@ -20,67 +19,147 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Siro AI Engine...\n";
|
||||
|
||||
// نسبة الخصم المستهدفة (6.5% من إجمالي سعر الرحلة)
|
||||
$targetMargin = 0.065;
|
||||
echo "Starting Siro AI Engine v2 (Powered by Pricing Engine)...\n";
|
||||
|
||||
// ==========================================
|
||||
// 1. وحدة التسعير الديناميكي بناءً على السعر الإجمالي
|
||||
// 0. جدول افتراضات عمولة المنافسين (لميزة "أرباحك أعلى" للسائق)
|
||||
// ==========================================
|
||||
echo "1. Running Smart Pricing Module (Total Price Formula)...\n";
|
||||
$con->exec("
|
||||
CREATE TABLE IF NOT EXISTS `competitor_commission_assumptions` (
|
||||
`country_code` VARCHAR(5) NOT NULL,
|
||||
`competitor_name` VARCHAR(64) NOT NULL,
|
||||
`commission_pct` DECIMAL(5,2) NOT NULL,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`country_code`, `competitor_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
// زرع أولي بأرقام حقيقية زوّدنا فيها — INSERT IGNORE عشان ما نبهدل أي تعديل يدوي لاحق من الأدمن
|
||||
$commissionSeed = [
|
||||
// الأردن — كل التطبيقات المذكورة أعطتنا نفس النسبة 23%
|
||||
['JO', 'com.careem.ae', 23.0],
|
||||
['JO', 'com.ubercab', 23.0],
|
||||
['JO', 'com.jeeny.app', 23.0],
|
||||
['JO', 'com.taxif.passenger', 23.0],
|
||||
['JO', 'gogo', 23.0],
|
||||
['JO', 'petra_ride', 23.0],
|
||||
// سوريا
|
||||
['SY', 'yallago', 20.0],
|
||||
['SY', 'zaken', 17.0],
|
||||
['SY', 'tufaddal', 15.0],
|
||||
// مصر — استخدمنا الطرف الأدنى من كل مجال أعطانا إياه المستخدم (تحفظاً)
|
||||
['EG', 'uber', 25.0],
|
||||
['EG', 'careem', 20.0],
|
||||
['EG', 'didi', 15.0],
|
||||
['EG', 'indrive', 14.0],
|
||||
];
|
||||
$seedStmt = $con->prepare("
|
||||
INSERT IGNORE INTO competitor_commission_assumptions (country_code, competitor_name, commission_pct)
|
||||
VALUES (:cc, :name, :pct)
|
||||
");
|
||||
foreach ($commissionSeed as [$cc, $name, $pct]) {
|
||||
$seedStmt->execute([':cc' => $cc, ':name' => $name, ':pct' => $pct]);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. التسعير الديناميكي بناءً على المعادلات الإحصائية
|
||||
// ==========================================
|
||||
echo "1. Reading competitor formulas from Pricing Engine...\n";
|
||||
try {
|
||||
$sql = "SELECT country_code,
|
||||
AVG(price_per_km) as avg_price_km,
|
||||
MIN(price_per_km) as min_price_km
|
||||
FROM scraped_competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 HOUR)
|
||||
AND price_per_km > 0
|
||||
GROUP BY country_code";
|
||||
|
||||
// قراءة آخر المعادلات من التحليل الإحصائي
|
||||
$sql = "SELECT * FROM competitor_secret_formulas
|
||||
WHERE last_updated >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||||
ORDER BY last_updated DESC";
|
||||
$stmt = $con->query($sql);
|
||||
$competitorRates = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$formulas = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($competitorRates as $rate) {
|
||||
$country = $rate['country_code'];
|
||||
$countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||||
$countryName = $countryNameMap[$country] ?? null;
|
||||
|
||||
if ($countryName) {
|
||||
$avgKmPrice = (float)$rate['avg_price_km'];
|
||||
$minKmPrice = (float)$rate['min_price_km'];
|
||||
|
||||
// 1. حساب السعر الفعّال للكيلومتر بناءً على المتوسط والأرخص
|
||||
$effectiveCompetitorPrice = round($avgKmPrice * (1 - $targetMargin), 2);
|
||||
if ($effectiveCompetitorPrice > $minKmPrice) {
|
||||
$effectiveCompetitorPrice = round(($effectiveCompetitorPrice + $minKmPrice) / 2, 2);
|
||||
if (empty($formulas)) {
|
||||
echo " ⚠️ No recent formulas found. Run pricing-engine first.\n";
|
||||
} else {
|
||||
// تجميع المعادلات حسب الدولة
|
||||
$byCountry = [];
|
||||
foreach ($formulas as $f) {
|
||||
$country = $f['country_code'];
|
||||
if (!isset($byCountry[$country])) $byCountry[$country] = [];
|
||||
$byCountry[$country][] = $f;
|
||||
}
|
||||
|
||||
foreach ($byCountry as $country => $countryFormulas) {
|
||||
$countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||||
$countryName = $countryNameMap[$country] ?? $country;
|
||||
|
||||
// اختيار أفضل معادلة للتسعير حسب الأولوية:
|
||||
// 1. Economy tier (الأكثر تنافسية)
|
||||
// 2. Standard tier (إذا ما في Economy)
|
||||
// 3. أي tier ثاني بأعلى R²
|
||||
$tierPriority = ['economy', 'standard', 'premium'];
|
||||
$bestFormula = null;
|
||||
$bestTierIdx = 999;
|
||||
|
||||
foreach ($countryFormulas as $f) {
|
||||
$tier = $f['tier'] ?? 'standard';
|
||||
$tierIdx = array_search($tier, $tierPriority);
|
||||
if ($tierIdx === false) $tierIdx = 999;
|
||||
|
||||
$currentRSq = (float)($f['r_squared'] ?? 0);
|
||||
$currentKm = (float)($f['price_per_km'] ?? 0);
|
||||
|
||||
// أفضلية: Tier أعلى أولوية، ثم R² أعلى
|
||||
if ($currentKm > 0 && (
|
||||
$bestFormula === null ||
|
||||
$tierIdx < $bestTierIdx ||
|
||||
($tierIdx === $bestTierIdx && $currentRSq > (float)($bestFormula['r_squared'] ?? 0))
|
||||
)) {
|
||||
$bestTierIdx = $tierIdx;
|
||||
$bestFormula = $f;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. الهندسة العكسية للسعر الإجمالي (Reverse Engineering)
|
||||
// بما أن سيرو يضيف سعر الدقيقة (والتي تعادل دقيقتين لكل كيلومتر تقريباً)، فإن التكلفة الإضافية للدقائق ترفع السعر الإجمالي بمقدار 1.5x
|
||||
// لضمان أن يكون السعر النهائي أقل بـ 6%، نقسم الناتج على 1.5 ليمتص تكلفة الدقائق.
|
||||
$calculatedSpeedPrice = round($effectiveCompetitorPrice / 1.5, 3);
|
||||
|
||||
// 3. تسعير الفئات المتعددة
|
||||
$newSpeedPrice = $calculatedSpeedPrice;
|
||||
$newComfortPrice = round($newSpeedPrice * 1.30, 3);
|
||||
$newLadyPrice = round($newSpeedPrice * 1.10, 3);
|
||||
$newElectricPrice = round($newSpeedPrice * 1.20, 3);
|
||||
$newVanPrice = round($newSpeedPrice * 1.50, 3);
|
||||
$newDeliveryPrice = round($newSpeedPrice * 0.90, 3);
|
||||
$newMishwarVipPrice = round($newSpeedPrice * 1.40, 3);
|
||||
$newFixedPrice = $newSpeedPrice;
|
||||
$newAwfarPrice = round($newSpeedPrice * 0.85, 3);
|
||||
|
||||
|
||||
$bestTier = null;
|
||||
if ($bestFormula) {
|
||||
$bestKmRate = (float)$bestFormula['price_per_km'];
|
||||
$bestMinRate = (float)$bestFormula['price_per_min'];
|
||||
$bestBaseFare = (float)$bestFormula['base_fare'];
|
||||
$bestMinFare = (float)$bestFormula['min_fare'];
|
||||
$bestRSq = (float)($bestFormula['r_squared'] ?? 0);
|
||||
$bestTier = $bestFormula['tier'] ?? 'standard';
|
||||
}
|
||||
|
||||
if ($bestKmRate === null) {
|
||||
echo " ⚠️ No valid formula for $countryName. Skipping.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// تسعير Siro: أرخص بنسبة 6.5% من المنافس مع الحفاظ على هيكل التسعير
|
||||
$discountFactor = 1 - 0.065;
|
||||
$ourKmRate = round($bestKmRate * $discountFactor, 3);
|
||||
$ourMinRate = round($bestMinRate * $discountFactor, 3);
|
||||
$ourBase = round($bestBaseFare * $discountFactor, 3);
|
||||
|
||||
echo " 📊 $countryName: Using formula [tier=$bestTier] (R²=$bestRSq)\n";
|
||||
echo " Competitor: KM=$bestKmRate, MIN=$bestMinRate, Base=$bestBaseFare, MinFare=$bestMinFare\n";
|
||||
echo " Siro (6.5% less): KM=$ourKmRate, MIN=$ourMinRate, Base=$ourBase\n";
|
||||
|
||||
// تحديث جدول kazan
|
||||
$speedPrice = $ourKmRate;
|
||||
$comfortPrice = round($ourKmRate * 1.30, 3);
|
||||
$ladyPrice = round($ourKmRate * 1.10, 3);
|
||||
$electricPrice = round($ourKmRate * 1.20, 3);
|
||||
$vanPrice = round($ourKmRate * 1.50, 3);
|
||||
$deliveryPrice = round($ourKmRate * 0.90, 3);
|
||||
$mishwarVipPrice = round($ourKmRate * 1.40, 3);
|
||||
$fixedPrice = $speedPrice;
|
||||
$awfarPrice = round($ourKmRate * 0.85, 3);
|
||||
|
||||
// أسعار الدقائق
|
||||
if ($country === 'JO') {
|
||||
$newNormalMin = 0.05;
|
||||
$newPeakMin = 0.06;
|
||||
$newLateMin = 0.05;
|
||||
$normalMin = $ourMinRate > 0 ? $ourMinRate : 0.05;
|
||||
$peakMin = round($normalMin * 1.15, 3);
|
||||
$lateMin = $normalMin;
|
||||
} else {
|
||||
$newNormalMin = round($newSpeedPrice / 4, 3);
|
||||
$newPeakMin = round($newNormalMin * 1.15, 3);
|
||||
$newLateMin = round($newNormalMin * 1.25, 3);
|
||||
$normalMin = $ourMinRate > 0 ? $ourMinRate : round($speedPrice / 4, 3);
|
||||
$peakMin = round($normalMin * 1.15, 3);
|
||||
$lateMin = round($normalMin * 1.25, 3);
|
||||
}
|
||||
|
||||
$updateSql = "UPDATE kazan
|
||||
@@ -95,67 +174,119 @@ try {
|
||||
awfarPrice = :awfarPrice,
|
||||
normalMinPrice = :normalMin,
|
||||
peakMinPrice = :peakMin,
|
||||
lateMinPrice = :lateMin
|
||||
lateMinPrice = :lateMin,
|
||||
startPrice = :startPrice
|
||||
WHERE country = :countryName";
|
||||
|
||||
$upStmt = $con->prepare($updateSql);
|
||||
$upStmt->execute([
|
||||
':speedPrice' => $newSpeedPrice,
|
||||
':comfortPrice' => $newComfortPrice,
|
||||
':ladyPrice' => $newLadyPrice,
|
||||
':electricPrice' => $newElectricPrice,
|
||||
':vanPrice' => $newVanPrice,
|
||||
':deliveryPrice' => $newDeliveryPrice,
|
||||
':mishwarVipPrice' => $newMishwarVipPrice,
|
||||
':fixedPrice' => $newFixedPrice,
|
||||
':awfarPrice' => $newAwfarPrice,
|
||||
':normalMin' => $newNormalMin,
|
||||
':peakMin' => $newPeakMin,
|
||||
':lateMin' => $newLateMin,
|
||||
':speedPrice' => $speedPrice,
|
||||
':comfortPrice' => $comfortPrice,
|
||||
':ladyPrice' => $ladyPrice,
|
||||
':electricPrice' => $electricPrice,
|
||||
':vanPrice' => $vanPrice,
|
||||
':deliveryPrice' => $deliveryPrice,
|
||||
':mishwarVipPrice' => $mishwarVipPrice,
|
||||
':fixedPrice' => $fixedPrice,
|
||||
':awfarPrice' => $awfarPrice,
|
||||
':normalMin' => $normalMin,
|
||||
':peakMin' => $peakMin,
|
||||
':lateMin' => $lateMin,
|
||||
':startPrice' => $ourBase,
|
||||
':countryName' => $countryName
|
||||
]);
|
||||
|
||||
echo " -> Updated $countryName (Total Price Math applied): Speed=$newSpeedPrice JOD/KM, NormalMin=$newNormalMin JOD/MIN\n";
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo " Error in Pricing Module: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. وحدة توجيه السائقين (Demand Predictor)
|
||||
// ==========================================
|
||||
echo "2. Running Demand Predictor Module...\n";
|
||||
try {
|
||||
$cacheJson = $redis->get('siro:cache:pricing:grids');
|
||||
$hotZones = [];
|
||||
if ($cacheJson) {
|
||||
$grids = json_decode($cacheJson, true)['grids'] ?? [];
|
||||
foreach ($grids as $key => $data) {
|
||||
if (strpos($key, 'FALLBACK') !== false) continue;
|
||||
|
||||
if ($data['avg_price'] > 0) {
|
||||
$parts = explode('_', $key);
|
||||
if (count($parts) == 3) {
|
||||
$hotZones[] = [
|
||||
'latitude' => (float)$parts[1],
|
||||
'longitude' => (float)$parts[2],
|
||||
'avg_price' => $data['avg_price'],
|
||||
'top_competitor' => $data['top_competitor'],
|
||||
'timestamp' => time()
|
||||
];
|
||||
}
|
||||
echo " ✅ Updated $countryName pricing in kazan table.\n";
|
||||
|
||||
// ── تخزين معاملات المنافس الحالي بـ Redis لميزة "أرباحك أعلى" للسائق ──
|
||||
// get.php / add_ride.php يقرأوا هذا المفتاح فقط (بدون أي استعلام DB
|
||||
// وقت إنشاء الرحلة) عبر estimateDriverEarningsAdvantage()
|
||||
try {
|
||||
$commStmt = $con->prepare("
|
||||
SELECT commission_pct FROM competitor_commission_assumptions
|
||||
WHERE country_code = :cc AND competitor_name = :name LIMIT 1
|
||||
");
|
||||
$commStmt->execute([':cc' => $country, ':name' => $bestFormula['competitor_name']]);
|
||||
$commissionPct = (float)($commStmt->fetchColumn() ?: 20.0);
|
||||
|
||||
$driverComparisonData = [
|
||||
'competitor_name' => $bestFormula['competitor_name'],
|
||||
'base_fare' => $bestBaseFare,
|
||||
'km_rate' => $bestKmRate,
|
||||
'min_rate' => $bestMinRate,
|
||||
'commission_pct' => $commissionPct,
|
||||
'currency' => getCurrencyByCountry($countryName),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
// TTL أطول من فترة الجدولة (3 ساعات) بهامش أمان
|
||||
$redis->setex("pricing:driver_comparison:{$country}", 14400, json_encode($driverComparisonData));
|
||||
echo " 💰 Cached driver-comparison data for $countryName (commission assumed: {$commissionPct}%)\n";
|
||||
} catch (Exception $e) {
|
||||
echo " ⚠️ Failed to cache driver-comparison data: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
$redis->set('siro:cache:ai:hotzones', json_encode(['status' => 'success', 'data' => $hotZones], JSON_UNESCAPED_UNICODE));
|
||||
echo " -> Saved " . count($hotZones) . " Hot Zones to Redis for Driver Map Guidance.\n";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo " Error in Demand Predictor: " . $e->getMessage() . "\n";
|
||||
echo " ❌ Error in Pricing Module: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. وحدة استهداف الركاب الخاملين (Smart Retention)
|
||||
// 2. قراءة Surge Insights من الـ Pricing Engine
|
||||
// ==========================================
|
||||
echo "2. Reading surge insights from Pricing Engine...\n";
|
||||
try {
|
||||
$sql = "SELECT * FROM competitor_surge_insights
|
||||
WHERE detected_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR)
|
||||
ORDER BY surge_multiplier DESC
|
||||
LIMIT 20";
|
||||
$stmt = $con->query($sql);
|
||||
$surgeRecords = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($surgeRecords)) {
|
||||
$surgeByCountry = [];
|
||||
foreach ($surgeRecords as $sr) {
|
||||
$country = $sr['country_code'];
|
||||
if (!isset($surgeByCountry[$country])) {
|
||||
$surgeByCountry[$country] = [
|
||||
'avg_multiplier' => 0,
|
||||
'count' => 0,
|
||||
'peak_hours' => []
|
||||
];
|
||||
}
|
||||
$surgeByCountry[$country]['avg_multiplier'] += $sr['surge_multiplier'];
|
||||
$surgeByCountry[$country]['count']++;
|
||||
$surgeByCountry[$country]['peak_hours'][] = "{$sr['peak_start_hour']}:00-{$sr['peak_end_hour']}:00";
|
||||
}
|
||||
|
||||
foreach ($surgeByCountry as $country => $data) {
|
||||
$avgMult = round($data['avg_multiplier'] / $data['count'], 3);
|
||||
$peakHoursStr = implode(', ', array_unique($data['peak_hours']));
|
||||
|
||||
// تخزين ملخص إحصائي (object) على مفتاح خاص ومستقل — لا نكتب على
|
||||
// surge:opportunities / surge:opportunities:{country} لأنها خرائط
|
||||
// grid_id → multiplier يملأها ويقرأها نظام آخر بالكامل
|
||||
// (cron_surge_opportunity.php / get.php / get_surge_heatmap.php /
|
||||
// winback_hotspot_targets.php / cron_kazan_adjuster.php). كتابة
|
||||
// object مسطّح على نفس المفتاح كانت تبهدل تلك الخريطة كل 3 ساعات.
|
||||
$surgeData = [
|
||||
'avg_competitor_surge' => $avgMult,
|
||||
'suggested_multiplier' => round(1.0 + ($avgMult - 1.0) * 0.6, 3),
|
||||
'peak_hours' => $data['peak_hours'],
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
$redis->setex("pricing_engine:competitor_surge_summary:{$country}", 7200, json_encode($surgeData));
|
||||
|
||||
echo " ⚡ $country: Avg competitor surge = {$avgMult}x, peak hours: {$peakHoursStr}\n";
|
||||
}
|
||||
} else {
|
||||
echo " ℹ️ No recent surge insights found.\n";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo " ❌ Error in Surge Module: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. وحدة استهداف الركاب الخاملين (Smart Retention) - كما هي
|
||||
// ==========================================
|
||||
echo "3. Running Smart Retention Module...\n";
|
||||
try {
|
||||
@@ -164,15 +295,42 @@ try {
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 HOUR)
|
||||
GROUP BY source
|
||||
HAVING opens >= 3";
|
||||
|
||||
|
||||
$stmt = $con->query($sql);
|
||||
$idleRiders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$notifiedCount = count($idleRiders);
|
||||
echo " -> Identified $notifiedCount idle riders requiring push notifications.\n";
|
||||
echo " 📱 Identified $notifiedCount idle riders requiring push notifications.\n";
|
||||
} catch (Exception $e) {
|
||||
echo " Error in Smart Retention: " . $e->getMessage() . "\n";
|
||||
echo " ❌ Error in Smart Retention: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "AI Engine finished successfully.\n";
|
||||
?>
|
||||
// ==========================================
|
||||
// 4. Export AI Hotzones to Redis for Captains
|
||||
// ==========================================
|
||||
echo "4. Exporting AI Hotzones to Redis...\n";
|
||||
try {
|
||||
$sql = "SELECT latitude, longitude, competitor_name AS top_competitor, avg_ppk AS avg_price
|
||||
FROM competitor_surge_zones
|
||||
WHERE detected_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)
|
||||
AND surge_multiplier > 1.1";
|
||||
|
||||
$stmt = $con->query($sql);
|
||||
$zones = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($zones)) {
|
||||
$redisData = [
|
||||
'status' => 'success',
|
||||
'data' => $zones
|
||||
];
|
||||
$redis->setex('siro:cache:ai:hotzones', 3600, json_encode($redisData));
|
||||
echo " 🗺️ Exported " . count($zones) . " hot zones to Redis (siro:cache:ai:hotzones).\n";
|
||||
} else {
|
||||
// Clear if no surge to avoid stale data
|
||||
$redis->del('siro:cache:ai:hotzones');
|
||||
echo " ℹ️ No active hot zones to export.\n";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo " ❌ Error in Hotzones Export: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "AI Engine v2 finished successfully.\n";
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_dashboard_snapshot.php
|
||||
* يجمع بيانات التحليلات من Redis وقاعدة البيانات ويخزنها كملفات JSON مؤرشفة.
|
||||
*
|
||||
* الجدولة المقترحة:
|
||||
* - لقطة سريعة (ساعي): 0 * * * * php cron_dashboard_snapshot.php hourly
|
||||
* - لقطة يومية: 0 3 * * * php cron_dashboard_snapshot.php daily
|
||||
* - لقطة أسبوعية: 0 4 * * 1 php cron_dashboard_snapshot.php weekly
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
set_time_limit(180);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
$mode = $argv[1] ?? 'hourly';
|
||||
$today = date('Y-m-d');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$cacheBase = __DIR__ . '/../cache/analytics';
|
||||
$todayDir = "$cacheBase/$today";
|
||||
|
||||
if (!is_dir($todayDir)) {
|
||||
mkdir($todayDir, 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
$conRide = Database::get('ride');
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
die("[DashboardSnapshot] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[DashboardSnapshot] Mode: $mode | $now\n";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// الوظائف المساعدة
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function saveSnapshot(string $filename, array $data, string $dir): void {
|
||||
$data['_generated_at'] = date('Y-m-d H:i:s');
|
||||
$data['_mode'] = $GLOBALS['mode'];
|
||||
$path = "$dir/$filename";
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
echo " ✓ Saved: $path\n";
|
||||
}
|
||||
|
||||
function safeQuery(PDO $db, string $sql, array $params = []): array {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function safeScalar(PDO $db, string $sql, array $params = []) {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1. لقطة اللحظة الحالية (كل ساعة)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if (in_array($mode, ['hourly', 'daily', 'weekly'])) {
|
||||
echo "[Snapshot] Realtime stats...\n";
|
||||
|
||||
$activeRides = (int)safeScalar($conRide, "SELECT COUNT(*) FROM ride WHERE status IN ('wait','started','arrived')");
|
||||
$onlineDrivers = (int)safeScalar($con, "SELECT COUNT(*) FROM car_locations WHERE status = 'on'");
|
||||
$revenueToday = (float)safeScalar($conRide, "SELECT IFNULL(SUM(price_for_passenger),0) FROM ride WHERE status='Finished' AND DATE(created_at)=CURDATE()");
|
||||
$revenueYesterday = (float)safeScalar($conRide, "SELECT IFNULL(SUM(price_for_passenger),0) FROM ride WHERE status='Finished' AND DATE(created_at)=DATE_SUB(CURDATE(), INTERVAL 1 DAY)");
|
||||
$openComplaints = (int)safeScalar($con, "SELECT COUNT(*) FROM complaint WHERE statusComplaint='Open'");
|
||||
$expiringLicenses = (int)safeScalar($con, "SELECT COUNT(*) FROM driver WHERE expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 15 DAY)");
|
||||
|
||||
$hour = date('H');
|
||||
saveSnapshot("realtime_{$hour}.json", [
|
||||
'active_rides' => $activeRides,
|
||||
'online_drivers' => $onlineDrivers,
|
||||
'revenue_today' => $revenueToday,
|
||||
'revenue_yesterday' => $revenueYesterday,
|
||||
'open_complaints' => $openComplaints,
|
||||
'expiring_licenses' => $expiringLicenses,
|
||||
], $todayDir);
|
||||
|
||||
// ─── فجوة العرض والطلب (Supply-Demand Gap) ───
|
||||
echo "[Snapshot] Supply-Demand gap...\n";
|
||||
|
||||
$GRID = 0.01;
|
||||
$demandRaw = safeQuery($conRide, "
|
||||
SELECT
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',1) / $GRID) * $GRID AS lat,
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',-1) / $GRID) * $GRID AS lng,
|
||||
COUNT(*) AS demand
|
||||
FROM ride
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR)
|
||||
AND start_location IS NOT NULL AND start_location != ''
|
||||
GROUP BY lat, lng
|
||||
HAVING demand >= 1
|
||||
");
|
||||
|
||||
$supplyRaw = safeQuery($con, "
|
||||
SELECT
|
||||
ROUND(latitude / $GRID) * $GRID AS lat,
|
||||
ROUND(longitude / $GRID) * $GRID AS lng,
|
||||
COUNT(*) AS supply
|
||||
FROM car_locations
|
||||
WHERE status = 'on'
|
||||
AND latitude != 0 AND longitude != 0
|
||||
GROUP BY lat, lng
|
||||
");
|
||||
|
||||
$supplyMap = [];
|
||||
foreach ($supplyRaw as $s) {
|
||||
$key = $s['lat'] . '_' . $s['lng'];
|
||||
$supplyMap[$key] = (int)$s['supply'];
|
||||
}
|
||||
|
||||
$gapCells = [];
|
||||
foreach ($demandRaw as $d) {
|
||||
$key = $d['lat'] . '_' . $d['lng'];
|
||||
$supply = $supplyMap[$key] ?? 0;
|
||||
$demand = (int)$d['demand'];
|
||||
$gap = $demand - $supply;
|
||||
$gapCells[] = [
|
||||
'lat' => (float)$d['lat'],
|
||||
'lng' => (float)$d['lng'],
|
||||
'demand' => $demand,
|
||||
'supply' => $supply,
|
||||
'gap' => $gap,
|
||||
'ratio' => $supply > 0 ? round($demand / $supply, 2) : ($demand > 0 ? 99.0 : 0),
|
||||
];
|
||||
}
|
||||
|
||||
usort($gapCells, fn($a, $b) => $b['gap'] <=> $a['gap']);
|
||||
$gapCells = array_slice($gapCells, 0, 200);
|
||||
|
||||
saveSnapshot("supply_demand_gap.json", [
|
||||
'cells' => $gapCells,
|
||||
'total_demand_cells' => count($demandRaw),
|
||||
'total_supply_cells' => count($supplyRaw),
|
||||
], $todayDir);
|
||||
|
||||
// ─── كاش الخريطة الحرارية من Redis ───
|
||||
echo "[Snapshot] Heatmap cache from Redis...\n";
|
||||
$heatmapRedis = $redis ? $redis->get('siro:cache:heatmap:data') : null;
|
||||
if ($heatmapRedis) {
|
||||
$heatmapData = json_decode($heatmapRedis, true);
|
||||
saveSnapshot("heatmap.json", $heatmapData ?: [], $todayDir);
|
||||
}
|
||||
|
||||
// ─── كاش التسعير من Redis ───
|
||||
echo "[Snapshot] Pricing cache from Redis...\n";
|
||||
$pricingRedis = $redis ? $redis->get('siro:cache:pricing:grids') : null;
|
||||
if ($pricingRedis) {
|
||||
$pricingData = json_decode($pricingRedis, true);
|
||||
saveSnapshot("pricing_grids.json", $pricingData ?: [], $todayDir);
|
||||
}
|
||||
|
||||
// ─── بيانات الكرون Predictive Demand من Redis ───
|
||||
$predictiveRedis = $redis ? $redis->get('siro:predictive_demand:latest') : null;
|
||||
if ($predictiveRedis) {
|
||||
saveSnapshot("predictive_demand.json", json_decode($predictiveRedis, true) ?: [], $todayDir);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. لقطة يومية (تحليلات ثقيلة)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if (in_array($mode, ['daily', 'weekly'])) {
|
||||
echo "[Snapshot] Daily analytics...\n";
|
||||
|
||||
// ─── الإيرادات اليومية (30 يوم) ───
|
||||
$revenueDaily = safeQuery($conRide, "
|
||||
SELECT DATE(created_at) as date,
|
||||
SUM(price) as total_revenue,
|
||||
SUM(price - price_for_driver) as company_profit,
|
||||
COUNT(*) as total_rides,
|
||||
AVG(price) as avg_fare
|
||||
FROM ride
|
||||
WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
");
|
||||
saveSnapshot("revenue_30d.json", ['daily' => $revenueDaily], $todayDir);
|
||||
|
||||
// ─── النمو (ركاب + كباتن) ───
|
||||
$passengerGrowth = safeQuery($con, "
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM passengers
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at) ORDER BY date ASC
|
||||
");
|
||||
$driverGrowth = safeQuery($con, "
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM driver
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at) ORDER BY date ASC
|
||||
");
|
||||
$totalPassengers = (int)safeScalar($con, "SELECT COUNT(*) FROM passengers");
|
||||
$totalDrivers = (int)safeScalar($con, "SELECT COUNT(*) FROM driver");
|
||||
|
||||
saveSnapshot("growth_30d.json", [
|
||||
'passengers' => $passengerGrowth,
|
||||
'drivers' => $driverGrowth,
|
||||
'totals' => ['passengers' => $totalPassengers, 'drivers' => $totalDrivers],
|
||||
], $todayDir);
|
||||
|
||||
// ─── توزيع الرحلات حسب الساعة (لاكتشاف الأنماط) ───
|
||||
$hourlyPattern = safeQuery($conRide, "
|
||||
SELECT HOUR(created_at) as hour, COUNT(*) as rides,
|
||||
AVG(price) as avg_price
|
||||
FROM ride
|
||||
WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY HOUR(created_at) ORDER BY hour
|
||||
");
|
||||
saveSnapshot("hourly_pattern.json", ['hours' => $hourlyPattern], $todayDir);
|
||||
|
||||
// ─── توزيع حالات الرحلات (Funnel) ───
|
||||
$rideFunnel = safeQuery($conRide, "
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM ride
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY status
|
||||
");
|
||||
saveSnapshot("ride_funnel.json", ['statuses' => $rideFunnel], $todayDir);
|
||||
|
||||
// ─── أسعار المنافسين (تاريخي ٢٤ ساعة) ───
|
||||
$competitorHourly = safeQuery($con, "
|
||||
SELECT DATE_FORMAT(created_at,'%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
competitor_name,
|
||||
AVG(price_per_km) AS avg_price,
|
||||
COUNT(*) AS samples
|
||||
FROM scraped_competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||||
GROUP BY hour_bucket, competitor_name
|
||||
ORDER BY hour_bucket ASC
|
||||
");
|
||||
saveSnapshot("competitor_prices_24h.json", ['hourly' => $competitorHourly], $todayDir);
|
||||
|
||||
// ─── صحة السوق (market health) ───
|
||||
$countries = ['JO', 'SY', 'EG', 'IQ'];
|
||||
$marketHealth = [];
|
||||
foreach ($countries as $cc) {
|
||||
$rows = safeQuery($con, "
|
||||
SELECT report_date, average_pci, market_share_percent, total_anomalies
|
||||
FROM market_health_reports
|
||||
WHERE country_code = :cc
|
||||
ORDER BY report_date DESC LIMIT 12
|
||||
", [':cc' => $cc]);
|
||||
if (!empty($rows)) {
|
||||
$marketHealth[$cc] = array_reverse($rows);
|
||||
}
|
||||
}
|
||||
saveSnapshot("market_health.json", ['countries' => $marketHealth], $todayDir);
|
||||
|
||||
// ─── شكاوى مفتوحة ───
|
||||
$complaints = safeQuery($con, "
|
||||
SELECT complaint_type, COUNT(*) as count
|
||||
FROM complaint
|
||||
WHERE statusComplaint = 'Open'
|
||||
GROUP BY complaint_type
|
||||
ORDER BY count DESC
|
||||
");
|
||||
saveSnapshot("complaints_open.json", ['by_type' => $complaints], $todayDir);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 3. لقطة أسبوعية (مقارنات وتجميعات)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if ($mode === 'weekly') {
|
||||
echo "[Snapshot] Weekly deep analytics...\n";
|
||||
|
||||
// ─── مقارنة أسبوع بأسبوع ───
|
||||
$weeklyComparison = safeQuery($conRide, "
|
||||
SELECT
|
||||
YEARWEEK(created_at, 1) as yw,
|
||||
MIN(DATE(created_at)) as week_start,
|
||||
COUNT(*) as rides,
|
||||
SUM(price) as revenue,
|
||||
SUM(price - price_for_driver) as profit,
|
||||
COUNT(DISTINCT driver_id) as active_drivers,
|
||||
COUNT(DISTINCT passenger_id) as active_passengers
|
||||
FROM ride
|
||||
WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 12 WEEK)
|
||||
GROUP BY yw ORDER BY yw ASC
|
||||
");
|
||||
saveSnapshot("weekly_comparison.json", ['weeks' => $weeklyComparison], $todayDir);
|
||||
|
||||
// ─── أعلى مناطق (أكثر 20 خلية نشاطاً) ───
|
||||
$topZones = safeQuery($conRide, "
|
||||
SELECT
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',1) / 0.01) * 0.01 AS lat,
|
||||
ROUND(SUBSTRING_INDEX(start_location,',',-1) / 0.01) * 0.01 AS lng,
|
||||
COUNT(*) AS rides,
|
||||
AVG(price) AS avg_price
|
||||
FROM ride
|
||||
WHERE status = 'Finished'
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL 4 WEEK)
|
||||
AND start_location IS NOT NULL AND start_location != ''
|
||||
GROUP BY lat, lng
|
||||
ORDER BY rides DESC
|
||||
LIMIT 20
|
||||
");
|
||||
saveSnapshot("top_zones.json", ['zones' => $topZones], $todayDir);
|
||||
|
||||
// ─── احتفاظ الركاب (Retention) ───
|
||||
$retention = safeQuery($conRide, "
|
||||
SELECT
|
||||
weeks_since_signup,
|
||||
COUNT(DISTINCT passenger_id) as active_passengers
|
||||
FROM (
|
||||
SELECT r.passenger_id,
|
||||
FLOOR(DATEDIFF(r.created_at, p.created_at) / 7) as weeks_since_signup
|
||||
FROM ride r
|
||||
JOIN passengers p ON r.passenger_id = p.id
|
||||
WHERE r.status = 'Finished'
|
||||
AND r.created_at >= DATE_SUB(CURDATE(), INTERVAL 12 WEEK)
|
||||
) sub
|
||||
GROUP BY weeks_since_signup
|
||||
ORDER BY weeks_since_signup
|
||||
");
|
||||
saveSnapshot("retention_cohort.json", ['cohorts' => $retention], $todayDir);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 4. إنشاء manifest يسهّل على الداشبورد معرفة الملفات المتاحة
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
$files = glob("$todayDir/*.json");
|
||||
$manifest = [];
|
||||
foreach ($files as $f) {
|
||||
$name = basename($f, '.json');
|
||||
if ($name === 'manifest') continue;
|
||||
$manifest[$name] = [
|
||||
'file' => basename($f),
|
||||
'size' => filesize($f),
|
||||
'updated' => date('Y-m-d H:i:s', filemtime($f)),
|
||||
];
|
||||
}
|
||||
saveSnapshot("manifest.json", ['snapshots' => $manifest, 'date' => $today], $todayDir);
|
||||
|
||||
// ─── فهرس الأيام المتاحة (لتسهيل التصفح التاريخي) ───
|
||||
$days = array_map('basename', glob("$cacheBase/20*", GLOB_ONLYDIR));
|
||||
rsort($days);
|
||||
file_put_contents("$cacheBase/index.json", json_encode([
|
||||
'available_dates' => $days,
|
||||
'latest' => $days[0] ?? $today,
|
||||
'updated' => $now,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
echo "[DashboardSnapshot] Done! Files in $todayDir\n";
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_gemini_advisor.php
|
||||
* يعمل هذا الملف كـ Cron Job (يفضل أسبوعياً أو كل 3 أيام)
|
||||
* وظيفته: أخذ معادلات المنافسين المكتشفة، وإرسالها إلى جيميناي لاستخراج تقرير تسويقي متقدم
|
||||
* ثم حفظ التقرير في قاعدة البيانات ليراه مدير النظام في لوحة الإدارة.
|
||||
* cron_gemini_advisor.php - Gemini Market Advisor
|
||||
*
|
||||
* يأخذ المعادلات المُكتشَفة من Pricing Engine (مع الفئات والمؤشرات الإحصائية)
|
||||
* ويُرسلها إلى Gemini لتحليل استراتيجي - لم يعُد التحليل الإحصائي من مسؤوليته.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
@@ -16,7 +16,7 @@ try {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Gemini Market Advisor Engine...\n";
|
||||
echo "Starting Gemini Market Advisor Engine v2...\n";
|
||||
|
||||
// 1. إنشاء جدول الإحصائيات الذكية إذا لم يكن موجوداً
|
||||
$sqlInit = "
|
||||
@@ -28,30 +28,37 @@ CREATE TABLE IF NOT EXISTS `gemini_market_insights` (
|
||||
";
|
||||
$con->exec($sqlInit);
|
||||
|
||||
// 2. سحب آخر المعادلات المكتشفة
|
||||
$stmt = $con->query("SELECT * FROM competitor_secret_formulas");
|
||||
// 2. سحب المعادلات المُطوّرة (مع الفئات والمؤشرات)
|
||||
$stmt = $con->query("
|
||||
SELECT csf.*,
|
||||
(SELECT surge_multiplier FROM competitor_surge_insights
|
||||
WHERE competitor_name = csf.competitor_name
|
||||
AND country_code = csf.country_code
|
||||
ORDER BY detected_at DESC LIMIT 1) as recent_surge
|
||||
FROM competitor_secret_formulas csf
|
||||
WHERE csf.tier IS NOT NULL
|
||||
ORDER BY csf.country_code, csf.competitor_name, csf.tier
|
||||
");
|
||||
$formulas = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($formulas)) {
|
||||
echo "No formulas found to analyze. Run ai_formula_solver.php first.\n";
|
||||
echo "No enriched formulas found. Run pricing-engine first.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. تمرير البيانات إلى Gemini
|
||||
// 3. تمرير البيانات المُثراة إلى Gemini
|
||||
$geminiService = new SiroGeminiService();
|
||||
echo "Sending data to Gemini AI for strategic analysis...\n";
|
||||
echo "Sending enriched data to Gemini AI for strategic analysis...\n";
|
||||
|
||||
$result = $geminiService->analyzeCompetitorFormulas($formulas);
|
||||
|
||||
if ($result && $result['status'] === 'success') {
|
||||
$htmlReport = $result['html_report'];
|
||||
|
||||
// 4. حفظ التقرير في قاعدة البيانات
|
||||
$stmtInsert = $con->prepare("INSERT INTO gemini_market_insights (insight_html) VALUES (:html)");
|
||||
$stmtInsert->execute([':html' => $htmlReport]);
|
||||
|
||||
echo "Gemini analysis saved successfully! Admin can now view the strategic report.\n";
|
||||
echo "Gemini analysis saved successfully! Admin can view the strategic report.\n";
|
||||
} else {
|
||||
echo "Failed to get analysis from Gemini. Check API keys and logs.\n";
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_pricing_stability_engine.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* محرك الثبات والاستقرار — وضع مراقبة (Shadow Mode) فقط.
|
||||
*
|
||||
* ما بيلمس جدول kazan أبداً. بيقارن معامل سعر الكيلو الحالي لكل
|
||||
* منافس/دولة (من competitor_secret_formulas) مع الوسيط المرجعي
|
||||
* لآخر 7 أيام (من competitor_formula_history)، ويسجّل تصنيفه
|
||||
* والإجراء المقترح بجدول pricing_stability_log لمراجعته يدوياً.
|
||||
*
|
||||
* التصنيف:
|
||||
* - drift <= -15% → temporary_promo (برومو مؤقت — لا يُغيَّر السعر الأساسي)
|
||||
* - |drift| > 5% مستمر عبر آخر 48 ساعة → sustained_change (تغيير حقيقي مقترح)
|
||||
* - غير هيك → stable
|
||||
*
|
||||
* فترة سكون: أي تغيير "حقيقي" مقترح يُحتجز إذا كان آخر تغيير حقيقي
|
||||
* (would_apply=1 من نوع sustained_change) بآخر 3 أيام.
|
||||
*
|
||||
* جدولة مقترحة: كل 3 ساعات، بالتوازي مع cron_ai_engine.php.
|
||||
*
|
||||
* ملاحظة: competitor_formula_history جدول جديد — بيضل فاضي/قليل
|
||||
* البيانات لأول أسبوع بعد النشر لحد ما يتراكم تاريخ كافي لحساب
|
||||
* وسيط 7 أيام موثوق. هذا متوقع وطبيعي، مو خطأ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Starting Pricing Stability Engine (Shadow Mode)...\n";
|
||||
|
||||
// ==========================================
|
||||
// 0. إنشاء الجداول إذا لم تكن موجودة
|
||||
// ==========================================
|
||||
$con->exec("
|
||||
CREATE TABLE IF NOT EXISTS `pricing_stability_log` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`country_code` VARCHAR(5) NOT NULL,
|
||||
`tier` VARCHAR(20) NOT NULL DEFAULT 'economy',
|
||||
`reference_median_km_rate` DECIMAL(10,4) NOT NULL,
|
||||
`current_km_rate` DECIMAL(10,4) NOT NULL,
|
||||
`drift_pct` DECIMAL(6,2) NOT NULL,
|
||||
`classification` ENUM('stable','temporary_promo','sustained_change') NOT NULL,
|
||||
`suggested_action` TEXT NULL,
|
||||
`would_apply` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`hold_reason` VARCHAR(100) NULL,
|
||||
`evaluated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_country_time` (`country_code`, `evaluated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
$con->exec("
|
||||
CREATE TABLE IF NOT EXISTS `country_pricing_floor` (
|
||||
`country_code` VARCHAR(5) NOT NULL,
|
||||
`min_km_rate` DECIMAL(10,4) NOT NULL,
|
||||
`min_base_fare` DECIMAL(10,4) NOT NULL DEFAULT 0,
|
||||
`updated_by` VARCHAR(100) NULL,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`country_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
// ==========================================
|
||||
// إعدادات
|
||||
// ==========================================
|
||||
$countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq'];
|
||||
$tierPriority = ['economy', 'standard', 'premium'];
|
||||
$promoDropPct = -15.0; // انخفاض مفاجئ أكبر من هذا = برومو مؤقت
|
||||
$driftThreshold = 5.0; // انحراف أكبر من هذا (مطلق) يستاهل الانتباه
|
||||
$sustainedWindow = 48; // ساعة — لازم الانحراف يستمر عبرها
|
||||
$holdDays = 3; // فترة سكون بعد أي تغيير حقيقي مقترح
|
||||
$maxStepPct = 3.0; // أقصى تغيير مسموح بالمرة الوحدة
|
||||
$discountFactor = 1 - 0.065;
|
||||
|
||||
/** وسيط بسيط لمصفوفة أرقام */
|
||||
function median_of(array $nums): ?float {
|
||||
$nums = array_values(array_filter($nums, fn($n) => $n !== null && $n > 0));
|
||||
if (empty($nums)) return null;
|
||||
sort($nums);
|
||||
$count = count($nums);
|
||||
$mid = intdiv($count, 2);
|
||||
if ($count % 2 === 0) {
|
||||
return ($nums[$mid - 1] + $nums[$mid]) / 2;
|
||||
}
|
||||
return $nums[$mid];
|
||||
}
|
||||
|
||||
foreach ($countryNameMap as $cc => $countryName) {
|
||||
echo "\n[$cc] Evaluating...\n";
|
||||
|
||||
// ── 1. سعر Siro الحالي بجدول kazan (للأرضية وحساب التغيير المقترح) ──
|
||||
$stmtKazan = $con->prepare("SELECT speedPrice, startPrice FROM kazan WHERE country = :country LIMIT 1");
|
||||
$stmtKazan->execute([':country' => $countryName]);
|
||||
$kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC);
|
||||
$currentSiroKmRate = $kazanRow ? (float)$kazanRow['speedPrice'] : 0.0;
|
||||
|
||||
if ($currentSiroKmRate <= 0) {
|
||||
echo " ⚠️ No kazan row for $countryName. Skipping.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 2. أرضية ربحية — زرعها تلقائياً أول مرة بـ85% من السعر الحالي ──
|
||||
$stmtFloor = $con->prepare("SELECT min_km_rate FROM country_pricing_floor WHERE country_code = :cc");
|
||||
$stmtFloor->execute([':cc' => $cc]);
|
||||
$floorRow = $stmtFloor->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$floorRow) {
|
||||
$seedFloor = round($currentSiroKmRate * 0.85, 4);
|
||||
$insFloor = $con->prepare("
|
||||
INSERT INTO country_pricing_floor (country_code, min_km_rate, min_base_fare, updated_by)
|
||||
VALUES (:cc, :floor, 0, 'auto_seed_default')
|
||||
");
|
||||
$insFloor->execute([':cc' => $cc, ':floor' => $seedFloor]);
|
||||
$floorKmRate = $seedFloor;
|
||||
echo " 🌱 Seeded default profit floor: $seedFloor (85% of current price)\n";
|
||||
} else {
|
||||
$floorKmRate = (float)$floorRow['min_km_rate'];
|
||||
}
|
||||
|
||||
// ── 3. اختيار أفضل فئة متوفرة (economy أولاً) من المعادلات الحالية ──
|
||||
$currentTier = null;
|
||||
$currentKmRate = null;
|
||||
foreach ($tierPriority as $tier) {
|
||||
$stmtCur = $con->prepare("
|
||||
SELECT price_per_km FROM competitor_secret_formulas
|
||||
WHERE country_code = :cc AND tier = :tier
|
||||
ORDER BY last_updated DESC LIMIT 1
|
||||
");
|
||||
$stmtCur->execute([':cc' => $cc, ':tier' => $tier]);
|
||||
$row = $stmtCur->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row && (float)$row['price_per_km'] > 0) {
|
||||
$currentTier = $tier;
|
||||
$currentKmRate = (float)$row['price_per_km'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentKmRate === null) {
|
||||
echo " ⚠️ No current competitor formula for $countryName. Skipping.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 4. الوسيط المرجعي لآخر 7 أيام من سجل التاريخ ──
|
||||
$stmtHist = $con->prepare("
|
||||
SELECT price_per_km FROM competitor_formula_history
|
||||
WHERE country_code = :cc AND tier = :tier
|
||||
AND snapshotted_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
ORDER BY snapshotted_at ASC
|
||||
");
|
||||
$stmtHist->execute([':cc' => $cc, ':tier' => $currentTier]);
|
||||
$historyRates = array_map('floatval', $stmtHist->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
$referenceMedian = median_of($historyRates);
|
||||
|
||||
if ($referenceMedian === null) {
|
||||
echo " ℹ️ Not enough history yet for $countryName [tier=$currentTier] — building up (need ~7 days). Skipping evaluation.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 5. حساب الانحراف ──
|
||||
$driftPct = round((($currentKmRate - $referenceMedian) / $referenceMedian) * 100, 2);
|
||||
|
||||
// ── 6. هل الانحراف مستمر آخر 48 ساعة؟ ──
|
||||
$stmtRecent = $con->prepare("
|
||||
SELECT price_per_km FROM competitor_formula_history
|
||||
WHERE country_code = :cc AND tier = :tier
|
||||
AND snapshotted_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)
|
||||
ORDER BY snapshotted_at ASC
|
||||
");
|
||||
$stmtRecent->execute([':cc' => $cc, ':tier' => $currentTier, ':hours' => $sustainedWindow]);
|
||||
$recentRates = array_map('floatval', $stmtRecent->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
$isSustained = false;
|
||||
if (count($recentRates) >= 2) {
|
||||
$sameDirectionCount = 0;
|
||||
foreach ($recentRates as $r) {
|
||||
$rDrift = (($r - $referenceMedian) / $referenceMedian) * 100;
|
||||
$sameSign = ($driftPct >= 0 && $rDrift >= 0) || ($driftPct < 0 && $rDrift < 0);
|
||||
if ($sameSign && abs($rDrift) > $driftThreshold) {
|
||||
$sameDirectionCount++;
|
||||
}
|
||||
}
|
||||
// كل اللقطات بآخر 48 ساعة (وليس عينة واحدة) تؤكد نفس الاتجاه
|
||||
$isSustained = ($sameDirectionCount === count($recentRates));
|
||||
}
|
||||
|
||||
// ── 7. التصنيف ──
|
||||
if ($driftPct <= $promoDropPct) {
|
||||
$classification = 'temporary_promo';
|
||||
} elseif (abs($driftPct) > $driftThreshold && $isSustained) {
|
||||
$classification = 'sustained_change';
|
||||
} else {
|
||||
$classification = 'stable';
|
||||
}
|
||||
|
||||
// ── 8. الإجراء المقترح + would_apply + فترة السكون ──
|
||||
$wouldApply = false;
|
||||
$holdReason = null;
|
||||
$suggestedAction = 'لا إجراء — السعر ثابت';
|
||||
|
||||
if ($classification === 'temporary_promo') {
|
||||
$wouldApply = true;
|
||||
$suggestedAction = "منافس نازل مؤقتاً بنسبة " . abs($driftPct) . "% تحت المرجع — الرد المقترح: كود خصم مؤقت عبر محرك التسويق، بدون لمس السعر الأساسي";
|
||||
} elseif ($classification === 'sustained_change') {
|
||||
// تحقق من فترة السكون بالاعتماد على سجلّنا الخاص فقط (shadow-only)
|
||||
$stmtLastApply = $con->prepare("
|
||||
SELECT evaluated_at FROM pricing_stability_log
|
||||
WHERE country_code = :cc AND classification = 'sustained_change' AND would_apply = 1
|
||||
ORDER BY evaluated_at DESC LIMIT 1
|
||||
");
|
||||
$stmtLastApply->execute([':cc' => $cc]);
|
||||
$lastApply = $stmtLastApply->fetchColumn();
|
||||
|
||||
$inHoldPeriod = false;
|
||||
if ($lastApply) {
|
||||
$daysSince = (time() - strtotime($lastApply)) / 86400;
|
||||
$inHoldPeriod = $daysSince < $holdDays;
|
||||
}
|
||||
|
||||
// السعر المستهدف: نفس منطق الخصم 6.5% المعتمد، بحد أقصى 3% تغيير بالمرة، ومقيّد بالأرضية
|
||||
$rawTarget = $currentKmRate * $discountFactor;
|
||||
$maxUp = $currentSiroKmRate * (1 + $maxStepPct / 100);
|
||||
$maxDown = $currentSiroKmRate * (1 - $maxStepPct / 100);
|
||||
$clampedTarget = max($maxDown, min($maxUp, $rawTarget));
|
||||
$clampedTarget = max($clampedTarget, $floorKmRate);
|
||||
$clampedTarget = round($clampedTarget, 4);
|
||||
|
||||
if ($inHoldPeriod) {
|
||||
$wouldApply = false;
|
||||
$holdReason = 'hold_period_active';
|
||||
$suggestedAction = "تغيير مستمر ({$driftPct}%) — بس بفترة سكون (آخر تغيير قبل أقل من $holdDays أيام). لو مفعّل: من $currentSiroKmRate إلى $clampedTarget";
|
||||
} else {
|
||||
$wouldApply = true;
|
||||
$suggestedAction = "تغيير مستمر ({$driftPct}%) — سعر كيلو مقترح: من $currentSiroKmRate إلى $clampedTarget (مقيّد بـ{$maxStepPct}% والأرضية $floorKmRate)";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9. تسجيل النتيجة ──
|
||||
$insLog = $con->prepare("
|
||||
INSERT INTO pricing_stability_log
|
||||
(country_code, tier, reference_median_km_rate, current_km_rate, drift_pct, classification, suggested_action, would_apply, hold_reason)
|
||||
VALUES
|
||||
(:cc, :tier, :ref, :cur, :drift, :cls, :action, :apply, :hold)
|
||||
");
|
||||
$insLog->execute([
|
||||
':cc' => $cc,
|
||||
':tier' => $currentTier,
|
||||
':ref' => $referenceMedian,
|
||||
':cur' => $currentKmRate,
|
||||
':drift' => $driftPct,
|
||||
':cls' => $classification,
|
||||
':action' => $suggestedAction,
|
||||
':apply' => $wouldApply ? 1 : 0,
|
||||
':hold' => $holdReason,
|
||||
]);
|
||||
|
||||
$icon = $classification === 'stable' ? '✅' : ($classification === 'temporary_promo' ? '🎯' : '⚠️');
|
||||
echo " $icon [tier=$currentTier] drift={$driftPct}% → $classification\n";
|
||||
echo " $suggestedAction\n";
|
||||
}
|
||||
|
||||
echo "\nPricing Stability Engine finished (shadow mode — kazan not touched).\n";
|
||||
@@ -62,35 +62,13 @@ echo "Silent Push triggered for $sentCount inactive users.\n";
|
||||
* Helper to send Silent FCM
|
||||
*/
|
||||
function sendSilentFcmNotification($token, $data) {
|
||||
// Basic curl request to FCM API for silent push
|
||||
$url = 'https://fcm.googleapis.com/fcm/send';
|
||||
|
||||
// Replace with your actual server key
|
||||
$serverKey = getenv('FCM_SERVER_KEY') ?: 'YOUR_LEGACY_SERVER_KEY';
|
||||
|
||||
$fields = [
|
||||
'to' => $token,
|
||||
'data' => $data,
|
||||
'content_available' => true, // Required for iOS to wake up in background
|
||||
'priority' => 'high' // Sometimes required to wake up Android
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'Authorization: key=' . $serverKey,
|
||||
'Content-Type: application/json'
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($fields));
|
||||
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $result;
|
||||
if (function_exists('sendFCM_Internal')) {
|
||||
return sendFCM_Internal($token, '', '', $data, 'SilentPush', false, 'silent');
|
||||
}
|
||||
if (!class_exists('FcmService')) {
|
||||
require_once __DIR__ . '/../core/Services/FcmService.php';
|
||||
}
|
||||
$fcm = new FcmService();
|
||||
return $fcm->send($token, '', '', $data, 'SilentPush', 'silent');
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -89,6 +89,7 @@ try {
|
||||
// 3. تحديد فرص الذروة
|
||||
$opportunities = [];
|
||||
$gridSurgeZones = [];
|
||||
$gridSurgeZonesByCountry = [];
|
||||
|
||||
foreach ($zones as $key => &$zone) {
|
||||
$zone['opportunity'] = (
|
||||
@@ -118,6 +119,15 @@ try {
|
||||
|
||||
// حفظ المنطقة مع المضاعف المقترح في Redis (للقراءة من get.php بعدين)
|
||||
$gridSurgeZones[$key] = $suggestedMultiplier;
|
||||
|
||||
// نفس البيانات مجمّعة حسب الدولة — يقرأها get_surge_heatmap.php،
|
||||
// winback_hotspot_targets.php، و cron_kazan_adjuster.php كخريطة
|
||||
// grid_id → multiplier لكل دولة على حدة
|
||||
$countryCode = $zone['country_code'];
|
||||
if (!isset($gridSurgeZonesByCountry[$countryCode])) {
|
||||
$gridSurgeZonesByCountry[$countryCode] = [];
|
||||
}
|
||||
$gridSurgeZonesByCountry[$countryCode][$key] = $suggestedMultiplier;
|
||||
}
|
||||
}
|
||||
unset($zone);
|
||||
@@ -137,6 +147,21 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. تخزين/مسح النسخة المقسّمة حسب الدولة — نفس مفتاح surge:opportunities:{CC}
|
||||
// اللي يقرأه get_surge_heatmap.php و winback_hotspot_targets.php و cron_kazan_adjuster.php
|
||||
if (isset($redis) && $redis !== null) {
|
||||
$knownCountries = ['SY', 'JO', 'EG', 'IQ'];
|
||||
foreach ($knownCountries as $cc) {
|
||||
$countryKey = "surge:opportunities:{$cc}";
|
||||
if (!empty($gridSurgeZonesByCountry[$cc])) {
|
||||
$redis->setex($countryKey, 600, json_encode($gridSurgeZonesByCountry[$cc]));
|
||||
echo "[".date('Y-m-d H:i:s')."] [$cc] Stored ".count($gridSurgeZonesByCountry[$cc])." surge zones.\n";
|
||||
} else {
|
||||
$redis->del($countryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "[".date('Y-m-d H:i:s')."] cron_surge_opportunity completed successfully.\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
||||
@@ -50,13 +50,17 @@ CREATE TABLE IF NOT EXISTS `competitor_secret_formulas` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`competitor_name` varchar(100) NOT NULL,
|
||||
`country_code` varchar(10) NOT NULL,
|
||||
`tier` varchar(20) DEFAULT 'standard',
|
||||
`base_fare` decimal(8,3) NOT NULL,
|
||||
`price_per_km` decimal(8,3) NOT NULL,
|
||||
`price_per_min` decimal(8,3) NOT NULL,
|
||||
`confidence_score` decimal(5,2) DEFAULT 0,
|
||||
`min_fare` decimal(8,3) DEFAULT 0,
|
||||
`rmse` decimal(10,4) DEFAULT 0,
|
||||
`r_squared` decimal(10,4) DEFAULT 0,
|
||||
`surge_multiplier` decimal(5,3) DEFAULT 1.0,
|
||||
`sample_size` int DEFAULT 0,
|
||||
`last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `idx_comp_country` (`competitor_name`, `country_code`)
|
||||
UNIQUE KEY `idx_comp_country_tier` (`competitor_name`, `country_code`, `tier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
";
|
||||
$con->exec($sqlFormula);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
// =========================================================
|
||||
// backend/bot/recalculate_all_distances.php
|
||||
// One-off script to recalculate distance_km and duration_min
|
||||
// for ALL rows in the scraped_competitor_prices table using Intaleq Maps.
|
||||
// =========================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/../core/osrm_routing.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "Fetching ALL rows to recalculate distance and duration...\n";
|
||||
|
||||
// Get all valid rows
|
||||
$stmt = $con->prepare("
|
||||
SELECT id, start_lat, start_lng, end_lat, end_lng, country_code, price_amount
|
||||
FROM scraped_competitor_prices
|
||||
WHERE start_lat IS NOT NULL
|
||||
AND start_lng IS NOT NULL
|
||||
");
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$rows || count($rows) === 0) {
|
||||
echo "No rows found.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Found " . count($rows) . " rows to update. Processing...\n";
|
||||
|
||||
$updateStmt = $con->prepare("
|
||||
UPDATE scraped_competitor_prices
|
||||
SET duration_min = :duration_min,
|
||||
distance_km = :distance_km,
|
||||
price_per_km = :price_per_km
|
||||
WHERE id = :id
|
||||
");
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$id = $row['id'];
|
||||
$countryCode = $row['country_code'] ?? 'JO';
|
||||
$price = (float)$row['price_amount'];
|
||||
|
||||
// Call Intaleq Maps (OSRM API)
|
||||
$routeInfo = getOsrmRouteDetails($row['start_lat'], $row['start_lng'], $row['end_lat'], $row['end_lng'], $countryCode);
|
||||
|
||||
if ($routeInfo && isset($routeInfo['duration_min']) && isset($routeInfo['distance_km'])) {
|
||||
$dist = (float)$routeInfo['distance_km'];
|
||||
$dur = (int)round($routeInfo['duration_min']);
|
||||
$pricePerKm = $dist > 0 ? ($price / $dist) : 0;
|
||||
|
||||
$updateStmt->execute([
|
||||
':duration_min' => $dur,
|
||||
':distance_km' => $dist,
|
||||
':price_per_km' => $pricePerKm,
|
||||
':id' => $id
|
||||
]);
|
||||
$successCount++;
|
||||
echo "Row $id: Distance {$dist}km, Duration {$dur}min, Price/Km {$pricePerKm} [SUCCESS]\n";
|
||||
} else {
|
||||
$failCount++;
|
||||
echo "Row $id: Failed to fetch route details.\n";
|
||||
}
|
||||
|
||||
// Small sleep to avoid hitting API rate limits too hard (100ms)
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
echo "======================================\n";
|
||||
echo "Completed processing " . count($rows) . " rows.\n";
|
||||
echo "Success: $successCount\n";
|
||||
echo "Failed: $failCount\n";
|
||||
@@ -1,3 +0,0 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain');
|
||||
echo file_get_contents(__DIR__ . '/auth/loginFromGooglePassenger.php');
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"require": {
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"firebase/php-jwt": "^6.0"
|
||||
"firebase/php-jwt": "^7.0"
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"audit": {
|
||||
"ignore": ["PKSA-y2cr-5h3j-g3ys"]
|
||||
"platform": {
|
||||
"php": "8.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+21
-15
@@ -4,20 +4,20 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e192df06759c90826eeb518a1ea5f0c8",
|
||||
"content-hash": "e5589fd14ce83adda13b8b9cebed44fa",
|
||||
"packages": [
|
||||
{
|
||||
"name": "firebase/php-jwt",
|
||||
"version": "v6.11.1",
|
||||
"version": "v7.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleapis/php-jwt.git",
|
||||
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66"
|
||||
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
|
||||
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
|
||||
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -25,6 +25,8 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"phpfastcache/phpfastcache": "^9.2",
|
||||
"phpseclib/phpseclib": "~3.0",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"psr/cache": "^2.0||^3.0",
|
||||
@@ -33,7 +35,8 @@
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
|
||||
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
|
||||
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -58,16 +61,16 @@
|
||||
}
|
||||
],
|
||||
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||
"homepage": "https://github.com/firebase/php-jwt",
|
||||
"homepage": "https://github.com/googleapis/php-jwt",
|
||||
"keywords": [
|
||||
"jwt",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||
"source": "https://github.com/googleapis/php-jwt/tree/v6.11.1"
|
||||
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
|
||||
},
|
||||
"time": "2025-04-09T20:32:01+00:00"
|
||||
"time": "2026-06-11T17:54:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "graham-campbell/result-type",
|
||||
@@ -460,16 +463,16 @@
|
||||
},
|
||||
{
|
||||
"name": "vlucas/phpdotenv",
|
||||
"version": "v5.6.3",
|
||||
"version": "v5.6.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vlucas/phpdotenv.git",
|
||||
"reference": "955e7815d677a3eaa7075231212f2110983adecc"
|
||||
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
|
||||
"reference": "955e7815d677a3eaa7075231212f2110983adecc",
|
||||
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b",
|
||||
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -528,7 +531,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/vlucas/phpdotenv/issues",
|
||||
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
|
||||
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -540,7 +543,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-27T19:49:13+00:00"
|
||||
"time": "2026-07-06T19:11:50+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
@@ -551,5 +554,8 @@
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"platform-overrides": {
|
||||
"php": "8.2"
|
||||
},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user