Compare commits
397
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 | ||
|
|
9d7e2b412e | ||
|
|
8b5daf6566 | ||
|
|
47a6fbb308 | ||
|
|
035fa5c209 | ||
|
|
785802221a | ||
|
|
61e7787d54 | ||
|
|
775d6dd0f3 | ||
|
|
7bcfed3ce1 | ||
|
|
6dccb63178 | ||
|
|
ac50598ee0 | ||
|
|
8d9126bb45 | ||
|
|
41746c844f | ||
|
|
22477356e3 | ||
|
|
f52316fa94 | ||
|
|
f227ce899d | ||
|
|
c24e921bac | ||
|
|
e7453cb6f9 | ||
|
|
bec8f61d70 | ||
|
|
4d16fbda48 | ||
|
|
ccf7dc99ee | ||
|
|
1b67c5e8fc | ||
|
|
d00502769a | ||
|
|
0c19486a24 | ||
|
|
03a74fa106 | ||
|
|
27b8a8ec59 | ||
|
|
4a6ca417ad | ||
|
|
36761dce91 | ||
|
|
632ff070fd | ||
|
|
c322e92913 | ||
|
|
0a35509abd | ||
|
|
71ca3790c1 | ||
|
|
b2e616ca92 | ||
|
|
ada5dd1c99 | ||
|
|
40ffcabc91 | ||
|
|
e23c96dc9d | ||
|
|
23a5d5fec1 | ||
|
|
63ae10e612 | ||
|
|
4150dbc7a6 | ||
|
|
27c0c916cd | ||
|
|
1b112f08ee | ||
|
|
d7c5eefe71 | ||
|
|
9b0f0de1ce | ||
|
|
2b15386291 | ||
|
|
5d79b67789 | ||
|
|
c96f457d2d | ||
|
|
4c7a51053f | ||
|
|
b2bc163acb | ||
|
|
2a5cd2cfbc | ||
|
|
7ae953f6e3 | ||
|
|
d7f723c735 | ||
|
|
bc44c1cd82 | ||
|
|
2b9390bca3 | ||
|
|
b89eab0651 | ||
|
|
2593558c9f | ||
|
|
01652eed8b | ||
|
|
73d4778dab | ||
|
|
468b06ffb3 | ||
|
|
5cf809e0cb | ||
|
|
35c0a6680c | ||
|
|
a0e9bd3cbb | ||
|
|
02bc56164c | ||
|
|
5f2aa84101 | ||
|
|
90fbe0ade8 | ||
|
|
9cf88adbd5 | ||
|
|
9a027e2535 | ||
|
|
c4746f08da | ||
|
|
ce7db27936 | ||
|
|
df4af1ac2b | ||
|
|
3c06b3624b | ||
|
|
3e0b4cbf64 | ||
|
|
3412f1b753 | ||
|
|
838e5ec656 | ||
|
|
c8fdd1bb5a | ||
|
|
8ae6a2e2da | ||
|
|
c7bd644b88 | ||
|
|
e4ba962ab6 | ||
|
|
d06500a20f | ||
|
|
5ce6c00905 | ||
|
|
8f59189832 | ||
|
|
6876d9bc54 | ||
|
|
5d14493ff4 | ||
|
|
75e6fc42d7 | ||
|
|
2adf195b36 | ||
|
|
05d047d871 | ||
|
|
d2ce4bdb16 | ||
|
|
808066f4a6 | ||
|
|
26ae0124c8 | ||
|
|
1b5d6eae44 | ||
|
|
772f8b647d | ||
|
|
9a296742f1 | ||
|
|
116e3fad22 | ||
|
|
d9dc124c3e | ||
|
|
ae723a75f6 | ||
|
|
e7785c9b2d | ||
|
|
1dc5ccc11d | ||
|
|
cf748dfd7c | ||
|
|
de06d1cd75 | ||
|
|
bd13cbb905 | ||
|
|
fdd09d8f35 | ||
|
|
1ae8acad7a | ||
|
|
c2eab19045 | ||
|
|
1f1a3385e3 | ||
|
|
ef0ee91fd9 | ||
|
|
de1f5ced47 | ||
|
|
81b1e41911 | ||
|
|
6b18a2ecf1 | ||
|
|
8126a0ac38 | ||
|
|
625df23bfc | ||
|
|
0d793fcce1 | ||
|
|
fdfe225b7b | ||
|
|
11140bca49 | ||
|
|
69b1d09829 | ||
|
|
152d4df0fc | ||
|
|
c8546bb744 | ||
|
|
cf696d05db | ||
|
|
44fa1c0558 | ||
|
|
1b285fbaea | ||
|
|
57080187e7 | ||
|
|
1428addcce | ||
|
|
299132b505 | ||
|
|
3506b07bc7 | ||
|
|
65b2e68154 | ||
|
|
2da943e745 | ||
|
|
5ab863edf1 | ||
|
|
d107dd6849 | ||
|
|
b8a817fc9c | ||
|
|
0af4eed1ce | ||
|
|
b5e2bf2fed | ||
|
|
c4fd859257 | ||
|
|
29d3a8ae7e | ||
|
|
9cc14864a3 | ||
|
|
03f26ce825 | ||
|
|
012b334885 | ||
|
|
d695a4e812 | ||
|
|
24da2bc7ca |
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 {
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<queries>
|
||||
<package android:name="ae.com.yalla.go.dubai.client" />
|
||||
<package android:name="com.zakinn.app" />
|
||||
<package android:name="com.bis.taxi" />
|
||||
<package android:name="com.careem.acma" />
|
||||
<package android:name="com.ubercab" />
|
||||
<package android:name="com.taxif.passenger" />
|
||||
<package android:name="me.com.easytaxi" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.siro.android_bot.network
|
||||
|
||||
import com.siro.android_bot.BuildConfig
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
@@ -24,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://api.intaleq.xyz/bot_android/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"
|
||||
|
||||
@@ -15,7 +15,15 @@ object AppLauncher {
|
||||
"zaken" to "com.zakinn.app",
|
||||
"com.zakinn.app" to "com.zakinn.app",
|
||||
"tufaddal" to "com.bis.taxi",
|
||||
"com.bis.taxi" to "com.bis.taxi"
|
||||
"com.bis.taxi" to "com.bis.taxi",
|
||||
"careem" to "com.careem.acma",
|
||||
"com.careem.acma" to "com.careem.acma",
|
||||
"uber" to "com.ubercab",
|
||||
"com.ubercab" to "com.ubercab",
|
||||
"taxif" to "com.taxif.passenger",
|
||||
"com.taxif.passenger" to "com.taxif.passenger",
|
||||
"jeeny" to "me.com.easytaxi",
|
||||
"me.com.easytaxi" to "me.com.easytaxi"
|
||||
)
|
||||
|
||||
fun launchApp(context: Context, appName: String): Boolean {
|
||||
|
||||
+716
-14
@@ -10,11 +10,13 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class BotState {
|
||||
IDLE,
|
||||
LAUNCHING_APP,
|
||||
NAVIGATING_HOME,
|
||||
SEARCHING_START,
|
||||
SEARCHING_END,
|
||||
READING_PRICE,
|
||||
@@ -33,6 +35,30 @@ class ScraperAccessibilityService : AccessibilityService() {
|
||||
private var currentState = BotState.IDLE
|
||||
private var currentTask: JSONObject? = null
|
||||
private var currentAppName: String = ""
|
||||
private var appLaunchTime = 0L
|
||||
private var taxiFHomeClickTime = 0L
|
||||
private var taxiFLocationClickTime = 0L
|
||||
private var taxiFSuggestionClickTime = 0L
|
||||
private var taxiFSearchTypingTime = 0L
|
||||
private var taxiFPickupDone = false
|
||||
private var taxiFDestinationDone = false
|
||||
private val taxiFCollectedPrices = mutableListOf<Pair<Double, String>>()
|
||||
private var taxiFPriceScrollAttempts = 0
|
||||
private var taxiFReadingPriceStartTime = 0L
|
||||
|
||||
// Jeeny State Memory
|
||||
private var jeenyDestinationDone = false
|
||||
private var jeenyPickupDone = false
|
||||
private var jeenySearchTypingTime = 0L
|
||||
private var jeenyPickupWaitStartTime = 0L
|
||||
companion object {
|
||||
private val TAXIF_EXCLUDED_TEXTS = setOf(
|
||||
"JOD", "ECO", "TaxiF", "SUV", "EV", "Mini", "Female", "Van",
|
||||
"Airport", "Courier", "~", "تحديد نقطة الانطلاق", "الآن", "نقدًا",
|
||||
"سيارة خاصة", "تاكسي", "جيب", "صغيرة", "كهرباء", "سائقة",
|
||||
"عائلية", "توصيل", "خدمة", "✈︎", "📦", "🇯🇴"
|
||||
)
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
@@ -57,9 +83,20 @@ class ScraperAccessibilityService : AccessibilityService() {
|
||||
} else {
|
||||
Log.d(TAG, "No tasks available.")
|
||||
}
|
||||
// Poll every 5 seconds
|
||||
delay(5000)
|
||||
} else {
|
||||
// We have an active task. Just to be safe, process screen periodically
|
||||
// in case accessibility events are missed (e.g. app launched but screen static)
|
||||
withContext(Dispatchers.Main) {
|
||||
val rootNode = rootInActiveWindow
|
||||
if (rootNode != null) {
|
||||
val pkg = rootNode.packageName?.toString() ?: currentAppName
|
||||
dispatchAutomation(pkg, rootNode)
|
||||
}
|
||||
}
|
||||
delay(1000) // Poll UI state every 1s when active
|
||||
}
|
||||
// Poll every 15 seconds
|
||||
delay(15000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,15 +108,17 @@ class ScraperAccessibilityService : AccessibilityService() {
|
||||
|
||||
Log.i(TAG, "Received Task: $taskId for App: $currentAppName")
|
||||
|
||||
taxiFPickupDone = false
|
||||
taxiFDestinationDone = false
|
||||
taxiFSearchTypingTime = 0L
|
||||
currentState = BotState.LAUNCHING_APP
|
||||
|
||||
// Launch the App
|
||||
val success = AppLauncher.launchApp(this, currentAppName)
|
||||
if (success) {
|
||||
// We wait for the AccessibilityEvent to tell us the app is opened,
|
||||
// but we can preemptively change state to SEARCHING_START
|
||||
currentState = BotState.SEARCHING_START
|
||||
Log.i(TAG, "State -> SEARCHING_START")
|
||||
appLaunchTime = System.currentTimeMillis()
|
||||
currentState = if (currentAppName == "taxif" || currentAppName == "com.taxif.passenger") BotState.NAVIGATING_HOME else BotState.SEARCHING_START
|
||||
Log.i(TAG, "State -> ${currentState}")
|
||||
} else {
|
||||
// Failed to launch (app not installed)
|
||||
Log.e(TAG, "Failed to launch app. Returning to IDLE.")
|
||||
@@ -94,10 +133,26 @@ class ScraperAccessibilityService : AccessibilityService() {
|
||||
val packageName = event.packageName?.toString() ?: return
|
||||
val rootNode = rootInActiveWindow ?: return
|
||||
|
||||
when (packageName) {
|
||||
"ae.com.yalla.go.dubai.client" -> handleYallaGoAutomation(rootNode)
|
||||
"com.zakinn.app" -> handleZakinnAutomation(rootNode)
|
||||
"com.bis.taxi" -> handleTfadalAutomation(rootNode)
|
||||
// Log layout hierarchy when a Jordanian app is open to inspect UI structure
|
||||
if (packageName == "com.careem.acma" || packageName == "com.ubercab" ||
|
||||
packageName == "com.taxif.passenger" || packageName == "me.com.easytaxi") {
|
||||
Log.d("HierarchyDump", "--- START SCREEN HIERARCHY DUMP FOR $packageName ---")
|
||||
dumpNodeHierarchy(rootNode, 0)
|
||||
Log.d("HierarchyDump", "--- END SCREEN HIERARCHY DUMP FOR $packageName ---")
|
||||
}
|
||||
|
||||
dispatchAutomation(packageName, rootNode)
|
||||
}
|
||||
|
||||
private fun dispatchAutomation(packageName: String, rootNode: android.view.accessibility.AccessibilityNodeInfo) {
|
||||
when {
|
||||
packageName.contains("yalla") -> handleYallaGoAutomation(rootNode)
|
||||
packageName.contains("zakinn") -> handleZakinnAutomation(rootNode)
|
||||
packageName.contains("bis.taxi") -> handleTfadalAutomation(rootNode)
|
||||
packageName.contains("careem") -> handleCareemAutomation(rootNode)
|
||||
packageName.contains("ubercab") -> handleUberAutomation(rootNode)
|
||||
packageName.contains("taxif") -> handleTaxiFAutomation(rootNode)
|
||||
packageName.contains("easytaxi") -> handleJeenyAutomation(rootNode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +377,7 @@ class ScraperAccessibilityService : AccessibilityService() {
|
||||
private fun searchPriceByCurrency(node: android.view.accessibility.AccessibilityNodeInfo?) {
|
||||
if (node == null) return
|
||||
val text = node.text?.toString() ?: ""
|
||||
if (text.contains("ل.س") || text.contains("SYP") || text.contains("AED") || text.contains("SP") || text.contains("SP.")) {
|
||||
if (text.contains("ل.س") || text.contains("SYP") || text.contains("AED") || text.contains("SP") || text.contains("SP.") || text.contains("JOD") || text.contains("د.أ") || text.contains("JD")) {
|
||||
Log.i(TAG, "Found price pattern dynamically: $text")
|
||||
submitPriceToServer(text)
|
||||
return
|
||||
@@ -368,9 +423,22 @@ class ScraperAccessibilityService : AccessibilityService() {
|
||||
Log.e(TAG, "Failed to submit price.")
|
||||
}
|
||||
|
||||
// Go back to IDLE
|
||||
currentState = BotState.IDLE
|
||||
currentTask = null
|
||||
// Wait 3 seconds for price to settle, then fetch next task directly
|
||||
delay(3000)
|
||||
|
||||
val nextResult = workerClient.fetchTask()
|
||||
if (nextResult != null && nextResult.optBoolean("has_task", false)) {
|
||||
val nextTask = nextResult.getJSONObject("task")
|
||||
currentState = BotState.IDLE
|
||||
withContext(Dispatchers.Main) {
|
||||
handleTask(nextTask)
|
||||
}
|
||||
Log.i(TAG, "Multi-trip: Immediately started next task ${nextTask.optString("task_id")}")
|
||||
} else {
|
||||
Log.i(TAG, "No more tasks, returning to IDLE.")
|
||||
currentState = BotState.IDLE
|
||||
currentTask = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +453,640 @@ class ScraperAccessibilityService : AccessibilityService() {
|
||||
return r * c
|
||||
}
|
||||
|
||||
private fun handleCareemAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
|
||||
val task = currentTask ?: return
|
||||
Log.d(TAG, "Careem Automation event. State: $currentState")
|
||||
when (currentState) {
|
||||
BotState.SEARCHING_START -> {
|
||||
val pickupEdit = findEditableNode(rootNode)
|
||||
if (pickupEdit != null) {
|
||||
val startLoc = task.optString("start_location", "Amman")
|
||||
val arguments = android.os.Bundle().apply {
|
||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc)
|
||||
}
|
||||
pickupEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
|
||||
Log.i(TAG, "Careem: Entered start: $startLoc")
|
||||
currentState = BotState.SEARCHING_END
|
||||
}
|
||||
}
|
||||
BotState.SEARCHING_END -> {
|
||||
val destEdit = findEditableNode(rootNode)
|
||||
if (destEdit != null) {
|
||||
val endLoc = task.optString("end_location", "Airport")
|
||||
val arguments = android.os.Bundle().apply {
|
||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, endLoc)
|
||||
}
|
||||
destEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
|
||||
Log.i(TAG, "Careem: Entered end: $endLoc")
|
||||
currentState = BotState.READING_PRICE
|
||||
}
|
||||
}
|
||||
BotState.READING_PRICE -> {
|
||||
searchPriceByCurrency(rootNode)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUberAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
|
||||
val task = currentTask ?: return
|
||||
Log.d(TAG, "Uber Automation event. State: $currentState")
|
||||
when (currentState) {
|
||||
BotState.SEARCHING_START -> {
|
||||
val pickupEdit = findEditableNode(rootNode)
|
||||
if (pickupEdit != null) {
|
||||
val startLoc = task.optString("start_location", "Amman")
|
||||
val arguments = android.os.Bundle().apply {
|
||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc)
|
||||
}
|
||||
pickupEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
|
||||
Log.i(TAG, "Uber: Entered start: $startLoc")
|
||||
currentState = BotState.SEARCHING_END
|
||||
}
|
||||
}
|
||||
BotState.SEARCHING_END -> {
|
||||
val destEdit = findEditableNode(rootNode)
|
||||
if (destEdit != null) {
|
||||
val endLoc = task.optString("end_location", "Airport")
|
||||
val arguments = android.os.Bundle().apply {
|
||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, endLoc)
|
||||
}
|
||||
destEdit.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
|
||||
Log.i(TAG, "Uber: Entered end: $endLoc")
|
||||
currentState = BotState.READING_PRICE
|
||||
}
|
||||
}
|
||||
BotState.READING_PRICE -> {
|
||||
searchPriceByCurrency(rootNode)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTaxiFAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
|
||||
val task = currentTask ?: return
|
||||
Log.d(TAG, "TaxiF Automation event. State: $currentState")
|
||||
when (currentState) {
|
||||
BotState.NAVIGATING_HOME -> {
|
||||
if (System.currentTimeMillis() - appLaunchTime < 2000) return
|
||||
if (hasJustClickedHome()) return
|
||||
val rihlaNode = findNodeByText(rootNode, "رحلة") ?: findNodeByText(rootNode, "طلب")
|
||||
if (rihlaNode != null) {
|
||||
var clickable: android.view.accessibility.AccessibilityNodeInfo? = rihlaNode
|
||||
while (clickable != null && !clickable.isClickable) {
|
||||
clickable = clickable.parent
|
||||
}
|
||||
if (clickable != null) {
|
||||
clickable.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "TaxiF: Clicked 'رحلة' or 'طلب' tab, entering trip flow")
|
||||
} else {
|
||||
rihlaNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "TaxiF: Clicked 'رحلة' or 'طلب' node directly")
|
||||
}
|
||||
taxiFHomeClickTime = System.currentTimeMillis()
|
||||
currentState = BotState.SEARCHING_START
|
||||
return
|
||||
}
|
||||
currentState = BotState.SEARCHING_START
|
||||
Log.i(TAG, "TaxiF: Home screen navigated, state -> SEARCHING_START")
|
||||
}
|
||||
BotState.SEARCHING_START -> {
|
||||
if (detectTaxiFPriceScreen(rootNode)) {
|
||||
if (hasJustClickedLocation() || hasJustClickedSuggestion()) return
|
||||
if (!taxiFPickupDone && shouldEditLocation(rootNode, task, isPickup = true)) {
|
||||
clickLocationRow(rootNode, isPickup = true)
|
||||
taxiFLocationClickTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
taxiFPickupDone = true
|
||||
currentState = BotState.SEARCHING_END
|
||||
return
|
||||
}
|
||||
if (!taxiFPickupDone && handleTaxiFSearchScreen(rootNode, task.optString("start_location", "Amman"))) {
|
||||
taxiFPickupDone = true
|
||||
Log.i(TAG, "TaxiF: Handled pickup search, waiting for price screen")
|
||||
}
|
||||
}
|
||||
BotState.SEARCHING_END -> {
|
||||
if (detectTaxiFPriceScreen(rootNode)) {
|
||||
if (hasJustClickedLocation() || hasJustClickedSuggestion()) return
|
||||
if (!taxiFDestinationDone && shouldEditLocation(rootNode, task, isPickup = false)) {
|
||||
clickLocationRow(rootNode, isPickup = false)
|
||||
taxiFLocationClickTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
taxiFDestinationDone = true
|
||||
currentState = BotState.READING_PRICE
|
||||
taxiFReadingPriceStartTime = System.currentTimeMillis()
|
||||
taxiFCollectedPrices.clear()
|
||||
taxiFPriceScrollAttempts = 0
|
||||
return
|
||||
}
|
||||
if (!taxiFDestinationDone && handleTaxiFSearchScreen(rootNode, task.optString("end_location", "Airport"))) {
|
||||
taxiFDestinationDone = true
|
||||
Log.i(TAG, "TaxiF: Handled dest search, waiting for price screen")
|
||||
}
|
||||
}
|
||||
BotState.READING_PRICE -> {
|
||||
if (hasJustClickedSuggestion()) return
|
||||
if (System.currentTimeMillis() - taxiFReadingPriceStartTime < 2000) return
|
||||
|
||||
val ecoPrice = getEcoPrice(rootNode)
|
||||
if (ecoPrice != null && ecoPrice > 0) {
|
||||
Log.i(TAG, "TaxiF: Found ECO price: $ecoPrice JOD")
|
||||
submitPriceToServer("$ecoPrice JOD")
|
||||
currentState = BotState.IDLE
|
||||
} else if (System.currentTimeMillis() - taxiFReadingPriceStartTime > 15000) {
|
||||
Log.w(TAG, "TaxiF: ECO price not found within 15s timeout, falling back to collectAndScrollPrices")
|
||||
collectAndScrollPrices(rootNode)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasJustClickedHome(): Boolean {
|
||||
return (System.currentTimeMillis() - taxiFHomeClickTime) < 500
|
||||
}
|
||||
|
||||
private fun hasJustClickedLocation(): Boolean {
|
||||
return (System.currentTimeMillis() - taxiFLocationClickTime) < 500
|
||||
}
|
||||
|
||||
private fun hasJustClickedSuggestion(): Boolean {
|
||||
return (System.currentTimeMillis() - taxiFSuggestionClickTime) < 500
|
||||
}
|
||||
|
||||
private fun shouldEditLocation(rootNode: android.view.accessibility.AccessibilityNodeInfo, task: JSONObject, isPickup: Boolean): Boolean {
|
||||
val locationNodes = getLocationTextNodes(rootNode)
|
||||
val taskLoc = if (isPickup) task.optString("start_location", "") else task.optString("end_location", "")
|
||||
if (taskLoc.isEmpty()) return false
|
||||
|
||||
val rowNode = if (isPickup) {
|
||||
locationNodes.firstOrNull()
|
||||
} else {
|
||||
val destIdx = locationNodes.indexOfFirst { it.text?.toString() == "عنوان الإنزال" }
|
||||
if (destIdx >= 0) locationNodes[destIdx] else locationNodes.getOrNull(1)
|
||||
}
|
||||
|
||||
val rowText = rowNode?.text?.toString()?.trim() ?: ""
|
||||
val matchFound = rowText.contains(taskLoc, ignoreCase = true) || taskLoc.contains(rowText, ignoreCase = true)
|
||||
|
||||
Log.d(TAG, "TaxiF: shouldEditLocation(isPickup=$isPickup): rowText='$rowText', taskLoc='$taskLoc', matchFound=$matchFound, locationNodes=[${locationNodes.joinToString { it.text?.toString() ?: "" }}]")
|
||||
|
||||
return !matchFound
|
||||
}
|
||||
|
||||
private fun clickLocationRow(rootNode: android.view.accessibility.AccessibilityNodeInfo?, isPickup: Boolean) {
|
||||
if (rootNode == null) return
|
||||
val locationNodes = getLocationTextNodes(rootNode)
|
||||
val targetIndex: Int
|
||||
if (isPickup) {
|
||||
targetIndex = 0
|
||||
} else {
|
||||
val destIdx = locationNodes.indexOfFirst { it.text?.toString() == "عنوان الإنزال" }
|
||||
targetIndex = if (destIdx >= 0) destIdx else minOf(1, locationNodes.size - 1)
|
||||
}
|
||||
if (targetIndex < locationNodes.size) {
|
||||
val locNode = locationNodes[targetIndex]
|
||||
var clickable: android.view.accessibility.AccessibilityNodeInfo? = locNode
|
||||
while (clickable != null && !clickable.isClickable) {
|
||||
clickable = clickable.parent
|
||||
}
|
||||
if (clickable != null) {
|
||||
clickable.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "TaxiF: Clicked ${if (isPickup) "pickup" else "destination"} row: '${locNode.text}'")
|
||||
} else {
|
||||
locNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "TaxiF: Clicked ${if (isPickup) "pickup" else "destination"} node directly: '${locNode.text}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLocationTextNodes(node: android.view.accessibility.AccessibilityNodeInfo?, results: MutableList<android.view.accessibility.AccessibilityNodeInfo> = mutableListOf()): List<android.view.accessibility.AccessibilityNodeInfo> {
|
||||
if (node == null) return results
|
||||
val text = node.text?.toString()?.trim() ?: ""
|
||||
val className = node.className?.toString() ?: ""
|
||||
val isEditText = className == "android.widget.EditText" || node.isEditable
|
||||
if (text.isNotBlank() && text.length > 2 && !isEditText && TAXIF_EXCLUDED_TEXTS.none { text.contains(it) }) {
|
||||
results.add(node)
|
||||
}
|
||||
for (i in 0 until node.childCount) {
|
||||
getLocationTextNodes(node.getChild(i), results)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun handleTaxiFSearchScreen(rootNode: android.view.accessibility.AccessibilityNodeInfo, location: String): Boolean {
|
||||
val editTexts = findAllEditTexts(rootNode)
|
||||
if (editTexts.isEmpty()) return false
|
||||
val editField = editTexts[0]
|
||||
val currentText = editField.text?.toString() ?: ""
|
||||
val normalizedCurrent = currentText.replace("-", " ").replace("\\s+".toRegex(), " ").trim()
|
||||
val normalizedLocation = location.replace("-", " ").replace("\\s+".toRegex(), " ").trim()
|
||||
if (!normalizedCurrent.contains(normalizedLocation, ignoreCase = true)) {
|
||||
val arguments = android.os.Bundle().apply {
|
||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, location)
|
||||
}
|
||||
editField.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
|
||||
Log.i(TAG, "TaxiF: Typed '$location' in search field")
|
||||
taxiFSearchTypingTime = System.currentTimeMillis()
|
||||
} else {
|
||||
if (taxiFSearchTypingTime == 0L) {
|
||||
taxiFSearchTypingTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
if (hasJustClickedSuggestion()) return false
|
||||
|
||||
val firstWord = location.split(" ").firstOrNull() ?: location
|
||||
if (clickFirstSuggestion(rootNode, firstWord)) {
|
||||
taxiFSuggestionClickTime = System.currentTimeMillis()
|
||||
Log.i(TAG, "TaxiF: Clicked suggestion matching '$firstWord'")
|
||||
taxiFSearchTypingTime = 0L
|
||||
return true
|
||||
} else {
|
||||
if (System.currentTimeMillis() - taxiFSearchTypingTime > 3000) {
|
||||
if (clickFirstGenericSuggestion(rootNode)) {
|
||||
taxiFSuggestionClickTime = System.currentTimeMillis()
|
||||
Log.i(TAG, "TaxiF: Clicked first generic suggestion as fallback")
|
||||
taxiFSearchTypingTime = 0L
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun clickFirstGenericSuggestion(rootNode: android.view.accessibility.AccessibilityNodeInfo?): Boolean {
|
||||
if (rootNode == null) return false
|
||||
val recycler = findRecyclerView(rootNode) ?: return false
|
||||
for (i in 0 until recycler.childCount) {
|
||||
val child = recycler.getChild(i) ?: continue
|
||||
if (child.isClickable) {
|
||||
child.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (i in 0 until recycler.childCount) {
|
||||
val child = recycler.getChild(i) ?: continue
|
||||
val clickableDescendant = findClickableDescendant(child)
|
||||
if (clickableDescendant != null) {
|
||||
clickableDescendant.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun findClickableDescendant(node: android.view.accessibility.AccessibilityNodeInfo?): android.view.accessibility.AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
if (node.isClickable) return node
|
||||
for (i in 0 until node.childCount) {
|
||||
val res = findClickableDescendant(node.getChild(i))
|
||||
if (res != null) return res
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun clickFirstSuggestion(rootNode: android.view.accessibility.AccessibilityNodeInfo?, target: String): Boolean {
|
||||
if (rootNode == null) return false
|
||||
val recycler = findRecyclerView(rootNode) ?: return false
|
||||
for (i in 0 until recycler.childCount) {
|
||||
val child = recycler.getChild(i) ?: continue
|
||||
if (child.isClickable && suggestionContainsText(child, target)) {
|
||||
child.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun suggestionContainsText(node: android.view.accessibility.AccessibilityNodeInfo?, target: String): Boolean {
|
||||
if (node == null) return false
|
||||
val text = node.text?.toString() ?: ""
|
||||
val normalizedText = text.replace("-", " ").replace("\\s+".toRegex(), " ").trim()
|
||||
val normalizedTarget = target.replace("-", " ").replace("\\s+".toRegex(), " ").trim()
|
||||
if (normalizedText.contains(normalizedTarget, ignoreCase = true)) return true
|
||||
for (i in 0 until node.childCount) {
|
||||
if (suggestionContainsText(node.getChild(i), target)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun findRecyclerView(node: android.view.accessibility.AccessibilityNodeInfo?): android.view.accessibility.AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
val className = node.className?.toString() ?: ""
|
||||
if (className.contains("RecyclerView") || className.contains("ListView") || className.contains("Recycler")) {
|
||||
return node
|
||||
}
|
||||
for (i in 0 until node.childCount) {
|
||||
val result = findRecyclerView(node.getChild(i))
|
||||
if (result != null) return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun detectTaxiFPriceScreen(node: android.view.accessibility.AccessibilityNodeInfo?): Boolean {
|
||||
val hasConfirmBtn = findNodeByText(node, "تحديد نقطة الانطلاق") != null
|
||||
if (hasConfirmBtn) return true
|
||||
val hasJOD = findNodeByText(node, "JOD") != null
|
||||
val hasRideOption = findNodeByText(node, "ECO") != null ||
|
||||
findNodeByText(node, "TaxiF") != null ||
|
||||
findNodeByText(node, "سيارة خاصة") != null ||
|
||||
findNodeByText(node, "Airport") != null ||
|
||||
findNodeByText(node, "توصيل المطار") != null
|
||||
return hasJOD && hasRideOption
|
||||
}
|
||||
|
||||
private fun collectAndScrollPrices(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
|
||||
findAllJODPrices(rootNode, taxiFCollectedPrices)
|
||||
if (taxiFPriceScrollAttempts < 3) {
|
||||
val recycler = findHorizontalRecyclerView(rootNode)
|
||||
if (recycler != null) {
|
||||
recycler.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
|
||||
taxiFPriceScrollAttempts++
|
||||
Log.i(TAG, "TaxiF: Scrolled price list, attempt $taxiFPriceScrollAttempts")
|
||||
}
|
||||
return
|
||||
}
|
||||
val prices = taxiFCollectedPrices.distinct()
|
||||
taxiFCollectedPrices.clear()
|
||||
taxiFPriceScrollAttempts = 0
|
||||
if (prices.isNotEmpty()) {
|
||||
prices.forEach { (price, label) ->
|
||||
Log.i(TAG, "TaxiF: Found price option: ${label.take(50)} = $price JOD")
|
||||
}
|
||||
val cheapest = prices.minByOrNull { it.first } ?: prices.first()
|
||||
Log.i(TAG, "TaxiF: Submitting cheapest price: ${cheapest.second.take(50)} = ${cheapest.first} JOD")
|
||||
submitPriceToServer("${cheapest.first} JOD")
|
||||
} else {
|
||||
Log.w(TAG, "TaxiF: No JOD prices found, falling back to generic search")
|
||||
searchPriceByCurrency(rootNode)
|
||||
}
|
||||
currentState = BotState.IDLE
|
||||
}
|
||||
|
||||
private fun findHorizontalRecyclerView(node: android.view.accessibility.AccessibilityNodeInfo?): android.view.accessibility.AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
val className = node.className?.toString() ?: ""
|
||||
if (className.contains("RecyclerView") || className.contains("HorizontalScrollView")) {
|
||||
return node
|
||||
}
|
||||
for (i in 0 until node.childCount) {
|
||||
val result = findHorizontalRecyclerView(node.getChild(i))
|
||||
if (result != null) return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findAllJODPrices(node: android.view.accessibility.AccessibilityNodeInfo?, prices: MutableList<Pair<Double, String>>) {
|
||||
if (node == null) return
|
||||
val text = node.text?.toString() ?: ""
|
||||
if (text.contains("JOD")) {
|
||||
val converted = arabicToWestern(text)
|
||||
val match = Regex("""(\d+\.?\d*)""").find(converted)
|
||||
if (match != null) {
|
||||
val price = match.value.toDoubleOrNull()
|
||||
if (price != null && price > 0) {
|
||||
prices.add(price to text.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i in 0 until node.childCount) {
|
||||
findAllJODPrices(node.getChild(i), prices)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEcoPrice(rootNode: android.view.accessibility.AccessibilityNodeInfo?): Double? {
|
||||
if (rootNode == null) return null
|
||||
val recycler = findHorizontalRecyclerView(rootNode) ?: return null
|
||||
for (i in 0 until recycler.childCount) {
|
||||
val card = recycler.getChild(i) ?: continue
|
||||
if (findNodeByText(card, "ECO") != null) {
|
||||
val jodNode = findNodeByText(card, "JOD") ?: continue
|
||||
val text = jodNode.text?.toString() ?: continue
|
||||
val converted = arabicToWestern(text)
|
||||
val match = Regex("""(\d+\.?\d*)""").find(converted)
|
||||
if (match != null) {
|
||||
val price = match.value.toDoubleOrNull()
|
||||
if (price != null && price > 0) return price
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getJeenyEcoLitePrice(rootNode: android.view.accessibility.AccessibilityNodeInfo?): Double? {
|
||||
if (rootNode == null) return null
|
||||
val ecoNode = findNodeByText(rootNode, "Eco lite")
|
||||
?: findNodeByText(rootNode, "EcoLite")
|
||||
?: findNodeByText(rootNode, "ايكو لايت")
|
||||
?: findNodeByText(rootNode, "إيكو لايت")
|
||||
|
||||
if (ecoNode != null) {
|
||||
var parent = ecoNode.parent
|
||||
for (i in 0..2) { // search up to 3 levels up
|
||||
if (parent == null) break
|
||||
val prices = mutableListOf<Pair<Double, String>>()
|
||||
findAllJODPrices(parent, prices)
|
||||
if (prices.isNotEmpty()) {
|
||||
return prices.first().first
|
||||
}
|
||||
parent = parent.parent
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun handleJeenyAutomation(rootNode: android.view.accessibility.AccessibilityNodeInfo) {
|
||||
val task = currentTask ?: return
|
||||
Log.d(TAG, "Jeeny Automation event. State: $currentState")
|
||||
|
||||
when (currentState) {
|
||||
BotState.SEARCHING_START -> {
|
||||
// Look for the "Where to" button/view on the main screen
|
||||
val whereToNode = findNodeByText(rootNode, "موقع الوصول")
|
||||
?: findNodeByText(rootNode, "إلى اين تريد الذهاب")
|
||||
?: findNodeByText(rootNode, "Where to")
|
||||
?: findNodeByText(rootNode, "إلى أين")
|
||||
|
||||
if (whereToNode != null) {
|
||||
// Click the parent clickable view if the TextView itself is not clickable
|
||||
var clickableParent = whereToNode
|
||||
while (clickableParent != null && !clickableParent.isClickable) {
|
||||
clickableParent = clickableParent.parent
|
||||
}
|
||||
if (clickableParent != null) {
|
||||
clickableParent.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "Jeeny: Clicked where to button/view.")
|
||||
jeenyDestinationDone = false
|
||||
jeenyPickupDone = false
|
||||
jeenySearchTypingTime = 0L
|
||||
jeenyPickupWaitStartTime = 0L
|
||||
currentState = BotState.SEARCHING_END
|
||||
} else {
|
||||
whereToNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "Jeeny: Clicked where to button/view (direct).")
|
||||
jeenyDestinationDone = false
|
||||
jeenyPickupDone = false
|
||||
jeenySearchTypingTime = 0L
|
||||
jeenyPickupWaitStartTime = 0L
|
||||
currentState = BotState.SEARCHING_END
|
||||
}
|
||||
} else {
|
||||
// If we are already on the search screen (we don't see "Where to" main button, but we see EditTexts)
|
||||
val editTexts = findAllEditTexts(rootNode)
|
||||
if (editTexts.isNotEmpty()) {
|
||||
Log.i(TAG, "Jeeny: Already on search screen with ${editTexts.size} input fields.")
|
||||
currentState = BotState.SEARCHING_END
|
||||
}
|
||||
}
|
||||
}
|
||||
BotState.SEARCHING_END -> {
|
||||
if (!jeenyDestinationDone) {
|
||||
val endLoc = task.optString("end_location", "Airport")
|
||||
|
||||
val editTexts = findAllEditTexts(rootNode)
|
||||
if (editTexts.isNotEmpty()) {
|
||||
val destField = editTexts.last() // Destination is usually the last one
|
||||
if (destField.text?.toString() != endLoc && !destField.text.toString().contains(endLoc)) {
|
||||
val destArgs = android.os.Bundle().apply {
|
||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, endLoc)
|
||||
}
|
||||
destField.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, destArgs)
|
||||
Log.i(TAG, "Jeeny: Set destination -> $endLoc")
|
||||
jeenySearchTypingTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (jeenySearchTypingTime > 0 && System.currentTimeMillis() - jeenySearchTypingTime > 1500) {
|
||||
val firstWord = endLoc.split(" ").firstOrNull() ?: endLoc
|
||||
if (clickFirstSuggestion(rootNode, firstWord) || clickFirstGenericSuggestion(rootNode)) {
|
||||
Log.i(TAG, "Jeeny: Clicked destination suggestion")
|
||||
jeenyDestinationDone = true
|
||||
jeenyPickupWaitStartTime = System.currentTimeMillis()
|
||||
jeenySearchTypingTime = 0L
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if (!jeenyPickupDone) {
|
||||
// Wait 2 seconds for pickup widget to appear
|
||||
if (jeenyPickupWaitStartTime > 0 && System.currentTimeMillis() - jeenyPickupWaitStartTime < 2000) {
|
||||
return
|
||||
}
|
||||
|
||||
val startLoc = task.optString("start_location", "Amman")
|
||||
|
||||
// The pickup search box might just be an EditText or we might need to click "موقع الانطلاق" first
|
||||
val pickupTextNode = findNodeByText(rootNode, "موقع الانطلاق")
|
||||
if (pickupTextNode != null) {
|
||||
var clickableParent = pickupTextNode
|
||||
while (clickableParent != null && !clickableParent.isClickable) {
|
||||
clickableParent = clickableParent.parent
|
||||
}
|
||||
if (clickableParent != null) {
|
||||
clickableParent.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "Jeeny: Clicked pickup location widget")
|
||||
jeenyPickupWaitStartTime = 0L // prevent re-clicking immediately
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val editTexts = findAllEditTexts(rootNode)
|
||||
if (editTexts.isNotEmpty()) {
|
||||
val pickupField = editTexts.first() // Pickup is usually the first one when both are visible
|
||||
if (pickupField.text?.toString() != startLoc && !pickupField.text.toString().contains(startLoc)) {
|
||||
val pickupArgs = android.os.Bundle().apply {
|
||||
putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, startLoc)
|
||||
}
|
||||
pickupField.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, pickupArgs)
|
||||
Log.i(TAG, "Jeeny: Set pickup -> $startLoc")
|
||||
jeenySearchTypingTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (jeenySearchTypingTime > 0 && System.currentTimeMillis() - jeenySearchTypingTime > 1500) {
|
||||
val firstWord = startLoc.split(" ").firstOrNull() ?: startLoc
|
||||
if (clickFirstSuggestion(rootNode, firstWord) || clickFirstGenericSuggestion(rootNode)) {
|
||||
Log.i(TAG, "Jeeny: Clicked pickup suggestion")
|
||||
jeenyPickupDone = true
|
||||
currentState = BotState.READING_PRICE
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BotState.READING_PRICE -> {
|
||||
// Check if we are on the Confirm Pickup map screen
|
||||
val confirmPickupBtn = findNodeByText(rootNode, "تاكيد وجهة الانطلاق")
|
||||
?: findNodeByText(rootNode, "تأكيد وجهة الانطلاق")
|
||||
?: findNodeByText(rootNode, "Confirm pickup")
|
||||
?: findNodeByText(rootNode, "Confirm start")
|
||||
|
||||
if (confirmPickupBtn != null) {
|
||||
var clickableParent = confirmPickupBtn
|
||||
while (clickableParent != null && !clickableParent.isClickable) {
|
||||
clickableParent = clickableParent.parent
|
||||
}
|
||||
if (clickableParent != null) {
|
||||
clickableParent.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK)
|
||||
Log.i(TAG, "Jeeny: Clicked confirm pickup button.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If not on pickup confirmation screen, read the price
|
||||
val ecoLitePrice = getJeenyEcoLitePrice(rootNode)
|
||||
if (ecoLitePrice != null && ecoLitePrice > 0) {
|
||||
Log.i(TAG, "Jeeny: Found Eco lite price: $ecoLitePrice JOD")
|
||||
submitPriceToServer("$ecoLitePrice JOD")
|
||||
currentState = BotState.IDLE
|
||||
} else {
|
||||
// Fallback to searching any price if eco lite isn't strictly matched
|
||||
searchPriceByCurrency(rootNode)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dumpNodeHierarchy(node: android.view.accessibility.AccessibilityNodeInfo?, depth: Int) {
|
||||
if (node == null) return
|
||||
val indent = " ".repeat(depth)
|
||||
val className = node.className ?: "unknown"
|
||||
val text = node.text ?: ""
|
||||
val resourceId = node.viewIdResourceName ?: "no-id"
|
||||
val isClickable = node.isClickable
|
||||
val isEditable = node.isEditable
|
||||
Log.d("HierarchyDump", "$indent[$className] ID: $resourceId | Text: \"$text\" | Clickable: $isClickable | Editable: $isEditable")
|
||||
for (i in 0 until node.childCount) {
|
||||
dumpNodeHierarchy(node.getChild(i), depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findAllEditTexts(node: android.view.accessibility.AccessibilityNodeInfo?, list: MutableList<android.view.accessibility.AccessibilityNodeInfo> = mutableListOf()): List<android.view.accessibility.AccessibilityNodeInfo> {
|
||||
if (node == null) return list
|
||||
if (node.className == "android.widget.EditText" || node.isEditable) {
|
||||
list.add(node)
|
||||
}
|
||||
for (i in 0 until node.childCount) {
|
||||
findAllEditTexts(node.getChild(i), list)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private fun arabicToWestern(text: String): String {
|
||||
val mapping = mapOf(
|
||||
'٠' to '0', '١' to '1', '٢' to '2', '٣' to '3', '٤' to '4',
|
||||
'٥' to '5', '٦' to '6', '٧' to '7', '٨' to '8', '٩' to '9',
|
||||
'٫' to '.'
|
||||
)
|
||||
return text.map { mapping[it] ?: it }.joinToString("")
|
||||
}
|
||||
|
||||
override fun onInterrupt() {
|
||||
Log.w(TAG, "Accessibility Service Interrupted")
|
||||
}
|
||||
|
||||
+56
-1
@@ -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
|
||||
# =============================================================================
|
||||
@@ -89,6 +106,41 @@ FEMALE_GENDER_HASH=<CHANGE_ME_FEMALE_HASH>
|
||||
FIREBASE_PROJECT_ID=siro-project
|
||||
FIREBASE_API_KEY=<CHANGE_ME_FIREBASE_KEY>
|
||||
|
||||
# =============================================================================
|
||||
# Payment Gateway Configuration
|
||||
# =============================================================================
|
||||
PAYMENT_GATEWAY_URL=https://api.paymentprovider.com
|
||||
PAYMENT_GATEWAY_KEY=<CHANGE_ME_PAYMENT_KEY>
|
||||
PAYMENT_GATEWAY_SECRET=<CHANGE_ME_PAYMENT_SECRET>
|
||||
PAYMENT_WEBHOOK_SECRET=<CHANGE_ME_WEBHOOK_SECRET>
|
||||
# Internal key used for server-to-server calls (Siro Backend → Wallet Server)
|
||||
PAYMENT_KEY=<CHANGE_ME_SHARED_PAYMENT_KEY>
|
||||
|
||||
# =============================================================================
|
||||
# Wallet Servers — Multi-Country (انطلق / Wallet Intaliq)
|
||||
# =============================================================================
|
||||
# Jordan wallet server (walletintaleq.intaleq.xyz)
|
||||
WALLET_SERVER_JORDAN=https://walletintaleq.intaleq.xyz
|
||||
|
||||
# Egypt wallet server
|
||||
WALLET_SERVER_EGYPT=https://wallet-egypt.siromove.com
|
||||
|
||||
# Syria wallet server
|
||||
WALLET_SERVER_SYRIA=https://wallet-syria.siromove.com
|
||||
|
||||
# Shared S2S secret key (must match wallet server's X-S2S-Api-Key config)
|
||||
S2S_SHARED_KEY=<CHANGE_ME_S2S_SHARED_SECRET>
|
||||
|
||||
# =============================================================================
|
||||
# Siro Commissions per Country
|
||||
# =============================================================================
|
||||
# Set the commission percentage Siro takes from drivers in each country
|
||||
# (0.15 = 15%, 0.12 = 12%, 0.10 = 10%)
|
||||
SIRO_COMMISSION_JO=0.15
|
||||
SIRO_COMMISSION_SY=0.12
|
||||
SIRO_COMMISSION_EG=0.10
|
||||
SIRO_COMMISSION_IQ=0.10
|
||||
|
||||
# =============================================================================
|
||||
# SMS Configuration (for OTP)
|
||||
# =============================================================================
|
||||
@@ -111,6 +163,8 @@ APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_NAME=Siro
|
||||
APP_DOMAIN=api-syria.siromove.com
|
||||
APP_COUNTRY=Jordan
|
||||
APP_CURRENCY=JOD
|
||||
|
||||
# =============================================================================
|
||||
# Nabeh Integration (server-to-server API key)
|
||||
@@ -120,9 +174,10 @@ NABEH_API_KEY=<CHANGE_ME_SHARED_SECRET>
|
||||
SECRET_KEY_HMAC=<CHANGE_ME_HMAC_SECRET_FOR_SIGNED_URLS>
|
||||
|
||||
# =============================================================================
|
||||
# Security Configuration - Fingerprint
|
||||
# Security Configuration - Fingerprint & Testers
|
||||
# =============================================================================
|
||||
FP_PEPPER=<CHANGE_ME_FINGERPRINT_PEPPER>
|
||||
ALLOWED_TESTER_EMAILS=driver_tester@siromove.com,passenger_tester@siromove.com
|
||||
|
||||
# =============================================================================
|
||||
# Gemini AI Configuration
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../connect.php'; // Includes db connection
|
||||
|
||||
$zone_name = filterRequest('zone_name');
|
||||
$latitude = filterRequest('latitude');
|
||||
$longitude = filterRequest('longitude');
|
||||
$radius_meters = filterRequest('radius_meters');
|
||||
$country_code = filterRequest('country_code');
|
||||
$priority = filterRequest('priority') ?? 1;
|
||||
|
||||
if (empty($zone_name) || empty($latitude) || empty($longitude) || empty($radius_meters) || empty($country_code)) {
|
||||
echo json_encode(["status" => "error", "message" => "Missing required fields"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Check for overlapping zones
|
||||
// Using Haversine formula directly in SQL to find any zone where distance < (new_radius + existing_radius)
|
||||
$sql = "
|
||||
SELECT id, zone_name, radius_meters,
|
||||
(
|
||||
6371000 * acos(
|
||||
cos(radians(:new_lat)) * cos(radians(latitude)) *
|
||||
cos(radians(longitude) - radians(:new_lng)) +
|
||||
sin(radians(:new_lat)) * sin(radians(latitude))
|
||||
)
|
||||
) AS distance_meters
|
||||
FROM geofence_zones
|
||||
WHERE is_active = 1 AND country_code = :country_code
|
||||
HAVING distance_meters < (radius_meters + :new_radius)
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindValue(':new_lat', (float) $latitude);
|
||||
$stmt->bindValue(':new_lng', (float) $longitude);
|
||||
$stmt->bindValue(':new_radius', (int) $radius_meters, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':country_code', $country_code);
|
||||
$stmt->execute();
|
||||
|
||||
$overlapping_zone = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($overlapping_zone) {
|
||||
echo json_encode([
|
||||
"status" => "error",
|
||||
"message" => "Zone overlaps with existing zone: " . $overlapping_zone['zone_name'],
|
||||
"overlap_details" => $overlapping_zone
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Insert new zone
|
||||
$insert_sql = "INSERT INTO geofence_zones (zone_name, latitude, longitude, radius_meters, priority, country_code)
|
||||
VALUES (:zone_name, :lat, :lng, :radius, :priority, :country)";
|
||||
|
||||
$insert_stmt = $con->prepare($insert_sql);
|
||||
$insert_stmt->bindValue(':zone_name', $zone_name);
|
||||
$insert_stmt->bindValue(':lat', (float) $latitude);
|
||||
$insert_stmt->bindValue(':lng', (float) $longitude);
|
||||
$insert_stmt->bindValue(':radius', (int) $radius_meters, PDO::PARAM_INT);
|
||||
$insert_stmt->bindValue(':priority', (int) $priority, PDO::PARAM_INT);
|
||||
$insert_stmt->bindValue(':country', $country_code);
|
||||
$insert_stmt->execute();
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"message" => "Geofence zone added successfully",
|
||||
"zone_id" => $con->lastInsertId()
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error adding geofence zone: " . $e->getMessage());
|
||||
echo json_encode(["status" => "error", "message" => "Server error"]);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* get_heatmap.php
|
||||
* ───────────────
|
||||
* تقرأ بيانات الخريطة الحرارية المجمعة من Redis
|
||||
* البيانات مقسمة حسب الدولة (عبر Bounding Boxes في الـ Cron)
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../connect.php'; // Includes functions.php which has filterRequest()
|
||||
|
||||
$days = (int)(filterRequest('days') ?? 7);
|
||||
$source = filterRequest('source') ?? 'all';
|
||||
$countryCode = strtoupper(filterRequest('country_code') ?? 'all');
|
||||
|
||||
try {
|
||||
$redis = getRedisConnection();
|
||||
$cacheJson = $redis->get('siro:cache:heatmap:data');
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Redis connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$cacheJson) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Cache not generated yet']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cacheData = json_decode($cacheJson, true);
|
||||
|
||||
if (!$cacheData || !isset($cacheData['data'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid cache data']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$limitDate = date('Y-m-d', strtotime("-$days days"));
|
||||
|
||||
$filteredLocations = [];
|
||||
$stats = ['geofence' => 0, 'app_usage' => 0, 'silent_push' => 0];
|
||||
|
||||
$dataByCountry = $cacheData['data'];
|
||||
|
||||
// تحديد الدول التي سنسحب منها
|
||||
$countriesToSearch = ($countryCode === 'ALL') ? array_keys($dataByCountry) : [$countryCode];
|
||||
|
||||
foreach ($countriesToSearch as $cc) {
|
||||
if (!isset($dataByCountry[$cc])) continue;
|
||||
|
||||
foreach ($dataByCountry[$cc] as $loc) {
|
||||
// فلتر الأيام
|
||||
if ($loc['date'] < $limitDate) continue;
|
||||
|
||||
// فلتر المصدر
|
||||
if ($source !== 'all' && $loc['source'] !== $source) continue;
|
||||
|
||||
$filteredLocations[] = [
|
||||
'latitude' => $loc['lat'],
|
||||
'longitude' => $loc['lng'],
|
||||
'source' => $loc['source']
|
||||
];
|
||||
|
||||
if (isset($stats[$loc['source']])) {
|
||||
$stats[$loc['source']]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => $filteredLocations,
|
||||
'total' => count($filteredLocations),
|
||||
'stats' => $stats,
|
||||
'source' => 'redis_cache'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// تم إزالة دالة filterRequest من هنا لتجنب خطأ Redeclaration لأنها معرفة في functions.php
|
||||
?>
|
||||
@@ -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);
|
||||
|
||||
@@ -13,7 +13,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null);
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
|
||||
@@ -37,17 +37,29 @@ try {
|
||||
$stmt->execute();
|
||||
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Decrypt names or just return them
|
||||
// (Names are not encrypted in this schema, only phones are, so we can return directly)
|
||||
// Decrypt names since they are encrypted in the passengers table
|
||||
foreach ($logs as &$log) {
|
||||
if (!empty($log['first_name'])) {
|
||||
$decName = $encryptionHelper->decryptData($log['first_name']);
|
||||
if ($decName) $log['first_name'] = $decName;
|
||||
}
|
||||
if (!empty($log['last_name'])) {
|
||||
$decName = $encryptionHelper->decryptData($log['last_name']);
|
||||
if ($decName) $log['last_name'] = $decName;
|
||||
}
|
||||
}
|
||||
unset($log);
|
||||
|
||||
// Aggregate statistics for Dashboard charts
|
||||
$sqlStats = "SELECT message_type, COUNT(*) as count
|
||||
FROM marketing_campaigns_log";
|
||||
if ($countryCode) {
|
||||
$sqlStats .= " WHERE country_code = :country";
|
||||
$sqlStats .= " GROUP BY message_type";
|
||||
$stmtStats = $con->prepare($sqlStats);
|
||||
$stmtStats->execute([':country' => strtoupper($countryCode)]);
|
||||
} else {
|
||||
$sqlStats .= " GROUP BY message_type";
|
||||
$stmtStats = $con->prepare($sqlStats);
|
||||
$stmtStats->execute();
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ try {
|
||||
$anomalies = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Fetch some recent competitor prices for context
|
||||
$sqlPrices = "SELECT * FROM competitor_prices";
|
||||
$sqlPrices = "SELECT * FROM scraped_competitor_prices";
|
||||
$paramsPrices = [];
|
||||
if ($countryCode) {
|
||||
$sqlPrices .= " WHERE country_code = :country";
|
||||
|
||||
@@ -13,7 +13,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null);
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
|
||||
@@ -15,7 +15,7 @@ try {
|
||||
DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
AVG(price_per_km) AS avg_price_per_km,
|
||||
COUNT(*) AS sample_count
|
||||
FROM competitor_prices
|
||||
FROM scraped_competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
||||
$compParams = [];
|
||||
if ($countryCode) {
|
||||
@@ -33,12 +33,12 @@ try {
|
||||
|
||||
// 2. PCI by region — group competitor prices by ~0.02° grid cells
|
||||
$pciSql = "SELECT
|
||||
ROUND(from_latitude * 50, 0) / 50 AS lat_group,
|
||||
ROUND(from_longitude * 50, 0) / 50 AS lng_group,
|
||||
ROUND(start_lat * 50, 0) / 50 AS lat_group,
|
||||
ROUND(start_lng * 50, 0) / 50 AS lng_group,
|
||||
competitor_name,
|
||||
AVG(price_per_km) AS avg_price_per_km,
|
||||
COUNT(*) AS samples
|
||||
FROM competitor_prices
|
||||
FROM scraped_competitor_prices
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
|
||||
$pciParams = [];
|
||||
if ($countryCode) {
|
||||
|
||||
@@ -13,7 +13,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null);
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
@@ -35,13 +35,13 @@ try {
|
||||
|
||||
// Aggregate competitor data by geographical grid (approx 1.5km x 1.5km)
|
||||
$sql = "SELECT
|
||||
ROUND(from_latitude * 74, 0) / 74 AS lat_group,
|
||||
ROUND(from_longitude * 74, 0) / 74 AS lng_group,
|
||||
ROUND(start_lat * 74, 0) / 74 AS lat_group,
|
||||
ROUND(start_lng * 74, 0) / 74 AS lng_group,
|
||||
AVG(price_per_km) as avg_competitor_price_per_km,
|
||||
COUNT(*) as trip_count
|
||||
FROM competitor_prices
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country
|
||||
AND distance_km > 0
|
||||
AND price_per_km > 0
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
GROUP BY lat_group, lng_group
|
||||
HAVING trip_count >= 3"; // Require at least 3 trips for a reliable heatmap point
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -32,8 +32,8 @@ try {
|
||||
// 1. حساب الـ baseline (آخر 7 أيام، بدون آخر 6 ساعات)
|
||||
// و current (آخر ساعتين) لكل منافس في كل خلية grid
|
||||
$sql = "SELECT
|
||||
ROUND(cp.from_latitude * 74, 0) / 74 AS lat_group,
|
||||
ROUND(cp.from_longitude * 74, 0) / 74 AS lng_group,
|
||||
ROUND(cp.start_lat * 74, 0) / 74 AS lat_group,
|
||||
ROUND(cp.start_lng * 74, 0) / 74 AS lng_group,
|
||||
cp.competitor_name,
|
||||
cp.country_code,
|
||||
AVG(CASE WHEN cp.created_at < DATE_SUB(NOW(), INTERVAL 6 HOUR)
|
||||
@@ -42,7 +42,7 @@ try {
|
||||
THEN cp.price_per_km END) AS current_avg,
|
||||
COUNT(*) AS total_samples,
|
||||
SUM(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) THEN 1 ELSE 0 END) AS recent_samples
|
||||
FROM competitor_prices cp
|
||||
FROM scraped_competitor_prices cp
|
||||
WHERE cp.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
AND cp.price_per_km > 0
|
||||
$where
|
||||
|
||||
@@ -15,15 +15,22 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
}
|
||||
|
||||
// 2. Filter inputs
|
||||
$regionName = filterRequest('region_name') ?? 'Damascus';
|
||||
$countryCode = filterRequest('country_code') ?? 'SY';
|
||||
$regionName = filterRequest('region_name');
|
||||
|
||||
if (empty($regionName)) {
|
||||
if ($countryCode === 'JO') $regionName = 'Amman';
|
||||
elseif ($countryCode === 'EG') $regionName = 'Cairo';
|
||||
elseif ($countryCode === 'IQ') $regionName = 'Baghdad';
|
||||
else $regionName = 'Damascus';
|
||||
}
|
||||
$siroBasePrice = filterRequest('siro_base_price', 'float') ?? 10000.0;
|
||||
|
||||
try {
|
||||
// 3. Fetch recent competitor prices for this region to supply context to Gemini
|
||||
$sqlPrices = "SELECT competitor_name, total_price, distance_km
|
||||
FROM competitor_prices
|
||||
WHERE country_code = :country
|
||||
$sqlPrices = "SELECT competitor_name, price_amount AS total_price, (price_amount / price_per_km) AS distance_km
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country AND price_per_km > 0
|
||||
ORDER BY created_at DESC LIMIT 10";
|
||||
$stmtPrices = $con->prepare($sqlPrices);
|
||||
$stmtPrices->execute([':country' => strtoupper($countryCode)]);
|
||||
@@ -67,18 +74,56 @@ try {
|
||||
$smsBody = $aiCampaign['sms_body'] ?? 'اشتقنا لك! عد إلينا ووفر أكثر مع الرمز الترويجي الخاص بك.';
|
||||
|
||||
// 5. Target Passengers in the specified country
|
||||
// Check passenger_opening_locations for target audiences
|
||||
$sqlTarget = "SELECT DISTINCT l.passenger_id
|
||||
FROM passenger_opening_locations l
|
||||
WHERE l.country_code = :country";
|
||||
// Since phone numbers are encrypted, we fetch all passengers, decrypt, and filter by country prefix.
|
||||
$sqlTarget = "SELECT id AS passenger_id, phone FROM passengers";
|
||||
$stmtTarget = $con->prepare($sqlTarget);
|
||||
$stmtTarget->execute([':country' => strtoupper($countryCode)]);
|
||||
$targets = $stmtTarget->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtTarget->execute();
|
||||
$allPassengers = $stmtTarget->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$targets = [];
|
||||
$debugCounts = ['JO' => 0, 'SY' => 0, 'EG' => 0, 'IQ' => 0, 'UNKNOWN' => 0, 'DECRYPT_FAIL' => 0];
|
||||
foreach ($allPassengers as $p) {
|
||||
$decryptedPhone = $encryptionHelper->decryptData($p['phone']);
|
||||
if (!$decryptedPhone) {
|
||||
$debugCounts['DECRYPT_FAIL']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cleanPhone = preg_replace('/[^0-9]/', '', $decryptedPhone);
|
||||
$pCountry = 'UNKNOWN';
|
||||
if (strpos($cleanPhone, '962') === 0 || strpos($cleanPhone, '07') === 0) $pCountry = 'JO';
|
||||
elseif (strpos($cleanPhone, '963') === 0 || (strpos($cleanPhone, '09') === 0 && strlen($cleanPhone) == 10)) $pCountry = 'SY';
|
||||
elseif (strpos($cleanPhone, '20') === 0 || (strpos($cleanPhone, '01') === 0 && strlen($cleanPhone) == 11)) $pCountry = 'EG';
|
||||
elseif (strpos($cleanPhone, '964') === 0) $pCountry = 'IQ';
|
||||
|
||||
$debugCounts[$pCountry]++;
|
||||
|
||||
if ($pCountry === strtoupper($countryCode)) {
|
||||
$targets[] = ['passenger_id' => $p['passenger_id'], 'decrypted_phone' => $decryptedPhone];
|
||||
}
|
||||
}
|
||||
|
||||
$sentFcm = 0;
|
||||
$sentSms = 0;
|
||||
$sentWhatsApp = 0;
|
||||
$dispatchedPassengers = [];
|
||||
$fcmErrors = [];
|
||||
|
||||
// 5.5 وضع المعاينة: يُرجع ما ستفعله الحملة (النص، الكود، حجم الجمهور)
|
||||
// دون إنشاء كود ترويجي ودون إرسال أي إشعار. الحملة تُنشئ خصماً حقيقياً
|
||||
// وتصل كل ركاب الدولة، فوجود معاينة قبل الإطلاق ضروري.
|
||||
if (filterRequest('dry_run') === '1') {
|
||||
jsonSuccess([
|
||||
'dry_run' => true,
|
||||
'campaign_created' => false,
|
||||
'promo_code' => $promoCode,
|
||||
'discount_percent' => $discountVal,
|
||||
'region' => $regionName,
|
||||
'country_code' => strtoupper($countryCode),
|
||||
'audience_size' => count($targets),
|
||||
'ai_analysis' => $aiCampaign,
|
||||
], 'Preview only — no promo code was created and no notification was sent.');
|
||||
}
|
||||
|
||||
// 6. Save broadcast promo for this campaign (Option 1 - promos table adjustment)
|
||||
$sqlPromo = "INSERT INTO promos
|
||||
@@ -104,37 +149,50 @@ try {
|
||||
$spamCount = intval($stmtSpam->fetchColumn());
|
||||
|
||||
// Check if passenger has active FCM token
|
||||
$sqlToken = "SELECT token FROM tokens WHERE passengerID = :pid LIMIT 1";
|
||||
$sqlToken = "SELECT token FROM tokens WHERE passengerID = :pid ORDER BY id DESC LIMIT 1";
|
||||
$stmtToken = $con->prepare($sqlToken);
|
||||
$stmtToken->execute([':pid' => $passengerId]);
|
||||
$fcmToken = $stmtToken->fetchColumn();
|
||||
|
||||
$pushSent = false;
|
||||
if ($fcmToken) {
|
||||
// Send FCM Push Notification (Free channel - no anti-spam restriction needed)
|
||||
$fcmData = [
|
||||
'type' => 'marketing_campaign',
|
||||
'promo_code' => $promoCode,
|
||||
'discount' => (string)$discountVal
|
||||
];
|
||||
|
||||
$fcmResult = sendFcmNotification(
|
||||
$fcmToken,
|
||||
$pushTitle,
|
||||
$pushBody,
|
||||
$fcmData,
|
||||
'Marketing',
|
||||
'notification'
|
||||
);
|
||||
$decryptedToken = $encryptionHelper->decryptData($fcmToken);
|
||||
if ($decryptedToken) {
|
||||
// Send FCM Push Notification (Free channel - no anti-spam restriction needed)
|
||||
$fcmData = [
|
||||
'type' => 'marketing_campaign',
|
||||
'promo_code' => $promoCode,
|
||||
'discount' => (string)$discountVal
|
||||
];
|
||||
|
||||
$fcmResult = sendFcmNotification(
|
||||
$decryptedToken,
|
||||
$pushTitle,
|
||||
$pushBody,
|
||||
$fcmData,
|
||||
'Marketing',
|
||||
'notification'
|
||||
);
|
||||
|
||||
if ($fcmResult['status'] === 'success') {
|
||||
$sentFcm++;
|
||||
// Log campaign dispatch
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'push', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
if ($fcmResult['status'] === 'success') {
|
||||
$sentFcm++;
|
||||
// Log campaign dispatch
|
||||
$logStmt = $con->prepare("INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) VALUES (?, 'push', ?, ?, 'autopilot')");
|
||||
$logStmt->execute([$passengerId, $countryCode, $regionName]);
|
||||
$dispatchedPassengers[] = $passengerId;
|
||||
$pushSent = true;
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => $fcmResult];
|
||||
}
|
||||
} else {
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => 'Token decryption failed'];
|
||||
}
|
||||
} else {
|
||||
// Churned user (Deleted the app / No token) -> Send WhatsApp or SMS
|
||||
$fcmErrors[] = ['passenger_id' => $passengerId, 'error' => 'No token in DB'];
|
||||
}
|
||||
|
||||
if (!$pushSent) {
|
||||
// Fallback: Churned user (No token) OR Push failed -> Send WhatsApp or SMS
|
||||
// Check anti-spam first to prevent unnecessary marketing cost
|
||||
if ($spamCount === 0) {
|
||||
// Fetch and decrypt passenger phone number
|
||||
@@ -191,7 +249,14 @@ try {
|
||||
'whatsapp_sent_count' => $sentWhatsApp,
|
||||
'sms_sent_count' => $sentSms
|
||||
],
|
||||
'total_dispatched' => count($dispatchedPassengers)
|
||||
'total_dispatched' => count($dispatchedPassengers),
|
||||
'debug_info' => [
|
||||
'requested_country' => $countryCode,
|
||||
'total_passengers_in_db' => count($allPassengers),
|
||||
'matched_targets' => count($targets),
|
||||
'distribution' => $debugCounts,
|
||||
'fcm_errors' => $fcmErrors ?? []
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
||||
@@ -13,7 +13,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null);
|
||||
$countryCode = filterRequest('country_code');
|
||||
$proposedSpeedPrice = (float)filterRequest('speed_price');
|
||||
|
||||
if (!$countryCode || $proposedSpeedPrice <= 0) {
|
||||
@@ -22,10 +22,10 @@ try {
|
||||
}
|
||||
|
||||
// 1. Fetch recent competitor trips (last 7 days, limit 500 for fast simulation)
|
||||
$sql = "SELECT distance_km, total_price, competitor_name
|
||||
FROM competitor_prices
|
||||
$sql = "SELECT (price_amount / price_per_km) AS distance_km, price_amount AS total_price, competitor_name
|
||||
FROM scraped_competitor_prices
|
||||
WHERE country_code = :country
|
||||
AND distance_km > 0
|
||||
AND price_per_km > 0
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500";
|
||||
|
||||
@@ -14,7 +14,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
}
|
||||
|
||||
try {
|
||||
$countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null);
|
||||
$countryCode = filterRequest('country_code');
|
||||
|
||||
if (!$countryCode) {
|
||||
jsonError("Missing required parameter: country_code");
|
||||
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
/**
|
||||
* getPredictiveDemandZones.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* API endpoint: يُعيد للسائق قائمة بأفضل المناطق المتوقع
|
||||
* ارتفاع الطلب فيها خلال الساعة القادمة.
|
||||
*
|
||||
* يقرأ من Redis أولاً (cache يُجدَّد كل ساعة بالـ cron)،
|
||||
* ثم يُصفّي أقرب المناطق لموقع السائق الحالي.
|
||||
*
|
||||
* Request (GET/POST):
|
||||
* lat — خط العرض الحالي للسائق
|
||||
* lng — خط الطول الحالي للسائق
|
||||
* radius — نطاق البحث بالكيلومترات (اختياري، افتراضي: 10)
|
||||
*
|
||||
* Response:
|
||||
* { success: true, zones: [...], predicted_hour: X, last_updated: "..." }
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────
|
||||
// يستخدم نفس آلية JWT الموجودة في النظام
|
||||
$driverId = getAuthDriverId(); // دالة موجودة في bootstrap/functions
|
||||
if (!$driverId) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Parameters ────────────────────────────────────────────────
|
||||
$driverLat = (float)filterRequest('lat');
|
||||
$driverLng = (float)filterRequest('lng');
|
||||
$radiusKm = (float)(filterRequest('radius') ?? 10);
|
||||
|
||||
// تحويل km إلى درجات تقريباً (1° ≈ 111km)
|
||||
$radiusDeg = $radiusKm / 111.0;
|
||||
|
||||
// ── Redis Cache ───────────────────────────────────────────────
|
||||
try {
|
||||
$redis = getRedisConnection();
|
||||
} catch (Exception $e) {
|
||||
$redis = null;
|
||||
}
|
||||
|
||||
$cacheRaw = $redis ? $redis->get('siro:cache:predictive_demand') : null;
|
||||
|
||||
if ($cacheRaw) {
|
||||
$cacheData = json_decode($cacheRaw, true);
|
||||
$allZones = $cacheData['zones'] ?? [];
|
||||
|
||||
// فلتر: فقط المناطق ضمن نطاق السائق
|
||||
$nearbyZones = array_filter($allZones, function ($z) use ($driverLat, $driverLng, $radiusDeg) {
|
||||
if ($driverLat == 0 || $driverLng == 0) return true; // لو ما أرسل موقعه، أرجع الكل
|
||||
return abs($z['lat'] - $driverLat) <= $radiusDeg
|
||||
&& abs($z['lng'] - $driverLng) <= $radiusDeg;
|
||||
});
|
||||
|
||||
// ترتيب: الأقرب للسائق أولاً
|
||||
if ($driverLat != 0) {
|
||||
usort($nearbyZones, function ($a, $b) use ($driverLat, $driverLng) {
|
||||
$da = abs($a['lat'] - $driverLat) + abs($a['lng'] - $driverLng);
|
||||
$db = abs($b['lat'] - $driverLat) + abs($b['lng'] - $driverLng);
|
||||
return $da <=> $db;
|
||||
});
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'source' => 'cache',
|
||||
'predicted_hour' => $cacheData['predicted_hour'] ?? null,
|
||||
'last_updated' => $cacheData['last_updated'] ?? null,
|
||||
'zones' => array_values($nearbyZones),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Fallback: حساب مباشر من DB إذا لم يتوفر cache ────────────
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => 'DB error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nextHour = ((int)date('H') + 1) % 24;
|
||||
$dow = (int)date('N');
|
||||
$gridSize = 0.01;
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
ROUND(pickup_lat / :g) * :g AS lat,
|
||||
ROUND(pickup_lng / :g) * :g AS lng,
|
||||
COUNT(*) AS demand_score,
|
||||
country_code
|
||||
FROM rides
|
||||
WHERE
|
||||
status IN ('completed', 'cancelled_by_driver', 'timeout')
|
||||
AND HOUR(created_at) = :hour
|
||||
AND DAYOFWEEK(created_at) = :dow
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 4 WEEK)
|
||||
AND pickup_lat BETWEEN (:dlat - :r) AND (:dlat + :r)
|
||||
AND pickup_lng BETWEEN (:dlng - :r) AND (:dlng + :r)
|
||||
GROUP BY lat, lng, country_code
|
||||
HAVING demand_score >= 2
|
||||
ORDER BY demand_score DESC
|
||||
LIMIT 10
|
||||
";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([
|
||||
':g' => $gridSize,
|
||||
':hour' => $nextHour,
|
||||
':dow' => $dow,
|
||||
':dlat' => $driverLat ?: 31.95, // fallback عمّان
|
||||
':dlng' => $driverLng ?: 35.93,
|
||||
':r' => $radiusDeg,
|
||||
]);
|
||||
$zones = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// تسمية المناطق
|
||||
foreach ($zones as &$z) {
|
||||
$stmtN = $con->prepare("
|
||||
SELECT zone_name FROM geofence_zones
|
||||
WHERE is_active = 1
|
||||
AND ABS(latitude - :lat) < 0.05
|
||||
AND ABS(longitude - :lng) < 0.05
|
||||
ORDER BY ABS(latitude - :lat) + ABS(longitude - :lng) ASC
|
||||
LIMIT 1
|
||||
");
|
||||
$stmtN->execute([':lat' => $z['lat'], ':lng' => $z['lng']]);
|
||||
$nameRow = $stmtN->fetch(PDO::FETCH_ASSOC);
|
||||
$z['zone_name'] = $nameRow['zone_name'] ?? 'منطقة قريبة';
|
||||
$z['lat'] = (float)$z['lat'];
|
||||
$z['lng'] = (float)$z['lng'];
|
||||
$z['demand_score'] = (int)$z['demand_score'];
|
||||
}
|
||||
unset($z);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'source' => 'realtime',
|
||||
'predicted_hour' => $nextHour,
|
||||
'last_updated' => date('Y-m-d H:i:s'),
|
||||
'zones' => $zones,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* sync_location.php
|
||||
* Unified endpoint for receiving passenger location updates from:
|
||||
* - App Usage (Primary)
|
||||
* - Geofencing Events (Secondary)
|
||||
* - Silent Push Wakeups (Tertiary)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
// require_once __DIR__ . '/../../functions.php';
|
||||
require_once __DIR__ . '/../../core/Services/LocationIntelligenceEngine.php';
|
||||
|
||||
// Validate JWT or traditional auth if needed. For now, rely on standard filterRequest if used.
|
||||
$passengerId = filterRequest('passenger_id');
|
||||
$lat = filterRequest('lat', 'float');
|
||||
$lng = filterRequest('lng', 'float');
|
||||
$source = filterRequest('source') ?? 'app_usage'; // 'app_usage', 'geofence', 'silent_push'
|
||||
$batteryLevel = filterRequest('battery_level', 'int');
|
||||
|
||||
if (!$passengerId || !$lat || !$lng) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Missing required parameters (passenger_id, lat, lng).']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$engine = new LocationIntelligenceEngine($con);
|
||||
$newGeofences = $engine->processLocationUpdate($passengerId, $lat, $lng, $source, $batteryLevel);
|
||||
|
||||
// Respond with success and optionally new geofences
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Location synced successfully',
|
||||
'update_geofences' => $newGeofences // App can use this array to update device geofencing regions
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log("[sync_location.php] Error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Internal server error.']);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// api/payments/get_prime_status.php
|
||||
// PURPOSE : جلب حالة اشتراك Siro Prime للراكب
|
||||
// AUTH : JWT (passenger)
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$passengerId = $user_id ?? null;
|
||||
if (!$passengerId || $role !== 'passenger') {
|
||||
jsonError("Unauthorized");
|
||||
exit;
|
||||
}
|
||||
|
||||
$isPrime = false;
|
||||
$expireAt = null;
|
||||
|
||||
// 1. تحقق من Redis أولاً (الأسرع)
|
||||
if (isset($redis) && $redis !== null) {
|
||||
try {
|
||||
$cached = $redis->get("prime:passenger:{$passengerId}");
|
||||
if ($cached) {
|
||||
$data = json_decode($cached, true);
|
||||
if (isset($data['is_prime']) && $data['is_prime'] == 1) {
|
||||
$expTime = strtotime($data['expire_at'] ?? '0');
|
||||
if ($expTime > time()) {
|
||||
$isPrime = true;
|
||||
$expireAt = $data['expire_at'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
}
|
||||
|
||||
// 2. إذا ما وُجد في Redis، ارجع للـ DB
|
||||
if (!$isPrime) {
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT is_prime, expire_at FROM passenger_prime_subscriptions WHERE passenger_id = :pid LIMIT 1");
|
||||
$stmt->execute([':pid' => $passengerId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row && $row['is_prime'] == 1 && strtotime($row['expire_at']) > time()) {
|
||||
$isPrime = true;
|
||||
$expireAt = $row['expire_at'];
|
||||
|
||||
// تحديث Redis للمرات القادمة
|
||||
if (isset($redis) && $redis !== null) {
|
||||
try {
|
||||
$redis->setex("prime:passenger:{$passengerId}", 3600, json_encode([
|
||||
'is_prime' => 1,
|
||||
'expire_at' => $expireAt
|
||||
]));
|
||||
} catch (Exception $e) {}
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log("[Prime Status] DB Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'is_prime' => $isPrime,
|
||||
'expire_at' => $expireAt,
|
||||
], "Prime status fetched");
|
||||
?>
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// api/payments/initiate_prime.php
|
||||
// PURPOSE : شراء اشتراك Siro Prime عبر خصم رصيد المحفظة الداخلية
|
||||
// AUTH : JWT (passenger)
|
||||
// FLOW :
|
||||
// 1. جلب هوية الراكب من JWT
|
||||
// 2. تحديد السعر حسب الدولة
|
||||
// 3. التحقق من رصيد الراكب في سيرفر المحفظة (S2S)
|
||||
// 4. إذا الرصيد كافٍ → الخصم + تفعيل Prime
|
||||
// 5. إذا الرصيد غير كافٍ → رسالة لإرشاد المستخدم للشحن
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// ── 1. هوية الراكب من JWT ─────────────────────────────────────
|
||||
$passengerId = $user_id ?? null;
|
||||
if (!$passengerId || $role !== 'passenger') {
|
||||
jsonError("Unauthorized");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 2. الدولة والسعر ──────────────────────────────────────────
|
||||
$country = filterRequest("country") ?: 'Jordan';
|
||||
|
||||
$pricingMap = [
|
||||
'Jordan' => ['amount' => 3.00, 'currency' => 'JOD'], // ~4 USD/month
|
||||
'Egypt' => ['amount' => 200.00,'currency' => 'EGP'], // ~4 USD/month
|
||||
'Syria' => ['amount' => 500.00,'currency' => 'SYP'], // ~4 USD/month (New Syrian Pound)
|
||||
];
|
||||
|
||||
$amount = $pricingMap[$country]['amount'] ?? 3.00;
|
||||
$currency = $pricingMap[$country]['currency'] ?? 'JOD';
|
||||
|
||||
// ── 3. سيرفر المحفظة حسب الدولة ──────────────────────────────
|
||||
$walletServer = "https://walletintaleq.intaleq.xyz"; // Default
|
||||
if (strtolower($country) === 'jordan') {
|
||||
$walletServer = getenv('WALLET_SERVER_JORDAN') ?: "https://walletintaleq.intaleq.xyz";
|
||||
} elseif (strtolower($country) === 'egypt') {
|
||||
$walletServer = getenv('WALLET_SERVER_EGYPT') ?: "https://wallet-egypt.siromove.com";
|
||||
} elseif (strtolower($country) === 'syria') {
|
||||
$walletServer = getenv('WALLET_SERVER_SYRIA') ?: "https://wallet-syria.siromove.com";
|
||||
}
|
||||
|
||||
$s2sKey = getenv('S2S_SHARED_KEY');
|
||||
if (empty($s2sKey)) {
|
||||
error_log("[Prime] CRITICAL: S2S_SHARED_KEY not set");
|
||||
jsonError("Server configuration error");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 4. التحقق من رصيد الراكب في سيرفر المحفظة ────────────────
|
||||
$balanceUrl = "$walletServer/v2/main/ride/passengerWallet/getWalletByPassenger.php";
|
||||
|
||||
$chBalance = curl_init($balanceUrl);
|
||||
curl_setopt_array($chBalance, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query(['passenger_id' => $passengerId]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . $s2sKey
|
||||
]
|
||||
]);
|
||||
|
||||
$balanceRaw = curl_exec($chBalance);
|
||||
$balanceCode = curl_getinfo($chBalance, CURLINFO_HTTP_CODE);
|
||||
$balanceErr = curl_error($chBalance);
|
||||
curl_close($chBalance);
|
||||
|
||||
if ($balanceErr || $balanceCode !== 200) {
|
||||
error_log("[Prime] Wallet balance fetch failed: HTTP $balanceCode | err: $balanceErr");
|
||||
jsonError("Unable to verify wallet balance. Please try again.");
|
||||
exit;
|
||||
}
|
||||
|
||||
$balanceData = json_decode($balanceRaw, true);
|
||||
$walletBalance = (float)($balanceData['message'][0]['total'] ?? $balanceData['total'] ?? -1);
|
||||
|
||||
if ($walletBalance < 0) {
|
||||
error_log("[Prime] Unexpected wallet response: $balanceRaw");
|
||||
jsonError("Unable to read wallet balance.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 5. هل الرصيد كافٍ؟ ────────────────────────────────────────
|
||||
if ($walletBalance < $amount) {
|
||||
// رصيد غير كافٍ — أخبر التطبيق ليوجّه المستخدم للشحن
|
||||
echo json_encode([
|
||||
'status' => 'insufficient_balance',
|
||||
'current_balance' => $walletBalance,
|
||||
'required_amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'message' => 'Your wallet balance is insufficient. Please top up your wallet to subscribe to Siro Prime.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 6. الرصيد كافٍ → بدء عملية الاشتراك ─────────────────────
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
// 6a. تسجيل الحركة في قاعدة بيانات سيرو (بادئها paid مباشرةً)
|
||||
$transactionRef = "PRIME-" . time() . "-" . rand(1000, 9999);
|
||||
$stmtTx = $con->prepare("
|
||||
INSERT INTO prime_payment_transactions (transaction_ref, passenger_id, amount, currency, status)
|
||||
VALUES (:ref, :pid, :amt, :curr, 'paid')
|
||||
");
|
||||
$stmtTx->execute([
|
||||
':ref' => $transactionRef,
|
||||
':pid' => $passengerId,
|
||||
':amt' => $amount,
|
||||
':curr' => $currency
|
||||
]);
|
||||
|
||||
// 6b. تفعيل أو تجديد اشتراك Prime (30 يوماً)
|
||||
$expireAt = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||
$stmtPrime = $con->prepare("
|
||||
INSERT INTO passenger_prime_subscriptions (passenger_id, is_prime, expire_at)
|
||||
VALUES (:pid, 1, :exp)
|
||||
ON DUPLICATE KEY UPDATE is_prime = 1, expire_at = :exp2, updated_at = NOW()
|
||||
");
|
||||
$stmtPrime->execute([
|
||||
':pid' => $passengerId,
|
||||
':exp' => $expireAt,
|
||||
':exp2' => $expireAt
|
||||
]);
|
||||
|
||||
// 6c. خصم المبلغ من المحفظة عبر S2S (نفس نمط tips/add.php)
|
||||
$deductUrl = "$walletServer/v2/main/ride/payment/add.php";
|
||||
$deductData = [
|
||||
"user_id" => $passengerId,
|
||||
"user_type" => "passenger",
|
||||
"amount" => -1 * $amount, // سالب = خصم
|
||||
"action" => "subtract",
|
||||
"paymentID" => $transactionRef,
|
||||
"paymentMethod" => "prime-subscription",
|
||||
"reason" => "Siro Prime Subscription - 1 Month"
|
||||
];
|
||||
|
||||
$chDeduct = curl_init($deductUrl);
|
||||
curl_setopt_array($chDeduct, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($deductData),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . $s2sKey
|
||||
]
|
||||
]);
|
||||
|
||||
$deductRaw = curl_exec($chDeduct);
|
||||
$deductCode = curl_getinfo($chDeduct, CURLINFO_HTTP_CODE);
|
||||
$deductErr = curl_error($chDeduct);
|
||||
curl_close($chDeduct);
|
||||
|
||||
$deductRes = json_decode($deductRaw, true);
|
||||
|
||||
if ($deductErr || $deductCode !== 200 || ($deductRes['status'] ?? '') !== 'success') {
|
||||
// فشل الخصم → نرجع الكل
|
||||
$con->rollBack();
|
||||
error_log("[Prime] Wallet deduct FAILED: HTTP $deductCode | err: $deductErr | response: $deductRaw");
|
||||
jsonError("Failed to deduct wallet balance. Please try again.");
|
||||
exit;
|
||||
}
|
||||
|
||||
$con->commit();
|
||||
|
||||
// 6d. تحديث Redis فوراً (التفعيل اللحظي بدون إعادة طلب من DB)
|
||||
if (isset($redis) && $redis !== null) {
|
||||
try {
|
||||
$primeKey = "prime:passenger:{$passengerId}";
|
||||
$redis->setex($primeKey, 3600, json_encode([
|
||||
'is_prime' => 1,
|
||||
'expire_at' => $expireAt
|
||||
]));
|
||||
} catch (Exception $e) {
|
||||
// Redis failure is non-critical — DB is source of truth
|
||||
error_log("[Prime] Redis update failed (non-critical): " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. ردّ النجاح للفلاتر ───────────────────────────────────
|
||||
jsonSuccess([
|
||||
'is_prime' => true,
|
||||
'expire_at' => $expireAt,
|
||||
'transaction_ref' => $transactionRef,
|
||||
'amount_deducted' => $amount,
|
||||
'currency' => $currency,
|
||||
], "Welcome to Siro Prime! 👑");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) {
|
||||
$con->rollBack();
|
||||
}
|
||||
error_log("[Prime] DB Error: " . $e->getMessage());
|
||||
jsonError("Database error. Please try again.");
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$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 || !$country) {
|
||||
echo json_encode(["status" => "error", "message" => "Missing parameters"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$lat = (float)$lat;
|
||||
$lng = (float)$lng;
|
||||
$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) {
|
||||
// Continue with defaults if Redis fails
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the competitor's total price based on distance and average market per-km rate
|
||||
// 🔥 لا يوجد أي تعديل صناعي على سعر المنافس هنا — الرقم المعروض للراكب
|
||||
// يجب أن يعكس بيانات السوق الحقيقية فقط، حتى لو لم نكن أرخص فعلياً في هذه الرحلة.
|
||||
$competitorTotalPrice = round($distance * $avgPricePerKm, 2);
|
||||
|
||||
// Format the labels
|
||||
$compNameAr = 'التطبيقات الأخرى';
|
||||
|
||||
// نعرض شارة "أوفر" فقط إذا كنا أرخص فعلياً حسب البيانات الحقيقية — لا تلاعب بالأرقام
|
||||
$savingsPct = 0;
|
||||
$savingsLabel = null;
|
||||
if ($competitorTotalPrice > 0 && $siroPrice > 0 && $siroPrice < $competitorTotalPrice) {
|
||||
$savingsPct = (($competitorTotalPrice - $siroPrice) / $competitorTotalPrice) * 100;
|
||||
$savingsLabel = "أوفر بـ " . number_format($savingsPct, 1) . "% من $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",
|
||||
"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
|
||||
]);
|
||||
?>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* get_hotzones.php
|
||||
* ───────────────
|
||||
* واجهة فائقة السرعة (Ultra-Fast API) مخصصة لتطبيق السائق (Flutter).
|
||||
* تقرأ المناطق الساخنة (Hot Zones) التي حددها الذكاء الاصطناعي من الـ Redis مباشرة.
|
||||
* زمن الاستجابة: O(1).
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$countryCode = strtoupper(filterRequest('country_code') ?? 'JO');
|
||||
|
||||
try {
|
||||
$redis = getRedisConnection();
|
||||
$hotZonesJson = $redis->get('siro:cache:ai:hotzones');
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(["status" => "error", "message" => "Redis connection failed"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$hotZonesJson) {
|
||||
echo json_encode(["status" => "success", "data" => [], "message" => "No hot zones available"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// الـ JSON القادم من Redis تم تصميمه بالفعل بالشكل النهائي المطلوب للتطبيق
|
||||
echo $hotZonesJson;
|
||||
?>
|
||||
@@ -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,94 +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');
|
||||
|
||||
// تشفير الإيميل لاستخدامه في الاستعلام
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// SQL لاسترجاع المستخدم بناءً على البريد الإلكتروني المشفر
|
||||
$sql = "SELECT
|
||||
driver.id,
|
||||
driver.phone,
|
||||
driver.email,
|
||||
driver.gender,
|
||||
driver.birthdate,
|
||||
driver.site,
|
||||
driver.first_name,
|
||||
driver.last_name,
|
||||
driver.bankCode,
|
||||
driver.accountBank,
|
||||
driver.employmentType,
|
||||
driver.maritalStatus,
|
||||
driver.created_at,
|
||||
driver.updated_at,
|
||||
driver.password,
|
||||
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) {
|
||||
// فحص الباسورد (في نظامنا، يمكن أن يكون الباسورد هو 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("
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user