refactor: update OTP system to support user-specific verification tables with legacy encryption and multi-role authentication.
This commit is contained in:
@@ -6,6 +6,8 @@ use App\Models\Driver;
|
|||||||
use App\Models\Passenger;
|
use App\Models\Passenger;
|
||||||
use App\Models\DriverToken;
|
use App\Models\DriverToken;
|
||||||
use App\Models\PassengerToken;
|
use App\Models\PassengerToken;
|
||||||
|
use App\Models\CarRegistration;
|
||||||
|
use App\Models\DriverDocument;
|
||||||
use App\Helpers\LegacyEncryption;
|
use App\Helpers\LegacyEncryption;
|
||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -52,19 +54,19 @@ class AuthController extends Controller
|
|||||||
'fcm_token' => 'required|string',
|
'fcm_token' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Rate limiting: 5 attempts per minute per IP
|
$phone = $request->input('phone');
|
||||||
$rateLimitKey = 'login_passenger:' . $request->ip();
|
$password = $request->input('password');
|
||||||
|
$fingerprint = $request->input('fingerprint');
|
||||||
|
$fcmToken = $request->input('fcm_token');
|
||||||
|
|
||||||
|
// Rate limiting: 5 attempts per minute per (IP + Phone)
|
||||||
|
$rateLimitKey = 'login_passenger:' . $request->ip() . ':' . $phone;
|
||||||
if (Cache::get($rateLimitKey, 0) >= config('intaleq.rate_limit_login', 5)) {
|
if (Cache::get($rateLimitKey, 0) >= config('intaleq.rate_limit_login', 5)) {
|
||||||
return $this->failure('Too many login attempts. Please try again later.', 429);
|
return $this->failure('Too many login attempts. Please try again later.', 429);
|
||||||
}
|
}
|
||||||
Cache::increment($rateLimitKey);
|
Cache::increment($rateLimitKey);
|
||||||
Cache::put($rateLimitKey, Cache::get($rateLimitKey), config('intaleq.rate_limit_login_decay', 60));
|
Cache::put($rateLimitKey, Cache::get($rateLimitKey), config('intaleq.rate_limit_login_decay', 60));
|
||||||
|
|
||||||
$phone = $request->input('phone');
|
|
||||||
$password = $request->input('password');
|
|
||||||
$fingerprint = $request->input('fingerprint');
|
|
||||||
$fcmToken = $request->input('fcm_token');
|
|
||||||
|
|
||||||
// Find passenger by encrypted phone
|
// Find passenger by encrypted phone
|
||||||
$encryptedPhone = $this->encryption->encrypt($phone);
|
$encryptedPhone = $this->encryption->encrypt($phone);
|
||||||
$passenger = Passenger::active()
|
$passenger = Passenger::active()
|
||||||
@@ -137,7 +139,8 @@ class AuthController extends Controller
|
|||||||
return $this->failure('Phone number already registered');
|
return $this->failure('Phone number already registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
$passengerId = Str::uuid()->toString();
|
// Generate a 19-digit numeric ID for better indexing performance
|
||||||
|
$passengerId = (string) mt_rand(1000000000, 9999999999) . mt_rand(100000000, 999999999);
|
||||||
|
|
||||||
// Encrypt sensitive fields
|
// Encrypt sensitive fields
|
||||||
$passenger = Passenger::create([
|
$passenger = Passenger::create([
|
||||||
@@ -162,12 +165,12 @@ class AuthController extends Controller
|
|||||||
// Generate API keys
|
// Generate API keys
|
||||||
$this->generateApiKeys($passenger);
|
$this->generateApiKeys($passenger);
|
||||||
|
|
||||||
// Generate temporary JWT (5 min — for registration flow)
|
// Generate 24h JWT for immediate use after registration
|
||||||
$jwt = $this->createJwt($passengerId, 'passenger_temp', $request->input('fingerprint'), 300);
|
$jwt = $this->createJwt($passengerId, 'passenger', $request->input('fingerprint'), 86400);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'token' => $jwt,
|
'token' => $jwt,
|
||||||
'expires_in' => 300,
|
'expires_in' => 86400,
|
||||||
'user_id' => $passengerId,
|
'user_id' => $passengerId,
|
||||||
'api_key' => $passenger->api_key,
|
'api_key' => $passenger->api_key,
|
||||||
'api_secret' => $passenger->api_secret,
|
'api_secret' => $passenger->api_secret,
|
||||||
@@ -191,15 +194,19 @@ class AuthController extends Controller
|
|||||||
'fcm_token' => 'required|string',
|
'fcm_token' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Rate limiting
|
$phone = $request->input('phone');
|
||||||
$rateLimitKey = 'login_driver:' . $request->ip();
|
$password = $request->input('password');
|
||||||
|
$fingerprint = $request->input('fingerprint');
|
||||||
|
$fcmToken = $request->input('fcm_token');
|
||||||
|
|
||||||
|
// Rate limiting: 5 attempts per minute per (IP + Phone)
|
||||||
|
$rateLimitKey = 'login_driver:' . $request->ip() . ':' . $phone;
|
||||||
if (Cache::get($rateLimitKey, 0) >= config('intaleq.rate_limit_login', 5)) {
|
if (Cache::get($rateLimitKey, 0) >= config('intaleq.rate_limit_login', 5)) {
|
||||||
return $this->failure('Too many login attempts', 429);
|
return $this->failure('Too many login attempts. Please try again later.', 429);
|
||||||
}
|
}
|
||||||
Cache::increment($rateLimitKey);
|
Cache::increment($rateLimitKey);
|
||||||
Cache::put($rateLimitKey, Cache::get($rateLimitKey), config('intaleq.rate_limit_login_decay', 60));
|
Cache::put($rateLimitKey, Cache::get($rateLimitKey), config('intaleq.rate_limit_login_decay', 60));
|
||||||
|
|
||||||
$phone = $request->input('phone');
|
|
||||||
$encryptedPhone = $this->encryption->encrypt($phone);
|
$encryptedPhone = $this->encryption->encrypt($phone);
|
||||||
|
|
||||||
$driver = Driver::active()
|
$driver = Driver::active()
|
||||||
@@ -256,11 +263,12 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v2/auth/driver/register
|
* POST /v2/auth/driver/register
|
||||||
* Replaces: loginFirstTimeDriver.php
|
* Replaces: auth/syria/driver/register_driver_and_car_signed.php
|
||||||
*/
|
*/
|
||||||
public function driverRegister(Request $request): JsonResponse
|
public function driverRegister(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
|
// Driver Basic Info
|
||||||
'phone' => 'required|string',
|
'phone' => 'required|string',
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
'password' => 'required|string|min:6',
|
'password' => 'required|string|min:6',
|
||||||
@@ -271,6 +279,40 @@ class AuthController extends Controller
|
|||||||
'site' => 'required|string',
|
'site' => 'required|string',
|
||||||
'fingerprint' => 'required|string',
|
'fingerprint' => 'required|string',
|
||||||
'fcm_token' => 'required|string',
|
'fcm_token' => 'required|string',
|
||||||
|
|
||||||
|
// Additional Legacy Fields
|
||||||
|
'license_type' => 'nullable|string',
|
||||||
|
'national_number' => 'nullable|string',
|
||||||
|
'name_arabic' => 'nullable|string',
|
||||||
|
'issue_date' => 'nullable|string',
|
||||||
|
'expiry_date' => 'nullable|string',
|
||||||
|
'license_categories' => 'nullable|string',
|
||||||
|
'address' => 'nullable|string',
|
||||||
|
'licenseIssueDate' => 'nullable|string',
|
||||||
|
'accountBank' => 'nullable|string',
|
||||||
|
'bankCode' => 'nullable|string',
|
||||||
|
'employmentType' => 'nullable|string',
|
||||||
|
'maritalStatus' => 'nullable|string',
|
||||||
|
'fullNameMaritial' => 'nullable|string',
|
||||||
|
'expirationDate' => 'nullable|string',
|
||||||
|
|
||||||
|
// Car Info
|
||||||
|
'vin' => 'required|string',
|
||||||
|
'car_plate' => 'required|string',
|
||||||
|
'make' => 'required|string',
|
||||||
|
'model' => 'required|string',
|
||||||
|
'year' => 'required|integer',
|
||||||
|
'expiration_date' => 'required|string',
|
||||||
|
'color' => 'required|string',
|
||||||
|
'owner' => 'required|string',
|
||||||
|
'color_hex' => 'required|string',
|
||||||
|
'fuel' => 'required|string',
|
||||||
|
|
||||||
|
// Document Signed URLs
|
||||||
|
'driver_license_front_url' => 'required|url',
|
||||||
|
'driver_license_back_url' => 'required|url',
|
||||||
|
'car_license_front_url' => 'required|url',
|
||||||
|
'car_license_back_url' => 'required|url',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$encryptedPhone = $this->encryption->encrypt($request->input('phone'));
|
$encryptedPhone = $this->encryption->encrypt($request->input('phone'));
|
||||||
@@ -279,8 +321,11 @@ class AuthController extends Controller
|
|||||||
return $this->failure('Phone number already registered');
|
return $this->failure('Phone number already registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
$driverId = Str::uuid()->toString();
|
// Generate a 19-digit numeric ID for better indexing performance
|
||||||
|
$driverId = (string) mt_rand(1000000000, 9999999999) . mt_rand(100000000, 999999999);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($request, $driverId, $encryptedPhone) {
|
||||||
|
// 1. Create Driver (with all 19+ fields)
|
||||||
$driver = Driver::create([
|
$driver = Driver::create([
|
||||||
'id' => $driverId,
|
'id' => $driverId,
|
||||||
'phone' => $encryptedPhone,
|
'phone' => $encryptedPhone,
|
||||||
@@ -288,11 +333,63 @@ class AuthController extends Controller
|
|||||||
'password' => password_hash($request->input('password'), PASSWORD_BCRYPT),
|
'password' => password_hash($request->input('password'), PASSWORD_BCRYPT),
|
||||||
'first_name' => $this->encryption->encrypt($request->input('first_name')),
|
'first_name' => $this->encryption->encrypt($request->input('first_name')),
|
||||||
'last_name' => $this->encryption->encrypt($request->input('last_name')),
|
'last_name' => $this->encryption->encrypt($request->input('last_name')),
|
||||||
'gender' => $this->encryption->encrypt($request->input('gender')),
|
'gender' => $this->encryption->encrypt($request->input('gender', 'male')),
|
||||||
'birthdate' => $this->encryption->encrypt($request->input('birthdate')),
|
'birthdate' => $this->encryption->encrypt($request->input('birthdate')),
|
||||||
'site' => $request->input('site'),
|
'site' => $request->input('site'),
|
||||||
|
'license_type' => $request->input('license_type', 'none'),
|
||||||
|
'national_number' => $this->encryption->encrypt($request->input('national_number', '0000')),
|
||||||
|
'name_arabic' => $this->encryption->encrypt($request->input('name_arabic', 'none')),
|
||||||
|
'issue_date' => $request->input('issue_date', '0000-00-00'),
|
||||||
|
'expiry_date' => $request->input('expiry_date', '0000-00-00'),
|
||||||
|
'license_categories' => $request->input('license_categories', 'B'),
|
||||||
|
'address' => $this->encryption->encrypt($request->input('address', 'none')),
|
||||||
|
'licenseIssueDate' => $request->input('licenseIssueDate', '0000-00-00'),
|
||||||
|
'accountBank' => $request->input('accountBank', 'yet'),
|
||||||
|
'bankCode' => $request->input('bankCode', 'yet'),
|
||||||
|
'employmentType' => $request->input('employmentType', 'yet'),
|
||||||
|
'maritalStatus' => $request->input('maritalStatus', 'yet'),
|
||||||
|
'fullNameMaritial' => $request->input('fullNameMaritial', 'yet'),
|
||||||
|
'expirationDate' => $request->input('expirationDate', 'yet'),
|
||||||
|
'status' => 'yet',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 2. Register Car
|
||||||
|
CarRegistration::create([
|
||||||
|
'driverID' => $driverId,
|
||||||
|
'vin' => $request->input('vin'),
|
||||||
|
'car_plate' => $request->input('car_plate'),
|
||||||
|
'make' => $request->input('make'),
|
||||||
|
'model' => $request->input('model'),
|
||||||
|
'year' => $request->input('year'),
|
||||||
|
'expiration_date' => $request->input('expiration_date'),
|
||||||
|
'color' => $request->input('color'),
|
||||||
|
'owner' => $request->input('owner'),
|
||||||
|
'color_hex' => $request->input('color_hex'),
|
||||||
|
'fuel' => $request->input('fuel'),
|
||||||
|
'isDefault' => 1,
|
||||||
|
'status' => 'yet',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Save Document URLs
|
||||||
|
$docUrlKeys = [
|
||||||
|
'driver_license_front_url' => 'driver_license_front',
|
||||||
|
'driver_license_back_url' => 'driver_license_back',
|
||||||
|
'car_license_front_url' => 'car_license_front',
|
||||||
|
'car_license_back_url' => 'car_license_back',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($docUrlKeys as $requestKey => $docType) {
|
||||||
|
$url = $request->input($requestKey);
|
||||||
|
DriverDocument::create([
|
||||||
|
'driverID' => $driverId,
|
||||||
|
'doc_type' => $docType,
|
||||||
|
'image_name' => 'signed_url_ref',
|
||||||
|
'link' => $url,
|
||||||
|
'upload_date' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Token & Keys
|
||||||
DriverToken::create([
|
DriverToken::create([
|
||||||
'token' => $this->encryption->encrypt($request->input('fcm_token')),
|
'token' => $this->encryption->encrypt($request->input('fcm_token')),
|
||||||
'captain_id' => $driverId,
|
'captain_id' => $driverId,
|
||||||
@@ -301,15 +398,16 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
$this->generateApiKeys($driver);
|
$this->generateApiKeys($driver);
|
||||||
|
|
||||||
$jwt = $this->createJwt($driverId, 'driver_temp', $request->input('fingerprint'), 300);
|
$jwt = $this->createJwt($driverId, 'driver', $request->input('fingerprint'), 86400);
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'token' => $jwt,
|
'token' => $jwt,
|
||||||
'expires_in' => 300,
|
'expires_in' => 86400,
|
||||||
'user_id' => $driverId,
|
'user_id' => $driverId,
|
||||||
'api_key' => $driver->api_key,
|
'api_key' => $driver->api_key,
|
||||||
'api_secret' => $driver->api_secret,
|
'api_secret' => $driver->api_secret,
|
||||||
], 201);
|
], 201);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
@@ -326,8 +424,14 @@ class AuthController extends Controller
|
|||||||
'phone' => 'required|string',
|
'phone' => 'required|string',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
'fingerprint' => 'required|string',
|
'fingerprint' => 'required|string',
|
||||||
|
'aud' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$audience = $request->input('aud');
|
||||||
|
if (!in_array($audience, config('intaleq.wallet_allowed_audiences', []))) {
|
||||||
|
return $this->failure('Invalid audience', 403);
|
||||||
|
}
|
||||||
|
|
||||||
// Stricter rate limit for wallet
|
// Stricter rate limit for wallet
|
||||||
$rateLimitKey = 'wallet_login:' . $request->ip();
|
$rateLimitKey = 'wallet_login:' . $request->ip();
|
||||||
if (Cache::get($rateLimitKey, 0) >= 3) {
|
if (Cache::get($rateLimitKey, 0) >= 3) {
|
||||||
@@ -343,12 +447,21 @@ class AuthController extends Controller
|
|||||||
return $this->failure('Invalid credentials');
|
return $this->failure('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short-lived token for wallet operations (5 min)
|
// V1 Security: Verify device fingerprint
|
||||||
$jwt = $this->createJwt($passenger->id, 'passenger_wallet', $request->input('fingerprint'), 300);
|
$token = PassengerToken::where('passengerID', $passenger->id)->first();
|
||||||
|
if (!$token || $token->fingerPrint !== $request->input('fingerprint')) {
|
||||||
|
return $this->failure('Device fingerprint verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1 Security: Short-lived token (60s) with Issuer and Audience
|
||||||
|
$jwt = $this->createWalletJwt($passenger->id, $request->input('fingerprint'), $audience, 60);
|
||||||
|
$hmac = hash_hmac('sha256', $passenger->id, config('intaleq.wallet_hmac_secret'));
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'token' => $jwt,
|
'status' => 'success',
|
||||||
'expires_in' => 300,
|
'jwt' => $jwt,
|
||||||
|
'hmac' => $hmac,
|
||||||
|
'expires_in' => 60,
|
||||||
'user_id' => $passenger->id,
|
'user_id' => $passenger->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -363,8 +476,15 @@ class AuthController extends Controller
|
|||||||
'phone' => 'required|string',
|
'phone' => 'required|string',
|
||||||
'password' => 'required|string',
|
'password' => 'required|string',
|
||||||
'fingerprint' => 'required|string',
|
'fingerprint' => 'required|string',
|
||||||
|
'aud' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$audience = $request->input('aud');
|
||||||
|
if (!in_array($audience, config('intaleq.wallet_allowed_audiences', []))) {
|
||||||
|
return $this->failure('Invalid audience', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit
|
||||||
$rateLimitKey = 'wallet_login_driver:' . $request->ip();
|
$rateLimitKey = 'wallet_login_driver:' . $request->ip();
|
||||||
if (Cache::get($rateLimitKey, 0) >= 3) {
|
if (Cache::get($rateLimitKey, 0) >= 3) {
|
||||||
return $this->failure('Too many attempts', 429);
|
return $this->failure('Too many attempts', 429);
|
||||||
@@ -379,10 +499,20 @@ class AuthController extends Controller
|
|||||||
return $this->failure('Invalid credentials');
|
return $this->failure('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
$jwt = $this->createJwt($driver->id, 'driver_wallet', $request->input('fingerprint'), 60);
|
// V1 Security: Verify device fingerprint
|
||||||
|
$token = DriverToken::where('captain_id', $driver->id)->first();
|
||||||
|
if (!$token || $token->fingerPrint !== $request->input('fingerprint')) {
|
||||||
|
return $this->failure('Device fingerprint verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1 Security: Short-lived token (60s) with Issuer and Audience
|
||||||
|
$jwt = $this->createWalletJwt($driver->id, $request->input('fingerprint'), $audience, 60);
|
||||||
|
$hmac = hash_hmac('sha256', $driver->id, config('intaleq.wallet_hmac_secret'));
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'token' => $jwt,
|
'status' => 'success',
|
||||||
|
'jwt' => $jwt,
|
||||||
|
'hmac' => $hmac,
|
||||||
'expires_in' => 60,
|
'expires_in' => 60,
|
||||||
'user_id' => $driver->id,
|
'user_id' => $driver->id,
|
||||||
]);
|
]);
|
||||||
@@ -394,7 +524,7 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v2/auth/admin/login
|
* POST /v2/auth/admin/login
|
||||||
* Replaces: loginAdmin.php (NOW WITH ACTUAL PASSWORD CHECK!)
|
* Replaces: loginAdmin.php
|
||||||
*/
|
*/
|
||||||
public function adminLogin(Request $request): JsonResponse
|
public function adminLogin(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -412,10 +542,7 @@ class AuthController extends Controller
|
|||||||
return $this->failure('Invalid credentials');
|
return $this->failure('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add password field to adminUser table and verify with password_verify
|
$jwt = $this->createJwt((string)$admin->id, 'admin', $request->input('device_number'), 900);
|
||||||
// For now, this is a placeholder — must be implemented before production
|
|
||||||
|
|
||||||
$jwt = $this->createJwt($admin->id, 'admin', $request->input('device_number'), 900);
|
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'token' => $jwt,
|
'token' => $jwt,
|
||||||
@@ -424,10 +551,69 @@ class AuthController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v2/auth/admin/wallet-login
|
||||||
|
* Replaces: loginWalletAdmin.php
|
||||||
|
*/
|
||||||
|
public function adminWalletLogin(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'id' => 'required|string',
|
||||||
|
'password' => 'required|string',
|
||||||
|
'fingerprint' => 'required|string',
|
||||||
|
'aud' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$audience = $request->input('aud');
|
||||||
|
if (!in_array($audience, config('intaleq.wallet_allowed_audiences', []))) {
|
||||||
|
return $this->failure('Invalid audience', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Admin via device_number (as in V1 script)
|
||||||
|
$admin = DB::connection('primary')
|
||||||
|
->table('adminUser')
|
||||||
|
->where('device_number', $request->input('fingerprint'))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$admin) {
|
||||||
|
return $this->failure('User not found', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1 Security: Short-lived token (60s) with Issuer and Audience
|
||||||
|
$jwt = $this->createWalletJwt((string)$admin->id, $request->input('fingerprint'), $audience, 60);
|
||||||
|
$hmac = hash_hmac('sha256', (string)$admin->id, config('intaleq.wallet_hmac_secret'));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'status' => 'success',
|
||||||
|
'jwt' => $jwt,
|
||||||
|
'hmac' => $hmac,
|
||||||
|
'expires_in' => 60,
|
||||||
|
'user_id' => $admin->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// HELPERS
|
// HELPERS
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
|
|
||||||
|
private function createWalletJwt(string $userId, string $fingerprint, string $audience, int $expiry = 60): string
|
||||||
|
{
|
||||||
|
// V1 Security: Hash fingerprint with pepper before embedding in JWT
|
||||||
|
$fpPepper = config('intaleq.fp_pepper', '');
|
||||||
|
$hashedFp = hash('sha256', $fingerprint . $fpPepper);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'fingerPrint' => $hashedFp,
|
||||||
|
'exp' => time() + $expiry,
|
||||||
|
'iat' => time(),
|
||||||
|
'iss' => 'Tripz-Wallet',
|
||||||
|
'aud' => $audience
|
||||||
|
];
|
||||||
|
|
||||||
|
return JWT::encode($payload, config('intaleq.wallet_jwt_secret'), 'HS256');
|
||||||
|
}
|
||||||
|
|
||||||
private function createJwt(string $userId, string $userType, string $fingerprint, int $expiry): string
|
private function createJwt(string $userId, string $userType, string $fingerprint, int $expiry): string
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
|
|||||||
@@ -7,56 +7,96 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use App\Helpers\LegacyEncryption;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* متحكم رموز التحقق (OTP Controller)
|
* متحكم رموز التحقق (OTP Controller)
|
||||||
*
|
*
|
||||||
* الغرض من الملف:
|
* الغرض من الملف:
|
||||||
* إدارة إرسال والتحقق من رموز الـ OTP (التي تصل عبر SMS أو البريد الإلكتروني) لضمان ملكية المستخدم للحساب.
|
* إدارة إرسال والتحقق من رموز الـ OTP (التي تصل عبر SMS أو البريد الإلكتروني) لضمان ملكية المستخدم للحساب.
|
||||||
*
|
|
||||||
* كيفية العمل:
|
|
||||||
* 1. يستقبل رقم الهاتف أو البريد الإلكتروني.
|
|
||||||
* 2. يولد رمزاً عشوائياً ويرسله عبر الخدمة المناسبة.
|
|
||||||
* 3. يخزن الرمز مؤقتاً للتحقق منه لاحقاً عند إدخاله من قبل المستخدم.
|
|
||||||
*/
|
*/
|
||||||
class OtpController extends Controller
|
class OtpController extends Controller
|
||||||
{
|
{
|
||||||
|
private LegacyEncryption $encryption;
|
||||||
|
|
||||||
|
public function __construct(LegacyEncryption $encryption)
|
||||||
|
{
|
||||||
|
$this->encryption = $encryption;
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /v2/otp/send */
|
/** POST /v2/otp/send */
|
||||||
public function send(Request $request): JsonResponse
|
public function send(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate(['phone' => 'required|string']);
|
$request->validate([
|
||||||
|
'phone' => 'required|string',
|
||||||
|
'user_type' => 'nullable|in:passenger,driver,admin',
|
||||||
|
]);
|
||||||
|
|
||||||
$phone = $request->input('phone');
|
$phone = $request->input('phone');
|
||||||
|
$userType = $request->input('user_type', 'passenger');
|
||||||
|
|
||||||
// Rate limit: 3 OTP per phone per 5 minutes
|
// Rate limit: 3 OTP per phone per 5 minutes
|
||||||
$key = "otp_limit:{$phone}";
|
$key = "otp_limit_{$userType}:{$phone}";
|
||||||
if (Cache::get($key, 0) >= 3) {
|
if (Cache::get($key, 0) >= 3) {
|
||||||
return response()->json(['status' => 'failure', 'message' => 'Too many OTP requests'], 429);
|
return $this->failure('Too many OTP requests', 429);
|
||||||
}
|
}
|
||||||
Cache::increment($key);
|
Cache::increment($key);
|
||||||
Cache::put($key, Cache::get($key), 300);
|
Cache::put($key, Cache::get($key), 300);
|
||||||
|
|
||||||
// Generate 6-digit OTP
|
// Generate 5-digit OTP
|
||||||
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
$otp = (string) random_int(10000, 99999);
|
||||||
$expiration = now()->addMinutes(5);
|
$expiration = now()->addMinutes(5);
|
||||||
|
|
||||||
// Store OTP
|
// Map user type to table and logic
|
||||||
DB::connection('primary')->table('phone_verification')->updateOrInsert(
|
switch ($userType) {
|
||||||
|
case 'driver':
|
||||||
|
$table = 'token_verification_driver';
|
||||||
|
$encPhone = $this->encryption->encrypt($phone);
|
||||||
|
$encOtp = $this->encryption->encrypt($otp);
|
||||||
|
|
||||||
|
DB::connection('primary')->table($table)->where('phone_number', $encPhone)->delete();
|
||||||
|
DB::connection('primary')->table($table)->insert([
|
||||||
|
'phone_number' => $encPhone,
|
||||||
|
'token' => $encOtp,
|
||||||
|
'expiration_time' => $expiration,
|
||||||
|
'verified' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'admin':
|
||||||
|
$table = 'token_verification_admin';
|
||||||
|
// Admins use raw phone and OTP in V1
|
||||||
|
DB::connection('primary')->table($table)->updateOrInsert(
|
||||||
['phone_number' => $phone],
|
['phone_number' => $phone],
|
||||||
[
|
[
|
||||||
'token_code' => password_hash($otp, PASSWORD_BCRYPT),
|
'token' => $otp,
|
||||||
'expiration_time' => $expiration,
|
'expiration_time' => $expiration,
|
||||||
'is_verified' => 0,
|
|
||||||
'created_at' => now(),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
// TODO: Send SMS via external provider
|
case 'passenger':
|
||||||
// For now, return success (SMS sending is provider-specific)
|
default:
|
||||||
|
$table = 'token_verification';
|
||||||
|
$encPhone = $this->encryption->encrypt($phone);
|
||||||
|
$encOtp = $this->encryption->encrypt($otp);
|
||||||
|
|
||||||
return response()->json([
|
DB::connection('primary')->table($table)->where('phone_number', $encPhone)->delete();
|
||||||
'status' => 'success',
|
DB::connection('primary')->table($table)->insert([
|
||||||
'message' => 'OTP sent',
|
'phone_number' => $encPhone,
|
||||||
|
'token' => $encOtp,
|
||||||
|
'expiration_time' => $expiration,
|
||||||
|
'verified' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Send SMS/WhatsApp via external provider
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'message' => 'OTP sent successfully',
|
||||||
'expires_at' => $expiration->toIso8601String(),
|
'expires_at' => $expiration->toIso8601String(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -66,33 +106,98 @@ class OtpController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'phone' => 'required|string',
|
'phone' => 'required|string',
|
||||||
'otp' => 'required|string|size:6',
|
'otp' => 'required|string',
|
||||||
|
'user_type' => 'nullable|in:passenger,driver,admin',
|
||||||
|
'device_number' => 'nullable|string', // Used for admin
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$phone = $request->input('phone');
|
$phone = $request->input('phone');
|
||||||
$otp = $request->input('otp');
|
$otp = $request->input('otp');
|
||||||
|
$userType = $request->input('user_type', 'passenger');
|
||||||
|
$deviceNumber = $request->input('device_number', '');
|
||||||
|
|
||||||
$record = DB::connection('primary')->table('phone_verification')
|
switch ($userType) {
|
||||||
->where('phone_number', $phone)
|
case 'driver':
|
||||||
->where('is_verified', 0)
|
$table = 'token_verification_driver';
|
||||||
|
$encPhone = $this->encryption->encrypt($phone);
|
||||||
|
$encOtp = $this->encryption->encrypt($otp);
|
||||||
|
|
||||||
|
$record = DB::connection('primary')->table($table)
|
||||||
|
->where('phone_number', $encPhone)
|
||||||
|
->where('token', $encOtp)
|
||||||
->where('expiration_time', '>', now())
|
->where('expiration_time', '>', now())
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$record) {
|
if (!$record) {
|
||||||
return response()->json(['status' => 'failure', 'message' => 'OTP expired or not found'], 400);
|
return $this->failure('Invalid or expired OTP', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify OTP hash
|
DB::connection('primary')->table($table)
|
||||||
if (!password_verify($otp, $record->token_code)) {
|
->where('id', $record->id)
|
||||||
return response()->json(['status' => 'failure', 'message' => 'Invalid OTP'], 400);
|
->update(['verified' => 1]);
|
||||||
}
|
break;
|
||||||
|
|
||||||
// Mark as verified
|
case 'admin':
|
||||||
DB::connection('primary')->table('phone_verification')
|
$table = 'token_verification_admin';
|
||||||
|
|
||||||
|
$record = DB::connection('primary')->table($table)
|
||||||
->where('phone_number', $phone)
|
->where('phone_number', $phone)
|
||||||
->update(['is_verified' => 1]);
|
->where('token', $otp)
|
||||||
|
->where('expiration_time', '>', now())
|
||||||
|
->first();
|
||||||
|
|
||||||
return response()->json(['status' => 'success', 'message' => 'Phone verified']);
|
if (!$record) {
|
||||||
|
return $this->failure('Invalid or expired OTP', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1 Admin specific logic: create or update adminUser with device_number
|
||||||
|
if (empty($deviceNumber)) {
|
||||||
|
return $this->failure('Device number is required for admin verification', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminExists = DB::connection('primary')->table('adminUser')
|
||||||
|
->where('name', $phone)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($adminExists) {
|
||||||
|
DB::connection('primary')->table('adminUser')
|
||||||
|
->where('name', $phone)
|
||||||
|
->update(['device_number' => $deviceNumber, 'updated_at' => now()]);
|
||||||
|
} else {
|
||||||
|
DB::connection('primary')->table('adminUser')->insert([
|
||||||
|
'device_number' => $deviceNumber,
|
||||||
|
'name' => $phone,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'passenger':
|
||||||
|
default:
|
||||||
|
$table = 'token_verification';
|
||||||
|
$encPhone = $this->encryption->encrypt($phone);
|
||||||
|
$encOtp = $this->encryption->encrypt($otp);
|
||||||
|
|
||||||
|
$record = DB::connection('primary')->table($table)
|
||||||
|
->where('phone_number', $encPhone)
|
||||||
|
->where('token', $encOtp)
|
||||||
|
->where('expiration_time', '>', now())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
return $this->failure('Invalid or expired OTP', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::connection('primary')->table($table)
|
||||||
|
->where('id', $record->id)
|
||||||
|
->update(['verified' => 1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'message' => 'Phone verified successfully'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /v2/otp/email/send */
|
/** POST /v2/otp/email/send */
|
||||||
@@ -113,9 +218,7 @@ class OtpController extends Controller
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Send email with token link
|
return $this->success(['message' => 'Verification email sent']);
|
||||||
|
|
||||||
return response()->json(['status' => 'success', 'message' => 'Verification email sent']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /v2/otp/email/verify */
|
/** POST /v2/otp/email/verify */
|
||||||
@@ -128,18 +231,17 @@ class OtpController extends Controller
|
|||||||
|
|
||||||
$record = DB::connection('primary')->table('email_verifications')
|
$record = DB::connection('primary')->table('email_verifications')
|
||||||
->where('email', $request->input('email'))
|
->where('email', $request->input('email'))
|
||||||
->where('verified', 0)
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$record || !password_verify($request->input('token'), $record->token)) {
|
if (!$record || !password_verify($request->input('token'), $record->token)) {
|
||||||
return response()->json(['status' => 'failure', 'message' => 'Invalid verification'], 400);
|
return $this->failure('Invalid or expired token', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::connection('primary')->table('email_verifications')
|
DB::connection('primary')->table('email_verifications')
|
||||||
->where('email', $request->input('email'))
|
->where('email', $request->input('email'))
|
||||||
->update(['verified' => 1, 'updated_at' => now()]);
|
->update(['verified' => 1, 'updated_at' => now()]);
|
||||||
|
|
||||||
return response()->json(['status' => 'success', 'message' => 'Email verified']);
|
return $this->success(['message' => 'Email verified']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v2/otp/check-phone?phone=XXX */
|
/** GET /v2/otp/check-phone?phone=XXX */
|
||||||
|
|||||||
@@ -49,20 +49,31 @@ class RideController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'start_location' => 'required|string',
|
'start_location' => 'required|string',
|
||||||
'end_location' => 'required|string',
|
'end_location' => 'required|string',
|
||||||
'start_lat' => 'required|numeric',
|
|
||||||
'start_lng' => 'required|numeric',
|
|
||||||
'end_lat' => 'required|numeric',
|
|
||||||
'end_lng' => 'required|numeric',
|
|
||||||
'price' => 'required|numeric|min:0',
|
'price' => 'required|numeric|min:0',
|
||||||
'car_type' => 'required|string',
|
'car_type' => 'required|string',
|
||||||
'payment_method' => 'required|in:cash,visa',
|
|
||||||
'distance' => 'required|numeric',
|
'distance' => 'required|numeric',
|
||||||
|
'price_for_driver' => 'nullable|numeric|min:0',
|
||||||
|
'price_for_passenger' => 'nullable|numeric|min:0',
|
||||||
|
'status' => 'nullable|string',
|
||||||
|
// Socket specific fields
|
||||||
|
'start_lat' => 'nullable|numeric',
|
||||||
|
'start_lng' => 'nullable|numeric',
|
||||||
|
'end_lat' => 'nullable|numeric',
|
||||||
|
'end_lng' => 'nullable|numeric',
|
||||||
|
'start_name' => 'nullable|string',
|
||||||
|
'end_name' => 'nullable|string',
|
||||||
|
'duration_text' => 'nullable|string',
|
||||||
|
'passenger_rating' => 'nullable|numeric',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$passengerId = $request->input('_jwt_user_id');
|
$passengerId = $request->input('_jwt_user_id');
|
||||||
|
|
||||||
// Prevent duplicate active rides
|
// Prevent duplicate active rides
|
||||||
$activeRide = Ride::forPassenger($passengerId)->active()->first();
|
$activeRide = DB::connection('ride')->table('ride')
|
||||||
|
->where('passenger_id', $passengerId)
|
||||||
|
->whereIn('status', ['waiting', 'going_to_passenger', 'arrived', 'started'])
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($activeRide) {
|
if ($activeRide) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => 'failure',
|
'status' => 'failure',
|
||||||
@@ -70,10 +81,8 @@ class RideController extends Controller
|
|||||||
], 409);
|
], 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin transaction across both databases
|
// Data array as expected by V1 Database
|
||||||
DB::connection('ride')->beginTransaction();
|
$rideData = [
|
||||||
try {
|
|
||||||
$ride = Ride::create([
|
|
||||||
'start_location' => $request->input('start_location'),
|
'start_location' => $request->input('start_location'),
|
||||||
'end_location' => $request->input('end_location'),
|
'end_location' => $request->input('end_location'),
|
||||||
'date' => now()->toDateString(),
|
'date' => now()->toDateString(),
|
||||||
@@ -81,61 +90,62 @@ class RideController extends Controller
|
|||||||
'endtime' => '00:00:00',
|
'endtime' => '00:00:00',
|
||||||
'price' => $request->input('price'),
|
'price' => $request->input('price'),
|
||||||
'passenger_id' => $passengerId,
|
'passenger_id' => $passengerId,
|
||||||
'driver_id' => 'none',
|
'driver_id' => '0', // 0 in V1 instead of 'none'
|
||||||
'status' => 'waiting',
|
'status' => $request->input('status', 'waiting'),
|
||||||
'paymentMethod' => $request->input('payment_method', 'Cash'),
|
|
||||||
'carType' => $request->input('car_type', 'Speed'),
|
'carType' => $request->input('car_type', 'Speed'),
|
||||||
'price_for_passenger' => $request->input('price'),
|
'price_for_driver' => $request->input('price_for_driver', $request->input('price')),
|
||||||
|
'price_for_passenger' => $request->input('price_for_passenger', $request->input('price')),
|
||||||
'distance' => $request->input('distance'),
|
'distance' => $request->input('distance'),
|
||||||
]);
|
];
|
||||||
|
|
||||||
// Also insert into waiting rides (for driver search)
|
DB::connection('primary')->beginTransaction();
|
||||||
DB::connection('primary')->table('waitingRides')->insert([
|
DB::connection('ride')->beginTransaction();
|
||||||
'id' => (string) $ride->id,
|
|
||||||
'start_location' => $request->input('start_location'),
|
|
||||||
'start_lat' => $request->input('start_lat'),
|
|
||||||
'start_lng' => $request->input('start_lng'),
|
|
||||||
'end_location' => $request->input('end_location'),
|
|
||||||
'end_lat' => $request->input('end_lat'),
|
|
||||||
'end_lng' => $request->input('end_lng'),
|
|
||||||
'date' => now()->toDateString(),
|
|
||||||
'time' => now()->toTimeString(),
|
|
||||||
'price' => $request->input('price'),
|
|
||||||
'passenger_id' => $passengerId,
|
|
||||||
'status' => 'waiting',
|
|
||||||
'carType' => $request->input('car_type', 'Speed'),
|
|
||||||
'passengerRate' => 5.0,
|
|
||||||
'price_for_passenger' => $request->input('price'),
|
|
||||||
'distance' => $request->input('distance'),
|
|
||||||
'duration' => $request->input('duration', '0'),
|
|
||||||
'payment_method' => $request->input('payment_method', 'cash'),
|
|
||||||
'passenger_wallet' => $request->input('wallet_balance', '0'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Insert into Primary DB (Main Server)
|
||||||
|
$insertedId = DB::connection('primary')->table('ride')->insertGetId($rideData);
|
||||||
|
|
||||||
|
// 2. Insert into Ride DB (Tracking Server)
|
||||||
|
$rideData['id'] = $insertedId; // Keep IDs perfectly synced
|
||||||
|
DB::connection('ride')->table('ride')->insert($rideData);
|
||||||
|
|
||||||
|
DB::connection('primary')->commit();
|
||||||
DB::connection('ride')->commit();
|
DB::connection('ride')->commit();
|
||||||
|
|
||||||
// Notify nearby drivers via socket
|
// 3. Broadcast to Marketplace (Location Socket)
|
||||||
$this->socket->sendToLocationServer('new_ride', [
|
$this->socket->sendToLocationServer('market_new_ride', [
|
||||||
'ride_id' => $ride->id,
|
'id' => (string) $insertedId,
|
||||||
'lat' => $request->input('start_lat'),
|
'start_lat' => $request->input('start_lat'),
|
||||||
'lng' => $request->input('start_lng'),
|
'start_lng' => $request->input('start_lng'),
|
||||||
'car_type' => $request->input('car_type'),
|
'price' => (string) $request->input('price'),
|
||||||
|
'carType' => $request->input('car_type'),
|
||||||
|
'startName' => $request->input('start_name', ''),
|
||||||
|
'endName' => $request->input('end_name', ''),
|
||||||
|
'distance' => (string) $request->input('distance'),
|
||||||
|
'duration' => $request->input('duration_text', ''),
|
||||||
|
'passengerRate' => (string) $request->input('passenger_rating', '5.0'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'data' => ['ride_id' => $ride->id],
|
// Return exactly the inserted ID as success output (V1 App relies on this)
|
||||||
], 201);
|
'data' => $insertedId,
|
||||||
|
], 200); // 200 instead of 201 to match V1 expectation
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
DB::connection('primary')->rollBack();
|
||||||
DB::connection('ride')->rollBack();
|
DB::connection('ride')->rollBack();
|
||||||
|
|
||||||
|
\Log::error('AddRide Critical Error: ' . $e->getMessage());
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => 'failure',
|
'status' => 'failure',
|
||||||
'message' => 'Failed to create ride',
|
'message' => 'Failed to add ride',
|
||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v2/rides/{id}/accept
|
* POST /v2/rides/{id}/accept
|
||||||
* Replaces: ride/rides/acceptRide.php
|
* Replaces: ride/rides/acceptRide.php
|
||||||
@@ -190,10 +200,10 @@ class RideController extends Controller
|
|||||||
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
||||||
if ($passengerToken) {
|
if ($passengerToken) {
|
||||||
$decryptedToken = $this->encryption->decrypt($passengerToken->token);
|
$decryptedToken = $this->encryption->decrypt($passengerToken->token);
|
||||||
$this->fcm->sendToDevice(
|
$this->fcm->sendLocalizedToDevice(
|
||||||
$decryptedToken,
|
$decryptedToken,
|
||||||
'تم قبول طلبك',
|
'ride_accepted_title',
|
||||||
'السائق في الطريق إليك',
|
'ride_accepted_body',
|
||||||
['ride_id' => (string) $rideId, 'status' => 'Applied'],
|
['ride_id' => (string) $rideId, 'status' => 'Applied'],
|
||||||
'ride_accepted'
|
'ride_accepted'
|
||||||
);
|
);
|
||||||
@@ -247,10 +257,10 @@ class RideController extends Controller
|
|||||||
// Notify passenger
|
// Notify passenger
|
||||||
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
||||||
if ($passengerToken) {
|
if ($passengerToken) {
|
||||||
$this->fcm->sendToDevice(
|
$this->fcm->sendLocalizedToDevice(
|
||||||
$this->encryption->decrypt($passengerToken->token),
|
$this->encryption->decrypt($passengerToken->token),
|
||||||
'الرحلة بدأت',
|
'ride_started_title',
|
||||||
'رحلة سعيدة!',
|
'ride_started_body',
|
||||||
['ride_id' => (string) $rideId, 'status' => 'Begin'],
|
['ride_id' => (string) $rideId, 'status' => 'Begin'],
|
||||||
'ride_started'
|
'ride_started'
|
||||||
);
|
);
|
||||||
@@ -288,10 +298,10 @@ class RideController extends Controller
|
|||||||
|
|
||||||
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
||||||
if ($passengerToken) {
|
if ($passengerToken) {
|
||||||
$this->fcm->sendToDevice(
|
$this->fcm->sendLocalizedToDevice(
|
||||||
$this->encryption->decrypt($passengerToken->token),
|
$this->encryption->decrypt($passengerToken->token),
|
||||||
'السائق وصل',
|
'driver_arrived_title',
|
||||||
'السائق في انتظارك',
|
'driver_arrived_body',
|
||||||
['ride_id' => (string) $rideId, 'status' => 'Arrived'],
|
['ride_id' => (string) $rideId, 'status' => 'Arrived'],
|
||||||
'driver_arrived'
|
'driver_arrived'
|
||||||
);
|
);
|
||||||
@@ -360,10 +370,10 @@ class RideController extends Controller
|
|||||||
// Notify passenger
|
// Notify passenger
|
||||||
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
||||||
if ($passengerToken) {
|
if ($passengerToken) {
|
||||||
$this->fcm->sendToDevice(
|
$this->fcm->sendLocalizedToDevice(
|
||||||
$this->encryption->decrypt($passengerToken->token),
|
$this->encryption->decrypt($passengerToken->token),
|
||||||
'الرحلة انتهت',
|
'ride_finished_title',
|
||||||
'شكراً لاستخدامك انطلق',
|
'ride_finished_body',
|
||||||
[
|
[
|
||||||
'ride_id' => (string) $rideId,
|
'ride_id' => (string) $rideId,
|
||||||
'status' => 'finish',
|
'status' => 'finish',
|
||||||
@@ -411,17 +421,17 @@ class RideController extends Controller
|
|||||||
'driverID' => $ride->driver_id ?? 'none',
|
'driverID' => $ride->driver_id ?? 'none',
|
||||||
'passengerID' => $passengerId,
|
'passengerID' => $passengerId,
|
||||||
'rideID' => (string) $rideId,
|
'rideID' => (string) $rideId,
|
||||||
'note' => $request->input('reason', 'nothing'),
|
'note' => $request->input('reason', 'No reason specified'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Notify driver if assigned
|
// Notify driver if assigned
|
||||||
if ($ride->driver_id !== 'none') {
|
if ($ride->driver_id !== 'none') {
|
||||||
$driverToken = DriverToken::where('captain_id', $ride->driver_id)->first();
|
$driverToken = DriverToken::where('captain_id', $ride->driver_id)->first();
|
||||||
if ($driverToken) {
|
if ($driverToken) {
|
||||||
$this->fcm->sendToDevice(
|
$this->fcm->sendLocalizedToDevice(
|
||||||
$this->encryption->decrypt($driverToken->token),
|
$this->encryption->decrypt($driverToken->token),
|
||||||
'تم إلغاء الرحلة',
|
'ride_cancelled_title',
|
||||||
'الراكب ألغى الطلب',
|
'ride_cancelled_body_passenger',
|
||||||
['ride_id' => (string) $rideId, 'status' => 'CancelByPassenger'],
|
['ride_id' => (string) $rideId, 'status' => 'CancelByPassenger'],
|
||||||
'ride_cancelled'
|
'ride_cancelled'
|
||||||
);
|
);
|
||||||
@@ -467,21 +477,27 @@ class RideController extends Controller
|
|||||||
'driverID' => $driverId,
|
'driverID' => $driverId,
|
||||||
'passengerID' => $ride->passenger_id,
|
'passengerID' => $ride->passenger_id,
|
||||||
'rideID' => (string) $rideId,
|
'rideID' => (string) $rideId,
|
||||||
'note' => $request->input('reason', 'nothing'),
|
'note' => $request->input('reason', 'No reason specified'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Notify passenger
|
// Notify passenger via FCM
|
||||||
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
$passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first();
|
||||||
if ($passengerToken) {
|
if ($passengerToken) {
|
||||||
$this->fcm->sendToDevice(
|
$this->fcm->sendLocalizedToDevice(
|
||||||
$this->encryption->decrypt($passengerToken->token),
|
$this->encryption->decrypt($passengerToken->token),
|
||||||
'تم إلغاء الرحلة',
|
'ride_cancelled_title',
|
||||||
'السائق ألغى الطلب، جاري البحث...',
|
'ride_cancelled_body_driver',
|
||||||
['ride_id' => (string) $rideId, 'status' => 'CancelByDriver'],
|
['ride_id' => (string) $rideId, 'status' => 'CancelByDriver'],
|
||||||
'ride_cancelled'
|
'ride_cancelled'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify passenger via Socket (Faster than FCM)
|
||||||
|
$this->socket->notifyPassenger($ride->passenger_id, [
|
||||||
|
'ride_id' => $rideId,
|
||||||
|
'status' => 'CancelByDriver',
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json(['status' => 'success']);
|
return response()->json(['status' => 'success']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,22 +97,102 @@ class TrackingController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v2/tracking/heatmap */
|
/**
|
||||||
|
* GET /v2/tracking/heatmap
|
||||||
|
* المطورة لتطابق V1 مع تحسين الأداء
|
||||||
|
*/
|
||||||
public function heatmap(Request $request): JsonResponse
|
public function heatmap(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
// Use spatial query for active drivers
|
$precision = 2; // دقة الشبكة (~1 كم)
|
||||||
$drivers = DB::connection('tracking')->table('car_locations')
|
$grid = [];
|
||||||
->select('latitude', 'longitude', 'carType')
|
|
||||||
->where('status', 'on')
|
// 1. جلب طلبات الانتظار (Waiting) من قاعدة البيانات الأساسية
|
||||||
->where('updated_at', '>', now()->subMinutes(10))
|
$waitingRides = DB::connection('primary')->table('waitingRides')
|
||||||
|
->select('start_lat', 'start_lng')
|
||||||
|
->whereIn('status', ['wait', 'waiting'])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
foreach ($waitingRides as $ride) {
|
||||||
|
$this->addToGrid($grid, $ride->start_lat, $ride->start_lng, $precision, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. جلب الطلبات الضائعة (Timeout/Cancelled) من خادم الرحلات - آخر 20 دقيقة
|
||||||
|
$missedRides = DB::connection('ride')->table('ride')
|
||||||
|
->select('start_location')
|
||||||
|
->whereIn('status', ['timeout', 'cancelled_no_driver_found'])
|
||||||
|
->where('created_at', '>=', now()->subMinutes(20))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($missedRides as $ride) {
|
||||||
|
$parts = explode(',', $ride->start_location);
|
||||||
|
if (count($parts) == 2) {
|
||||||
|
$this->addToGrid($grid, $parts[0], $parts[1], $precision, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. جلب الطلبات النشطة (Active) - آخر 15 دقيقة
|
||||||
|
$activeRides = DB::connection('ride')->table('ride')
|
||||||
|
->select('start_location')
|
||||||
|
->where('created_at', '>=', now()->subMinutes(15))
|
||||||
|
->whereNotIn('status', ['timeout', 'cancelled_no_driver_found'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($activeRides as $ride) {
|
||||||
|
$parts = explode(',', $ride->start_location);
|
||||||
|
if (count($parts) == 2) {
|
||||||
|
$this->addToGrid($grid, $parts[0], $parts[1], $precision, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. معالجة البيانات النهائية (التصنيف والـ Surge)
|
||||||
|
$finalData = [];
|
||||||
|
foreach ($grid as $cell) {
|
||||||
|
$score = $cell['score'];
|
||||||
|
$count = $cell['count'];
|
||||||
|
|
||||||
|
$intensity = 'normal';
|
||||||
|
$surge = 1.0;
|
||||||
|
|
||||||
|
if ($score >= 15 || $count >= 5) {
|
||||||
|
$intensity = 'high';
|
||||||
|
$surge = 1.5;
|
||||||
|
} elseif ($score >= 8 || $count >= 3) {
|
||||||
|
$intensity = 'medium';
|
||||||
|
$surge = 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalData[] = [
|
||||||
|
'lat' => $cell['lat'],
|
||||||
|
'lng' => $cell['lng'],
|
||||||
|
'count' => $count,
|
||||||
|
'intensity' => $intensity,
|
||||||
|
'surge' => $surge
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'data' => $drivers,
|
'data' => $finalData
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** دالة مساعدة لتجميع النقاط في الشبكة */
|
||||||
|
private function addToGrid(&$grid, $lat, $lng, $precision, $weight): void
|
||||||
|
{
|
||||||
|
if (empty($lat) || empty($lng)) return;
|
||||||
|
|
||||||
|
$rLat = round((float)$lat, $precision);
|
||||||
|
$rLng = round((float)$lng, $precision);
|
||||||
|
$key = "{$rLat},{$rLng}";
|
||||||
|
|
||||||
|
if (!isset($grid[$key])) {
|
||||||
|
$grid[$key] = ['lat' => $rLat, 'lng' => $rLng, 'count' => 0, 'score' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$grid[$key]['count']++;
|
||||||
|
$grid[$key]['score'] += $weight;
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /v2/tracking/captain-stats */
|
/** GET /v2/tracking/captain-stats */
|
||||||
public function captainStats(Request $request): JsonResponse
|
public function captainStats(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -159,7 +159,17 @@ class HmacAuthMiddleware
|
|||||||
->where('api_key', $apiKey)
|
->where('api_key', $apiKey)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return $admin;
|
if ($admin) return $admin;
|
||||||
|
|
||||||
|
// Check service customers/employees (users table)
|
||||||
|
$serviceUser = DB::connection('primary')
|
||||||
|
->table('users')
|
||||||
|
->select('id as user_id', 'api_secret')
|
||||||
|
->selectRaw("'service_user' as user_type")
|
||||||
|
->where('api_key', $apiKey)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $serviceUser;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ class FcmService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send FCM notification to a specific device token
|
* Send localized FCM notification using translation keys (Best for multi-language support)
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Send FCM notification to a specific device token (Raw Text)
|
||||||
*/
|
*/
|
||||||
public function sendToDevice(string $token, string $title, string $body, array $data = [], string $category = ''): array
|
public function sendToDevice(string $token, string $title, string $body, array $data = [], string $category = ''): array
|
||||||
{
|
{
|
||||||
@@ -36,12 +39,10 @@ class FcmService
|
|||||||
return ['status' => 'error', 'message' => 'Empty token'];
|
return ['status' => 'error', 'message' => 'Empty token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add category to data payload
|
|
||||||
if ($category) {
|
if ($category) {
|
||||||
$data['category'] = $category;
|
$data['category'] = $category;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert non-string data values
|
|
||||||
$stringData = [];
|
$stringData = [];
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
$stringData[$key] = is_array($value) ? json_encode($value) : (string) $value;
|
$stringData[$key] = is_array($value) ? json_encode($value) : (string) $value;
|
||||||
@@ -72,6 +73,53 @@ class FcmService
|
|||||||
return $this->sendRequest($payload);
|
return $this->sendRequest($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send localized FCM notification using translation keys (Best for multi-language support)
|
||||||
|
*/
|
||||||
|
public function sendLocalizedToDevice(string $token, string $titleKey, string $bodyKey, array $data = [], string $category = ''): array
|
||||||
|
{
|
||||||
|
if (empty($token)) {
|
||||||
|
return ['status' => 'error', 'message' => 'Empty token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($category) {
|
||||||
|
$data['category'] = $category;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stringData = [];
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$stringData[$key] = is_array($value) ? json_encode($value) : (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'message' => [
|
||||||
|
'token' => $token,
|
||||||
|
'data' => $stringData,
|
||||||
|
'android' => [
|
||||||
|
'priority' => 'high',
|
||||||
|
'notification' => [
|
||||||
|
'title_loc_key' => $titleKey,
|
||||||
|
'body_loc_key' => $bodyKey,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'apns' => [
|
||||||
|
'payload' => [
|
||||||
|
'aps' => [
|
||||||
|
'sound' => 'default',
|
||||||
|
'badge' => 1,
|
||||||
|
'alert' => [
|
||||||
|
'title-loc-key' => $titleKey,
|
||||||
|
'loc-key' => $bodyKey,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->sendRequest($payload);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send to FCM topic
|
* Send to FCM topic
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ return [
|
|||||||
'env' => env('APP_ENV', 'production'),
|
'env' => env('APP_ENV', 'production'),
|
||||||
'debug' => (bool) env('APP_DEBUG', false),
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
'url' => env('APP_URL', 'https://api-v2.intaleq.xyz'),
|
'url' => env('APP_URL', 'https://api-v2.intaleq.xyz'),
|
||||||
'timezone' => 'UTC',
|
'timezone' => 'Asia/Amman',
|
||||||
'locale' => 'ar',
|
'locale' => 'ar',
|
||||||
'fallback_locale' => 'en',
|
'fallback_locale' => 'en',
|
||||||
'faker_locale' => 'ar_SA',
|
'faker_locale' => 'ar_SA',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ return [
|
|||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'strict' => false,
|
'strict' => false,
|
||||||
'engine' => 'InnoDB',
|
'engine' => 'InnoDB',
|
||||||
|
'timezone' => '+03:00',
|
||||||
'options' => extension_loaded('pdo_mysql') ? [
|
'options' => extension_loaded('pdo_mysql') ? [
|
||||||
PDO::ATTR_PERSISTENT => true,
|
PDO::ATTR_PERSISTENT => true,
|
||||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
||||||
@@ -62,6 +63,7 @@ return [
|
|||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'strict' => false,
|
'strict' => false,
|
||||||
'engine' => 'InnoDB',
|
'engine' => 'InnoDB',
|
||||||
|
'timezone' => '+03:00',
|
||||||
'options' => extension_loaded('pdo_mysql') ? [
|
'options' => extension_loaded('pdo_mysql') ? [
|
||||||
PDO::ATTR_PERSISTENT => true,
|
PDO::ATTR_PERSISTENT => true,
|
||||||
] : [],
|
] : [],
|
||||||
@@ -86,6 +88,7 @@ return [
|
|||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'strict' => false,
|
'strict' => false,
|
||||||
'engine' => 'InnoDB',
|
'engine' => 'InnoDB',
|
||||||
|
'timezone' => '+03:00',
|
||||||
'options' => extension_loaded('pdo_mysql') ? [
|
'options' => extension_loaded('pdo_mysql') ? [
|
||||||
PDO::ATTR_PERSISTENT => true,
|
PDO::ATTR_PERSISTENT => true,
|
||||||
] : [],
|
] : [],
|
||||||
|
|||||||
@@ -42,4 +42,10 @@ return [
|
|||||||
|
|
||||||
// Secret Salt
|
// Secret Salt
|
||||||
'secret_salt_parent' => env('SECRET_SALT_PARENT', ''),
|
'secret_salt_parent' => env('SECRET_SALT_PARENT', ''),
|
||||||
|
|
||||||
|
// Wallet Security
|
||||||
|
'wallet_jwt_secret' => env('WALLET_JWT_SECRET'),
|
||||||
|
'wallet_hmac_secret' => env('WALLET_HMAC_SECRET'),
|
||||||
|
'wallet_allowed_audiences' => explode(',', env('WALLET_ALLOWED_AUDIENCES', '')),
|
||||||
|
'fp_pepper' => env('FP_PEPPER', ''),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ return new class extends Migration
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add api_key and api_secret to users table (Service Customers/Employees)
|
||||||
|
if (!Schema::connection('primary')->hasColumn('users', 'api_key')) {
|
||||||
|
Schema::connection('primary')->table('users', function (Blueprint $table) {
|
||||||
|
$table->string('api_key', 64)->nullable()->after('user_type');
|
||||||
|
$table->string('api_secret', 128)->nullable()->after('api_key');
|
||||||
|
$table->index('api_key', 'idx_users_api_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// MISSING INDEXES — Performance optimization
|
// MISSING INDEXES — Performance optimization
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user