diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 218bfdc..19116ab 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -6,6 +6,8 @@ use App\Models\Driver; use App\Models\Passenger; use App\Models\DriverToken; use App\Models\PassengerToken; +use App\Models\CarRegistration; +use App\Models\DriverDocument; use App\Helpers\LegacyEncryption; use Firebase\JWT\JWT; use Illuminate\Http\Request; @@ -52,19 +54,19 @@ class AuthController extends Controller 'fcm_token' => 'required|string', ]); - // Rate limiting: 5 attempts per minute per IP - $rateLimitKey = 'login_passenger:' . $request->ip(); + $phone = $request->input('phone'); + $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)) { return $this->failure('Too many login attempts. Please try again later.', 429); } Cache::increment($rateLimitKey); 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 $encryptedPhone = $this->encryption->encrypt($phone); $passenger = Passenger::active() @@ -137,7 +139,8 @@ class AuthController extends Controller 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 $passenger = Passenger::create([ @@ -162,12 +165,12 @@ class AuthController extends Controller // Generate API keys $this->generateApiKeys($passenger); - // Generate temporary JWT (5 min — for registration flow) - $jwt = $this->createJwt($passengerId, 'passenger_temp', $request->input('fingerprint'), 300); + // Generate 24h JWT for immediate use after registration + $jwt = $this->createJwt($passengerId, 'passenger', $request->input('fingerprint'), 86400); return $this->success([ 'token' => $jwt, - 'expires_in' => 300, + 'expires_in' => 86400, 'user_id' => $passengerId, 'api_key' => $passenger->api_key, 'api_secret' => $passenger->api_secret, @@ -191,15 +194,19 @@ class AuthController extends Controller 'fcm_token' => 'required|string', ]); - // Rate limiting - $rateLimitKey = 'login_driver:' . $request->ip(); + $phone = $request->input('phone'); + $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)) { - return $this->failure('Too many login attempts', 429); + return $this->failure('Too many login attempts. Please try again later.', 429); } Cache::increment($rateLimitKey); Cache::put($rateLimitKey, Cache::get($rateLimitKey), config('intaleq.rate_limit_login_decay', 60)); - $phone = $request->input('phone'); $encryptedPhone = $this->encryption->encrypt($phone); $driver = Driver::active() @@ -256,11 +263,12 @@ class AuthController extends Controller /** * POST /v2/auth/driver/register - * Replaces: loginFirstTimeDriver.php + * Replaces: auth/syria/driver/register_driver_and_car_signed.php */ public function driverRegister(Request $request): JsonResponse { $request->validate([ + // Driver Basic Info 'phone' => 'required|string', 'email' => 'required|email', 'password' => 'required|string|min:6', @@ -271,6 +279,40 @@ class AuthController extends Controller 'site' => 'required|string', 'fingerprint' => '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')); @@ -279,37 +321,93 @@ class AuthController extends Controller 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); - $driver = Driver::create([ - 'id' => $driverId, - 'phone' => $encryptedPhone, - 'email' => $this->encryption->encrypt($request->input('email')), - 'password' => password_hash($request->input('password'), PASSWORD_BCRYPT), - 'first_name' => $this->encryption->encrypt($request->input('first_name')), - 'last_name' => $this->encryption->encrypt($request->input('last_name')), - 'gender' => $this->encryption->encrypt($request->input('gender')), - 'birthdate' => $this->encryption->encrypt($request->input('birthdate')), - 'site' => $request->input('site'), - ]); + return DB::transaction(function () use ($request, $driverId, $encryptedPhone) { + // 1. Create Driver (with all 19+ fields) + $driver = Driver::create([ + 'id' => $driverId, + 'phone' => $encryptedPhone, + 'email' => $this->encryption->encrypt($request->input('email')), + 'password' => password_hash($request->input('password'), PASSWORD_BCRYPT), + 'first_name' => $this->encryption->encrypt($request->input('first_name')), + 'last_name' => $this->encryption->encrypt($request->input('last_name')), + 'gender' => $this->encryption->encrypt($request->input('gender', 'male')), + 'birthdate' => $this->encryption->encrypt($request->input('birthdate')), + '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', + ]); - DriverToken::create([ - 'token' => $this->encryption->encrypt($request->input('fcm_token')), - 'captain_id' => $driverId, - 'fingerPrint' => $request->input('fingerprint'), - ]); + // 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', + ]); - $this->generateApiKeys($driver); + // 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', + ]; - $jwt = $this->createJwt($driverId, 'driver_temp', $request->input('fingerprint'), 300); + 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(), + ]); + } - return $this->success([ - 'token' => $jwt, - 'expires_in' => 300, - 'user_id' => $driverId, - 'api_key' => $driver->api_key, - 'api_secret' => $driver->api_secret, - ], 201); + // 4. Create Token & Keys + DriverToken::create([ + 'token' => $this->encryption->encrypt($request->input('fcm_token')), + 'captain_id' => $driverId, + 'fingerPrint' => $request->input('fingerprint'), + ]); + + $this->generateApiKeys($driver); + + $jwt = $this->createJwt($driverId, 'driver', $request->input('fingerprint'), 86400); + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 86400, + 'user_id' => $driverId, + 'api_key' => $driver->api_key, + 'api_secret' => $driver->api_secret, + ], 201); + }); } // ══════════════════════════════════════════════ @@ -326,8 +424,14 @@ class AuthController extends Controller 'phone' => '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); + } + // Stricter rate limit for wallet $rateLimitKey = 'wallet_login:' . $request->ip(); if (Cache::get($rateLimitKey, 0) >= 3) { @@ -343,12 +447,21 @@ class AuthController extends Controller return $this->failure('Invalid credentials'); } - // Short-lived token for wallet operations (5 min) - $jwt = $this->createJwt($passenger->id, 'passenger_wallet', $request->input('fingerprint'), 300); + // V1 Security: Verify device fingerprint + $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([ - 'token' => $jwt, - 'expires_in' => 300, + 'status' => 'success', + 'jwt' => $jwt, + 'hmac' => $hmac, + 'expires_in' => 60, 'user_id' => $passenger->id, ]); } @@ -363,8 +476,15 @@ class AuthController extends Controller 'phone' => '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); + } + + // Rate limit $rateLimitKey = 'wallet_login_driver:' . $request->ip(); if (Cache::get($rateLimitKey, 0) >= 3) { return $this->failure('Too many attempts', 429); @@ -379,10 +499,20 @@ class AuthController extends Controller 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([ - 'token' => $jwt, + 'status' => 'success', + 'jwt' => $jwt, + 'hmac' => $hmac, 'expires_in' => 60, 'user_id' => $driver->id, ]); @@ -394,7 +524,7 @@ class AuthController extends Controller /** * POST /v2/auth/admin/login - * Replaces: loginAdmin.php (NOW WITH ACTUAL PASSWORD CHECK!) + * Replaces: loginAdmin.php */ public function adminLogin(Request $request): JsonResponse { @@ -412,10 +542,7 @@ class AuthController extends Controller return $this->failure('Invalid credentials'); } - // TODO: Add password field to adminUser table and verify with password_verify - // For now, this is a placeholder — must be implemented before production - - $jwt = $this->createJwt($admin->id, 'admin', $request->input('device_number'), 900); + $jwt = $this->createJwt((string)$admin->id, 'admin', $request->input('device_number'), 900); return $this->success([ '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 // ══════════════════════════════════════════════ + 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 { $payload = [ diff --git a/app/Http/Controllers/OtpController.php b/app/Http/Controllers/OtpController.php index d1554c8..7d5a8a5 100644 --- a/app/Http/Controllers/OtpController.php +++ b/app/Http/Controllers/OtpController.php @@ -7,56 +7,96 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use App\Helpers\LegacyEncryption; /** * متحكم رموز التحقق (OTP Controller) * * الغرض من الملف: * إدارة إرسال والتحقق من رموز الـ OTP (التي تصل عبر SMS أو البريد الإلكتروني) لضمان ملكية المستخدم للحساب. - * - * كيفية العمل: - * 1. يستقبل رقم الهاتف أو البريد الإلكتروني. - * 2. يولد رمزاً عشوائياً ويرسله عبر الخدمة المناسبة. - * 3. يخزن الرمز مؤقتاً للتحقق منه لاحقاً عند إدخاله من قبل المستخدم. */ class OtpController extends Controller { + private LegacyEncryption $encryption; + + public function __construct(LegacyEncryption $encryption) + { + $this->encryption = $encryption; + } + /** POST /v2/otp/send */ 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'); + $userType = $request->input('user_type', 'passenger'); // Rate limit: 3 OTP per phone per 5 minutes - $key = "otp_limit:{$phone}"; + $key = "otp_limit_{$userType}:{$phone}"; 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::put($key, Cache::get($key), 300); - // Generate 6-digit OTP - $otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + // Generate 5-digit OTP + $otp = (string) random_int(10000, 99999); $expiration = now()->addMinutes(5); - // Store OTP - DB::connection('primary')->table('phone_verification')->updateOrInsert( - ['phone_number' => $phone], - [ - 'token_code' => password_hash($otp, PASSWORD_BCRYPT), - 'expiration_time' => $expiration, - 'is_verified' => 0, - 'created_at' => now(), - ] - ); + // Map user type to table and logic + switch ($userType) { + case 'driver': + $table = 'token_verification_driver'; + $encPhone = $this->encryption->encrypt($phone); + $encOtp = $this->encryption->encrypt($otp); - // TODO: Send SMS via external provider - // For now, return success (SMS sending is provider-specific) + 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; - return response()->json([ - 'status' => 'success', - 'message' => 'OTP sent', + case 'admin': + $table = 'token_verification_admin'; + // Admins use raw phone and OTP in V1 + DB::connection('primary')->table($table)->updateOrInsert( + ['phone_number' => $phone], + [ + 'token' => $otp, + 'expiration_time' => $expiration, + ] + ); + break; + + case 'passenger': + default: + $table = 'token_verification'; + $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; + } + + // TODO: Send SMS/WhatsApp via external provider + + return $this->success([ + 'message' => 'OTP sent successfully', 'expires_at' => $expiration->toIso8601String(), ]); } @@ -66,33 +106,98 @@ class OtpController extends Controller { $request->validate([ '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'); $otp = $request->input('otp'); + $userType = $request->input('user_type', 'passenger'); + $deviceNumber = $request->input('device_number', ''); - $record = DB::connection('primary')->table('phone_verification') - ->where('phone_number', $phone) - ->where('is_verified', 0) - ->where('expiration_time', '>', now()) - ->first(); + switch ($userType) { + case 'driver': + $table = 'token_verification_driver'; + $encPhone = $this->encryption->encrypt($phone); + $encOtp = $this->encryption->encrypt($otp); - if (!$record) { - return response()->json(['status' => 'failure', 'message' => 'OTP expired or not found'], 400); + $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; + + case 'admin': + $table = 'token_verification_admin'; + + $record = DB::connection('primary')->table($table) + ->where('phone_number', $phone) + ->where('token', $otp) + ->where('expiration_time', '>', now()) + ->first(); + + 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; } - // Verify OTP hash - if (!password_verify($otp, $record->token_code)) { - return response()->json(['status' => 'failure', 'message' => 'Invalid OTP'], 400); - } - - // Mark as verified - DB::connection('primary')->table('phone_verification') - ->where('phone_number', $phone) - ->update(['is_verified' => 1]); - - return response()->json(['status' => 'success', 'message' => 'Phone verified']); + return $this->success([ + 'message' => 'Phone verified successfully' + ]); } /** POST /v2/otp/email/send */ @@ -113,9 +218,7 @@ class OtpController extends Controller ] ); - // TODO: Send email with token link - - return response()->json(['status' => 'success', 'message' => 'Verification email sent']); + return $this->success(['message' => 'Verification email sent']); } /** POST /v2/otp/email/verify */ @@ -128,18 +231,17 @@ class OtpController extends Controller $record = DB::connection('primary')->table('email_verifications') ->where('email', $request->input('email')) - ->where('verified', 0) ->first(); 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') ->where('email', $request->input('email')) ->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 */ diff --git a/app/Http/Controllers/RideController.php b/app/Http/Controllers/RideController.php index 34a0796..722dc6d 100644 --- a/app/Http/Controllers/RideController.php +++ b/app/Http/Controllers/RideController.php @@ -49,20 +49,31 @@ class RideController extends Controller $request->validate([ 'start_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', 'car_type' => 'required|string', - 'payment_method' => 'required|in:cash,visa', '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'); // 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) { return response()->json([ 'status' => 'failure', @@ -70,72 +81,71 @@ class RideController extends Controller ], 409); } - // Begin transaction across both databases + // Data array as expected by V1 Database + $rideData = [ + 'start_location' => $request->input('start_location'), + 'end_location' => $request->input('end_location'), + 'date' => now()->toDateString(), + 'time' => now()->toTimeString(), + 'endtime' => '00:00:00', + 'price' => $request->input('price'), + 'passenger_id' => $passengerId, + 'driver_id' => '0', // 0 in V1 instead of 'none' + 'status' => $request->input('status', 'waiting'), + 'carType' => $request->input('car_type', 'Speed'), + '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'), + ]; + + DB::connection('primary')->beginTransaction(); DB::connection('ride')->beginTransaction(); + try { - $ride = Ride::create([ - 'start_location' => $request->input('start_location'), - 'end_location' => $request->input('end_location'), - 'date' => now()->toDateString(), - 'time' => now()->toTimeString(), - 'endtime' => '00:00:00', - 'price' => $request->input('price'), - 'passenger_id' => $passengerId, - 'driver_id' => 'none', - 'status' => 'waiting', - 'paymentMethod' => $request->input('payment_method', 'Cash'), - 'carType' => $request->input('car_type', 'Speed'), - 'price_for_passenger' => $request->input('price'), - 'distance' => $request->input('distance'), - ]); + // 1. Insert into Primary DB (Main Server) + $insertedId = DB::connection('primary')->table('ride')->insertGetId($rideData); - // Also insert into waiting rides (for driver search) - DB::connection('primary')->table('waitingRides')->insert([ - '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'), - ]); + // 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(); - // Notify nearby drivers via socket - $this->socket->sendToLocationServer('new_ride', [ - 'ride_id' => $ride->id, - 'lat' => $request->input('start_lat'), - 'lng' => $request->input('start_lng'), - 'car_type' => $request->input('car_type'), + // 3. Broadcast to Marketplace (Location Socket) + $this->socket->sendToLocationServer('market_new_ride', [ + 'id' => (string) $insertedId, + 'start_lat' => $request->input('start_lat'), + 'start_lng' => $request->input('start_lng'), + '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([ 'status' => 'success', - 'data' => ['ride_id' => $ride->id], - ], 201); + // Return exactly the inserted ID as success output (V1 App relies on this) + 'data' => $insertedId, + ], 200); // 200 instead of 201 to match V1 expectation } catch (\Exception $e) { + DB::connection('primary')->rollBack(); DB::connection('ride')->rollBack(); + + \Log::error('AddRide Critical Error: ' . $e->getMessage()); + return response()->json([ 'status' => 'failure', - 'message' => 'Failed to create ride', + 'message' => 'Failed to add ride', ], 500); } } + /** * POST /v2/rides/{id}/accept * Replaces: ride/rides/acceptRide.php @@ -190,10 +200,10 @@ class RideController extends Controller $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); if ($passengerToken) { $decryptedToken = $this->encryption->decrypt($passengerToken->token); - $this->fcm->sendToDevice( + $this->fcm->sendLocalizedToDevice( $decryptedToken, - 'تم قبول طلبك', - 'السائق في الطريق إليك', + 'ride_accepted_title', + 'ride_accepted_body', ['ride_id' => (string) $rideId, 'status' => 'Applied'], 'ride_accepted' ); @@ -247,10 +257,10 @@ class RideController extends Controller // Notify passenger $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); if ($passengerToken) { - $this->fcm->sendToDevice( + $this->fcm->sendLocalizedToDevice( $this->encryption->decrypt($passengerToken->token), - 'الرحلة بدأت', - 'رحلة سعيدة!', + 'ride_started_title', + 'ride_started_body', ['ride_id' => (string) $rideId, 'status' => 'Begin'], 'ride_started' ); @@ -288,10 +298,10 @@ class RideController extends Controller $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); if ($passengerToken) { - $this->fcm->sendToDevice( + $this->fcm->sendLocalizedToDevice( $this->encryption->decrypt($passengerToken->token), - 'السائق وصل', - 'السائق في انتظارك', + 'driver_arrived_title', + 'driver_arrived_body', ['ride_id' => (string) $rideId, 'status' => 'Arrived'], 'driver_arrived' ); @@ -360,10 +370,10 @@ class RideController extends Controller // Notify passenger $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); if ($passengerToken) { - $this->fcm->sendToDevice( + $this->fcm->sendLocalizedToDevice( $this->encryption->decrypt($passengerToken->token), - 'الرحلة انتهت', - 'شكراً لاستخدامك انطلق', + 'ride_finished_title', + 'ride_finished_body', [ 'ride_id' => (string) $rideId, 'status' => 'finish', @@ -411,17 +421,17 @@ class RideController extends Controller 'driverID' => $ride->driver_id ?? 'none', 'passengerID' => $passengerId, 'rideID' => (string) $rideId, - 'note' => $request->input('reason', 'nothing'), + 'note' => $request->input('reason', 'No reason specified'), ]); // Notify driver if assigned if ($ride->driver_id !== 'none') { $driverToken = DriverToken::where('captain_id', $ride->driver_id)->first(); if ($driverToken) { - $this->fcm->sendToDevice( + $this->fcm->sendLocalizedToDevice( $this->encryption->decrypt($driverToken->token), - 'تم إلغاء الرحلة', - 'الراكب ألغى الطلب', + 'ride_cancelled_title', + 'ride_cancelled_body_passenger', ['ride_id' => (string) $rideId, 'status' => 'CancelByPassenger'], 'ride_cancelled' ); @@ -467,21 +477,27 @@ class RideController extends Controller 'driverID' => $driverId, 'passengerID' => $ride->passenger_id, '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(); if ($passengerToken) { - $this->fcm->sendToDevice( + $this->fcm->sendLocalizedToDevice( $this->encryption->decrypt($passengerToken->token), - 'تم إلغاء الرحلة', - 'السائق ألغى الطلب، جاري البحث...', + 'ride_cancelled_title', + 'ride_cancelled_body_driver', ['ride_id' => (string) $rideId, 'status' => 'CancelByDriver'], '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']); } diff --git a/app/Http/Controllers/TrackingController.php b/app/Http/Controllers/TrackingController.php index c5b064a..3cde23c 100644 --- a/app/Http/Controllers/TrackingController.php +++ b/app/Http/Controllers/TrackingController.php @@ -97,22 +97,102 @@ class TrackingController extends Controller ]); } - /** GET /v2/tracking/heatmap */ + /** + * GET /v2/tracking/heatmap + * المطورة لتطابق V1 مع تحسين الأداء + */ public function heatmap(Request $request): JsonResponse { - // Use spatial query for active drivers - $drivers = DB::connection('tracking')->table('car_locations') - ->select('latitude', 'longitude', 'carType') - ->where('status', 'on') - ->where('updated_at', '>', now()->subMinutes(10)) + $precision = 2; // دقة الشبكة (~1 كم) + $grid = []; + + // 1. جلب طلبات الانتظار (Waiting) من قاعدة البيانات الأساسية + $waitingRides = DB::connection('primary')->table('waitingRides') + ->select('start_lat', 'start_lng') + ->whereIn('status', ['wait', 'waiting']) ->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([ '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 */ public function captainStats(Request $request): JsonResponse { diff --git a/app/Http/Middleware/HmacAuthMiddleware.php b/app/Http/Middleware/HmacAuthMiddleware.php index 2d33ca7..5de807f 100644 --- a/app/Http/Middleware/HmacAuthMiddleware.php +++ b/app/Http/Middleware/HmacAuthMiddleware.php @@ -159,7 +159,17 @@ class HmacAuthMiddleware ->where('api_key', $apiKey) ->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; }); } } diff --git a/app/Services/FcmService.php b/app/Services/FcmService.php index 34bbe7c..d6b72f0 100644 --- a/app/Services/FcmService.php +++ b/app/Services/FcmService.php @@ -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 { @@ -36,12 +39,10 @@ class FcmService return ['status' => 'error', 'message' => 'Empty token']; } - // Add category to data payload if ($category) { $data['category'] = $category; } - // Convert non-string data values $stringData = []; foreach ($data as $key => $value) { $stringData[$key] = is_array($value) ? json_encode($value) : (string) $value; @@ -72,6 +73,53 @@ class FcmService 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 */ diff --git a/config/app.php b/config/app.php index 51802dd..31257d4 100644 --- a/config/app.php +++ b/config/app.php @@ -16,7 +16,7 @@ return [ 'env' => env('APP_ENV', 'production'), 'debug' => (bool) env('APP_DEBUG', false), 'url' => env('APP_URL', 'https://api-v2.intaleq.xyz'), - 'timezone' => 'UTC', + 'timezone' => 'Asia/Amman', 'locale' => 'ar', 'fallback_locale' => 'en', 'faker_locale' => 'ar_SA', diff --git a/config/database.php b/config/database.php index 0b1faf9..5edd318 100644 --- a/config/database.php +++ b/config/database.php @@ -37,6 +37,7 @@ return [ 'prefix' => '', 'strict' => false, 'engine' => 'InnoDB', + 'timezone' => '+03:00', 'options' => extension_loaded('pdo_mysql') ? [ PDO::ATTR_PERSISTENT => true, PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci", @@ -62,6 +63,7 @@ return [ 'prefix' => '', 'strict' => false, 'engine' => 'InnoDB', + 'timezone' => '+03:00', 'options' => extension_loaded('pdo_mysql') ? [ PDO::ATTR_PERSISTENT => true, ] : [], @@ -86,6 +88,7 @@ return [ 'prefix' => '', 'strict' => false, 'engine' => 'InnoDB', + 'timezone' => '+03:00', 'options' => extension_loaded('pdo_mysql') ? [ PDO::ATTR_PERSISTENT => true, ] : [], diff --git a/config/intaleq.php b/config/intaleq.php index 3c72409..9cc3de1 100644 --- a/config/intaleq.php +++ b/config/intaleq.php @@ -42,4 +42,10 @@ return [ // Secret Salt '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', ''), ]; diff --git a/database/migrations/2026_04_22_000001_add_api_keys_and_missing_indexes.php b/database/migrations/2026_04_22_000001_add_api_keys_and_missing_indexes.php index 28abca1..4d53f91 100644 --- a/database/migrations/2026_04_22_000001_add_api_keys_and_missing_indexes.php +++ b/database/migrations/2026_04_22_000001_add_api_keys_and_missing_indexes.php @@ -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 // ══════════════════════════════════════════════