From 4706404488635e7d252630641aec8d7248810316 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 22 Apr 2026 21:59:56 +0300 Subject: [PATCH] Initial V2 commit --- .env.example | 90 +++ .gitignore | 17 + app/Helpers/LegacyEncryption.php | 97 ++++ .../Admin/DriverManagementController.php | 179 ++++++ .../Admin/PassengerManagementController.php | 74 +++ .../Admin/RideManagementController.php | 55 ++ .../Controllers/Admin/StatsController.php | 85 +++ app/Http/Controllers/AuthController.php | 465 +++++++++++++++ app/Http/Controllers/Controller.php | 10 + .../Controllers/NotificationController.php | 52 ++ app/Http/Controllers/OtpController.php | 153 +++++ app/Http/Controllers/PlaceController.php | 71 +++ app/Http/Controllers/ProfileController.php | 148 +++++ app/Http/Controllers/PromoController.php | 108 ++++ app/Http/Controllers/RatingController.php | 144 +++++ app/Http/Controllers/RideController.php | 544 ++++++++++++++++++ app/Http/Controllers/TrackingController.php | 141 +++++ app/Http/Controllers/UploadController.php | 171 ++++++ app/Http/Controllers/WalletController.php | 151 +++++ app/Http/Middleware/AdminMiddleware.php | 27 + app/Http/Middleware/HmacAuthMiddleware.php | 136 +++++ app/Http/Middleware/JwtAuthMiddleware.php | 56 ++ app/Models/CarLocation.php | 14 + app/Models/CarRegistration.php | 12 + app/Models/Driver.php | 105 ++++ app/Models/DriverDocument.php | 10 + app/Models/DriverGift.php | 10 + app/Models/DriverHealthAssurance.php | 10 + app/Models/DriverOrder.php | 10 + app/Models/DriverToken.php | 12 + app/Models/ImageProfileCaptain.php | 10 + app/Models/Passenger.php | 56 ++ app/Models/PassengerToken.php | 12 + app/Models/PassengerWallet.php | 13 + app/Models/RatingDriver.php | 10 + app/Models/RatingPassenger.php | 10 + app/Models/Ride.php | 68 +++ app/Models/WaitingRide.php | 27 + app/Services/FcmService.php | 177 ++++++ app/Services/PayloadCrypto.php | 104 ++++ app/Services/SocketService.php | 74 +++ artisan | 11 + bootstrap/app.php | 52 ++ composer.json | 53 ++ config/app.php | 20 + config/cache.php | 21 + config/cors.php | 25 + config/database.php | 104 ++++ config/intaleq.php | 38 ++ ...00001_add_api_keys_and_missing_indexes.php | 110 ++++ public/index.php | 24 + routes/api.php | 153 +++++ setup.sh | 63 ++ 53 files changed, 4392 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app/Helpers/LegacyEncryption.php create mode 100644 app/Http/Controllers/Admin/DriverManagementController.php create mode 100644 app/Http/Controllers/Admin/PassengerManagementController.php create mode 100644 app/Http/Controllers/Admin/RideManagementController.php create mode 100644 app/Http/Controllers/Admin/StatsController.php create mode 100644 app/Http/Controllers/AuthController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Http/Controllers/OtpController.php create mode 100644 app/Http/Controllers/PlaceController.php create mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 app/Http/Controllers/PromoController.php create mode 100644 app/Http/Controllers/RatingController.php create mode 100644 app/Http/Controllers/RideController.php create mode 100644 app/Http/Controllers/TrackingController.php create mode 100644 app/Http/Controllers/UploadController.php create mode 100644 app/Http/Controllers/WalletController.php create mode 100644 app/Http/Middleware/AdminMiddleware.php create mode 100644 app/Http/Middleware/HmacAuthMiddleware.php create mode 100644 app/Http/Middleware/JwtAuthMiddleware.php create mode 100644 app/Models/CarLocation.php create mode 100644 app/Models/CarRegistration.php create mode 100644 app/Models/Driver.php create mode 100644 app/Models/DriverDocument.php create mode 100644 app/Models/DriverGift.php create mode 100644 app/Models/DriverHealthAssurance.php create mode 100644 app/Models/DriverOrder.php create mode 100644 app/Models/DriverToken.php create mode 100644 app/Models/ImageProfileCaptain.php create mode 100644 app/Models/Passenger.php create mode 100644 app/Models/PassengerToken.php create mode 100644 app/Models/PassengerWallet.php create mode 100644 app/Models/RatingDriver.php create mode 100644 app/Models/RatingPassenger.php create mode 100644 app/Models/Ride.php create mode 100644 app/Models/WaitingRide.php create mode 100644 app/Services/FcmService.php create mode 100644 app/Services/PayloadCrypto.php create mode 100644 app/Services/SocketService.php create mode 100644 artisan create mode 100644 bootstrap/app.php create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/cache.php create mode 100644 config/cors.php create mode 100644 config/database.php create mode 100644 config/intaleq.php create mode 100644 database/migrations/2026_04_22_000001_add_api_keys_and_missing_indexes.php create mode 100644 public/index.php create mode 100644 routes/api.php create mode 100755 setup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..53eddca --- /dev/null +++ b/.env.example @@ -0,0 +1,90 @@ +APP_NAME=IntaleqV2 +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://intaleq-v2.intaleq.xyz + +# ============================== +# Database Connections +# ============================== + +# Primary DB (Main Server) +DB_CONNECTION=primary +DB_HOST=188.68.36.205 +DB_PORT=3306 +DB_DATABASE=intaleqDB1 +DB_USERNAME= +DB_PASSWORD= + +# Ride DB (Ride Server) +DB_RIDE_HOST= +DB_RIDE_PORT=3306 +DB_RIDE_DATABASE=intaleq-ridesDB +DB_RIDE_USERNAME= +DB_RIDE_PASSWORD= + +# Tracking DB (Location Server) +DB_TRACKING_HOST=188.68.36.205 +DB_TRACKING_PORT=3306 +DB_TRACKING_DATABASE=locationDB +DB_TRACKING_USERNAME= +DB_TRACKING_PASSWORD= + +# ============================== +# Security Keys +# ============================== + +JWT_SECRET= +HMAC_TOLERANCE_SECONDS=300 +ENCRYPTION_KEY_PATH=/home/intaleq-api/.enckey +INITIALIZATION_VECTOR= + +# Legacy encryption (for backward compatibility with stored data) +LEGACY_ENC_KEY_PATH=/home/intaleq-api/.enckey +LEGACY_IV= + +# ============================== +# Internal Services +# ============================== + +LOCATION_SERVER_URL=http://188.68.36.205:2021 +RIDE_SOCKET_URL=http://188.68.36.205:3031 +INTERNAL_SOCKET_KEY_PATH=/home/intaleq-api/.internal_socket_key + +# ============================== +# External Services +# ============================== + +FCM_CREDENTIALS_PATH=/home/intaleq-api/firebase-credentials.json +FCM_CACHE_PATH=/home/intaleq-api/fcm_token_cache.json + +# Secret salt for parent tracking +SECRET_SALT_PARENT= + +# ============================== +# Redis (Rate Limiting & Cache) +# ============================== + +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +CACHE_DRIVER=redis +SESSION_DRIVER=redis + +# ============================== +# Rate Limiting +# ============================== + +RATE_LIMIT_LOGIN=5 +RATE_LIMIT_LOGIN_DECAY=60 +RATE_LIMIT_API=60 +RATE_LIMIT_API_DECAY=60 + +# ============================== +# File Upload +# ============================== + +UPLOAD_MAX_SIZE=5242880 +UPLOAD_ALLOWED_TYPES=jpg,jpeg,png,webp +UPLOAD_BASE_URL=https://intaleq.xyz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9c1e92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +/vendor +/node_modules +/.env +/.env.backup +/.phpunit.cache +/storage/*.key +/storage/framework/cache/data/* +/storage/framework/sessions/* +/storage/framework/views/* +*.log +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +.idea +.vscode +.DS_Store diff --git a/app/Helpers/LegacyEncryption.php b/app/Helpers/LegacyEncryption.php new file mode 100644 index 0000000..82dc3fb --- /dev/null +++ b/app/Helpers/LegacyEncryption.php @@ -0,0 +1,97 @@ +key = trim(file_get_contents($keyPath)); + $this->iv = env('LEGACY_IV', ''); + } + + /** + * Encrypt data (legacy format — for backward compatibility) + */ + public function encrypt(string $plainText): string + { + $padded = $this->pkcs5Pad($plainText); + $encrypted = openssl_encrypt($padded, $this->cipher, $this->key, OPENSSL_RAW_DATA, $this->iv); + return base64_encode($encrypted); + } + + /** + * Decrypt data encrypted with legacy format + */ + public function decrypt(?string $cipherText): ?string + { + if (empty($cipherText)) { + return null; + } + + try { + $decoded = base64_decode($cipherText); + if ($decoded === false) { + return $cipherText; // Not base64, return as-is + } + + $decrypted = openssl_decrypt($decoded, $this->cipher, $this->key, OPENSSL_RAW_DATA, $this->iv); + + if ($decrypted === false) { + return $cipherText; // Decryption failed, return as-is + } + + return $this->pkcs5Unpad($decrypted); + } catch (\Exception $e) { + return $cipherText; + } + } + + /** + * Decrypt multiple fields in an associative array + */ + public function decryptFields(array $data, array $fields): array + { + foreach ($fields as $field) { + if (!empty($data[$field])) { + $data[$field] = $this->decrypt($data[$field]); + } + } + return $data; + } + + private function pkcs5Pad(string $text): string + { + $blockSize = 16; + $pad = $blockSize - (strlen($text) % $blockSize); + return $text . str_repeat(chr($pad), $pad); + } + + private function pkcs5Unpad(string $text): string + { + $pad = ord($text[strlen($text) - 1]); + if ($pad > 16 || $pad === 0) { + return $text; + } + return substr($text, 0, -$pad); + } +} diff --git a/app/Http/Controllers/Admin/DriverManagementController.php b/app/Http/Controllers/Admin/DriverManagementController.php new file mode 100644 index 0000000..da82330 --- /dev/null +++ b/app/Http/Controllers/Admin/DriverManagementController.php @@ -0,0 +1,179 @@ +enc = $enc; } + + /** GET /v2/admin/drivers?status=waiting&page=1 */ + public function index(Request $request): JsonResponse + { + $status = $request->input('status', 'notDeleted'); + $page = (int) $request->input('page', 1); + $limit = min((int) $request->input('limit', 20), 100); + + $drivers = DB::connection('ride')->table('driver') + ->where('status', $status) + ->orderBy('created_at', 'desc') + ->skip(($page - 1) * $limit)->take($limit) + ->get(); + + // Decrypt fields + $drivers = $drivers->map(function ($d) { + $arr = (array) $d; + return $this->enc->decryptFields($arr, ['first_name', 'last_name', 'phone', 'email', 'national_number']); + }); + + $total = DB::connection('ride')->table('driver')->where('status', $status)->count(); + + return response()->json([ + 'status' => 'success', + 'data' => $drivers, + 'pagination' => ['page' => $page, 'limit' => $limit, 'total' => $total], + ]); + } + + /** GET /v2/admin/drivers/search?phone=XXX */ + public function search(Request $request): JsonResponse + { + $phone = $request->input('phone'); + $national = $request->input('national_number'); + + $query = DB::connection('ride')->table('driver'); + + if ($phone) { + $encPhone = $this->enc->encrypt($phone); + $query->where('phone', $encPhone); + } + if ($national) { + $encNat = $this->enc->encrypt($national); + $query->where('national_number', $encNat); + } + + $driver = $query->first(); + if (!$driver) { + return response()->json(['status' => 'failure', 'message' => 'Driver not found'], 404); + } + + $data = $this->enc->decryptFields((array) $driver, ['first_name', 'last_name', 'phone', 'email', 'national_number', 'address']); + unset($data['password'], $data['api_secret']); + + // Attach car info + $car = DB::connection('ride')->table('CarRegistration') + ->where('driverID', $driver->id)->where('isDefault', 1)->first(); + $data['car'] = $car ? $this->enc->decryptFields((array) $car, ['car_plate', 'owner']) : null; + + // Attach documents + $docs = DB::connection('ride')->table('driver_documents') + ->where('driverID', $driver->id)->get(); + $data['documents'] = $docs; + + return response()->json(['status' => 'success', 'data' => $data]); + } + + /** POST /v2/admin/drivers/{id}/activate */ + public function activate(Request $request, string $driverId): JsonResponse + { + DB::connection('ride')->table('driver') + ->where('id', $driverId)->update(['status' => 'notDeleted']); + DB::connection('tracking')->table('driver') + ->where('id', $driverId)->update(['status' => 'notDeleted']); + DB::connection('primary')->table('driver') + ->where('id', $driverId)->update(['status' => 'notDeleted']); + + return response()->json(['status' => 'success', 'message' => 'Driver activated']); + } + + /** POST /v2/admin/drivers/{id}/deactivate */ + public function deactivate(Request $request, string $driverId): JsonResponse + { + $reason = $request->input('reason', 'Admin deactivation'); + + DB::connection('ride')->table('driver') + ->where('id', $driverId)->update(['status' => 'Deleted']); + DB::connection('tracking')->table('driver') + ->where('id', $driverId)->update(['status' => 'Deleted']); + + // Add to blacklist + DB::connection('ride')->table('blacklist_driver')->insert([ + 'driver_id' => $driverId, + 'phone' => '', + 'reason' => $reason, + 'created_at' => now(), + ]); + + return response()->json(['status' => 'success', 'message' => 'Driver deactivated']); + } + + /** POST /v2/admin/drivers/{id}/add-car */ + public function addCar(Request $request, string $driverId): JsonResponse + { + $request->validate([ + 'car_plate' => 'required|string', + 'make' => 'required|string', + 'model' => 'required|string', + 'year' => 'required|string', + 'color' => 'required|string', + ]); + + $data = [ + 'driverID' => $driverId, + 'vin' => $request->input('vin', ''), + 'car_plate' => $this->enc->encrypt($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' => $this->enc->encrypt($request->input('owner', '')), + 'color_hex' => $request->input('color_hex', ''), + 'fuel' => $request->input('fuel', ''), + 'isDefault' => 1, + 'created_at' => now(), + 'status' => 'yet', + ]; + + // Insert in all 3 databases + DB::connection('ride')->table('CarRegistration')->insert($data); + DB::connection('tracking')->table('CarRegistration')->insert($data); + DB::connection('primary')->table('CarRegistration')->insert($data); + + return response()->json(['status' => 'success'], 201); + } + + /** POST /v2/admin/drivers/{id}/notes */ + public function addNote(Request $request, string $driverId): JsonResponse + { + $request->validate(['note' => 'required|string|max:250']); + + // Get driver phone + $driver = DB::connection('ride')->table('driver')->where('id', $driverId)->first(); + $phone = $driver ? $this->enc->decrypt($driver->phone) : ''; + + DB::connection('primary')->table('notesForDriverService')->updateOrInsert( + ['phone' => $phone], + [ + 'note' => $request->input('note'), + 'editor' => $request->input('editor', 'admin'), + 'createdAt' => now(), + ] + ); + + return response()->json(['status' => 'success']); + } +} diff --git a/app/Http/Controllers/Admin/PassengerManagementController.php b/app/Http/Controllers/Admin/PassengerManagementController.php new file mode 100644 index 0000000..83da607 --- /dev/null +++ b/app/Http/Controllers/Admin/PassengerManagementController.php @@ -0,0 +1,74 @@ +enc = $enc; + } + + /** GET /v2/admin/passengers */ + public function index(Request $request): JsonResponse + { + $status = $request->input('status', 'notDeleted'); + $page = (int) $request->input('page', 1); + $limit = min((int) $request->input('limit', 20), 100); + + $passengers = DB::connection('primary')->table('passengers') + ->where('status', $status) + ->orderBy('created_at', 'desc') + ->skip(($page - 1) * $limit) + ->take($limit) + ->get(); + + $passengers = $passengers->map(function ($p) { + $arr = (array) $p; + return $this->enc->decryptFields($arr, Passenger::ENCRYPTED_FIELDS); + }); + + $total = DB::connection('primary')->table('passengers')->where('status', $status)->count(); + + return response()->json([ + 'status' => 'success', + 'data' => $passengers, + 'pagination' => ['page' => $page, 'limit' => $limit, 'total' => $total], + ]); + } + + /** GET /v2/admin/passengers/search?phone=XXX */ + public function search(Request $request): JsonResponse + { + $phone = $request->input('phone'); + if (!$phone) { + return response()->json(['status' => 'failure', 'message' => 'Phone required'], 400); + } + + $encPhone = $this->enc->encrypt($phone); + $passenger = DB::connection('primary')->table('passengers') + ->where('phone', $encPhone) + ->first(); + + if (!$passenger) { + return response()->json(['status' => 'failure', 'message' => 'Passenger not found'], 404); + } + + $data = $this->enc->decryptFields((array) $passenger, Passenger::ENCRYPTED_FIELDS); + unset($data['password'], $data['api_secret']); + + return response()->json(['status' => 'success', 'data' => $data]); + } +} diff --git a/app/Http/Controllers/Admin/RideManagementController.php b/app/Http/Controllers/Admin/RideManagementController.php new file mode 100644 index 0000000..41701d8 --- /dev/null +++ b/app/Http/Controllers/Admin/RideManagementController.php @@ -0,0 +1,55 @@ +input('status'); + $page = (int) $request->input('page', 1); + $limit = min((int) $request->input('limit', 20), 100); + + $query = DB::connection('ride')->table('ride'); + + if ($status) { + $query->where('status', $status); + } + + $rides = $query->orderBy('created_at', 'desc') + ->skip(($page - 1) * $limit) + ->take($limit) + ->get(); + + $total = $status + ? DB::connection('ride')->table('ride')->where('status', $status)->count() + : DB::connection('ride')->table('ride')->count(); + + return response()->json([ + 'status' => 'success', + 'data' => $rides, + 'pagination' => ['page' => $page, 'limit' => $limit, 'total' => $total], + ]); + } + + /** GET /v2/admin/rides/{id} */ + public function show(string $id): JsonResponse + { + $ride = DB::connection('ride')->table('ride')->where('id', $id)->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not found'], 404); + } + + return response()->json(['status' => 'success', 'data' => $ride]); + } +} diff --git a/app/Http/Controllers/Admin/StatsController.php b/app/Http/Controllers/Admin/StatsController.php new file mode 100644 index 0000000..43374b0 --- /dev/null +++ b/app/Http/Controllers/Admin/StatsController.php @@ -0,0 +1,85 @@ +table('driver')->count(); + $activeDrivers = DB::connection('ride')->table('driver')->where('status', 'notDeleted')->count(); + $totalPassengers = DB::connection('primary')->table('passengers')->count(); + $activePassengers = DB::connection('primary')->table('passengers')->where('status', 'notDeleted')->count(); + $totalRides = DB::connection('ride')->table('ride')->count(); + $finishedRides = DB::connection('ride')->table('ride')->where('status', 'finish')->count(); + $todayRides = DB::connection('ride')->table('ride') + ->where('status', 'finish')->whereDate('rideTimeFinish', today())->count(); + $todayRevenue = DB::connection('ride')->table('ride') + ->where('status', 'finish')->whereDate('rideTimeFinish', today()) + ->sum('price_for_passenger'); + $onlineDrivers = DB::connection('tracking')->table('car_locations') + ->where('status', 'on')->where('updated_at', '>', now()->subMinutes(10))->count(); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'drivers' => ['total' => $totalDrivers, 'active' => $activeDrivers, 'online' => $onlineDrivers], + 'passengers' => ['total' => $totalPassengers, 'active' => $activePassengers], + 'rides' => ['total' => $totalRides, 'finished' => $finishedRides, 'today' => $todayRides], + 'revenue' => ['today' => round($todayRevenue, 2)], + ], + ]); + } + + /** GET /v2/admin/stats/rides?from=2026-01-01&to=2026-04-22 */ + public function rides(Request $request): JsonResponse + { + $from = $request->input('from', today()->subDays(30)->toDateString()); + $to = $request->input('to', today()->toDateString()); + + $daily = DB::connection('ride')->table('ride') + ->selectRaw("DATE(rideTimeFinish) as date, COUNT(*) as count, SUM(price_for_passenger) as revenue") + ->where('status', 'finish') + ->whereBetween('rideTimeFinish', [$from . ' 00:00:00', $to . ' 23:59:59']) + ->groupByRaw('DATE(rideTimeFinish)') + ->orderBy('date') + ->get(); + + return response()->json(['status' => 'success', 'data' => $daily]); + } + + /** GET /v2/admin/stats/drivers-monthly */ + public function driversMonthly(): JsonResponse + { + $monthly = DB::connection('ride')->table('driver') + ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count") + ->groupByRaw("DATE_FORMAT(created_at, '%Y-%m')") + ->orderBy('month', 'desc') + ->limit(12) + ->get(); + + return response()->json(['status' => 'success', 'data' => $monthly]); + } + + /** GET /v2/admin/stats/employees */ + public function employees(): JsonResponse + { + $employees = DB::connection('ride')->table('employee') + ->select('id', 'name', 'phone', 'status', 'created_at') + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json(['status' => 'success', 'data' => $employees]); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..468ec88 --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,465 @@ +encryption = $encryption; + } + + // ══════════════════════════════════════════════ + // PASSENGER LOGIN + // ══════════════════════════════════════════════ + + /** + * POST /v2/auth/passenger/login + * Replaces: login.php + */ + public function passengerLogin(Request $request): JsonResponse + { + $request->validate([ + 'phone' => 'required|string', + 'password' => 'required|string', + 'fingerprint' => 'required|string', + 'fcm_token' => 'required|string', + ]); + + // Rate limiting: 5 attempts per minute per IP + $rateLimitKey = 'login_passenger:' . $request->ip(); + 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() + ->where('phone', $encryptedPhone) + ->first(); + + if (!$passenger) { + return $this->failure('Invalid credentials'); + } + + // Verify password (bcrypt) + if (!password_verify($password, $passenger->password)) { + return $this->failure('Invalid credentials'); + } + + // Verify device fingerprint + $token = PassengerToken::where('passengerID', $passenger->id)->first(); + if ($token && $token->fingerPrint !== $fingerprint) { + return $this->failure('Device mismatch. Please login from your registered device.'); + } + + // Update FCM token + if ($token) { + $encryptedFcm = $this->encryption->encrypt($fcmToken); + $token->update(['token' => $encryptedFcm]); + } + + // Generate API keys if not exist + if (empty($passenger->api_key)) { + $this->generateApiKeys($passenger); + } + + // Generate JWT + $jwt = $this->createJwt($passenger->id, 'passenger', $fingerprint, 86400); // 24h + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 86400, + 'user_id' => $passenger->id, + 'api_key' => $passenger->api_key, + 'api_secret' => $passenger->api_secret, + ]); + } + + /** + * POST /v2/auth/passenger/register + * Replaces: loginFirstTime.php + */ + public function passengerRegister(Request $request): JsonResponse + { + $request->validate([ + 'phone' => 'required|string', + 'email' => 'required|email', + 'password' => 'required|string|min:6', + 'first_name' => 'required|string', + 'last_name' => 'required|string', + 'gender' => 'required|string', + 'birthdate' => 'required|string', + 'site' => 'required|string', + 'fingerprint' => 'required|string', + 'fcm_token' => 'required|string', + ]); + + $phone = $request->input('phone'); + $encryptedPhone = $this->encryption->encrypt($phone); + + // Check if already exists + $exists = Passenger::where('phone', $encryptedPhone)->exists(); + if ($exists) { + return $this->failure('Phone number already registered'); + } + + $passengerId = Str::uuid()->toString(); + + // Encrypt sensitive fields + $passenger = Passenger::create([ + 'id' => $passengerId, + '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'), + ]); + + // Create FCM token record + PassengerToken::create([ + 'token' => $this->encryption->encrypt($request->input('fcm_token')), + 'passengerID' => $passengerId, + 'fingerPrint' => $request->input('fingerprint'), + ]); + + // Generate API keys + $this->generateApiKeys($passenger); + + // Generate temporary JWT (5 min — for registration flow) + $jwt = $this->createJwt($passengerId, 'passenger_temp', $request->input('fingerprint'), 300); + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 300, + 'user_id' => $passengerId, + 'api_key' => $passenger->api_key, + 'api_secret' => $passenger->api_secret, + ], 201); + } + + // ══════════════════════════════════════════════ + // DRIVER LOGIN + // ══════════════════════════════════════════════ + + /** + * POST /v2/auth/driver/login + * Replaces: loginJwtDriver.php + */ + public function driverLogin(Request $request): JsonResponse + { + $request->validate([ + 'phone' => 'required|string', + 'password' => 'required|string', + 'fingerprint' => 'required|string', + 'fcm_token' => 'required|string', + ]); + + // Rate limiting + $rateLimitKey = 'login_driver:' . $request->ip(); + if (Cache::get($rateLimitKey, 0) >= config('intaleq.rate_limit_login', 5)) { + return $this->failure('Too many login attempts', 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() + ->where('phone', $encryptedPhone) + ->first(); + + if (!$driver) { + return $this->failure('Invalid credentials'); + } + + // HMAC password verification (V1 uses this for drivers) + $storedPassword = $driver->password; + if (!password_verify($request->input('password'), $storedPassword) && + !hash_equals($storedPassword, hash_hmac('sha256', $request->input('password'), config('intaleq.jwt_secret')))) { + return $this->failure('Invalid credentials'); + } + + // Verify fingerprint + $driverToken = DriverToken::where('captain_id', $driver->id)->first(); + if ($driverToken && $driverToken->fingerPrint !== $request->input('fingerprint')) { + return $this->failure('Device mismatch'); + } + + // Update FCM token + $encryptedFcm = $this->encryption->encrypt($request->input('fcm_token')); + if ($driverToken) { + $driverToken->update([ + 'token' => $encryptedFcm, + 'fingerPrint' => $request->input('fingerprint'), + ]); + } else { + DriverToken::create([ + 'token' => $encryptedFcm, + 'captain_id' => $driver->id, + 'fingerPrint' => $request->input('fingerprint'), + ]); + } + + // Generate API keys if not exist + if (empty($driver->api_key)) { + $this->generateApiKeys($driver); + } + + $jwt = $this->createJwt($driver->id, 'driver', $request->input('fingerprint'), 86400); + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 86400, + 'user_id' => $driver->id, + 'api_key' => $driver->api_key, + 'api_secret' => $driver->api_secret, + ]); + } + + /** + * POST /v2/auth/driver/register + * Replaces: loginFirstTimeDriver.php + */ + public function driverRegister(Request $request): JsonResponse + { + $request->validate([ + 'phone' => 'required|string', + 'email' => 'required|email', + 'password' => 'required|string|min:6', + 'first_name' => 'required|string', + 'last_name' => 'required|string', + 'gender' => 'required|string', + 'birthdate' => 'required|string', + 'site' => 'required|string', + 'fingerprint' => 'required|string', + 'fcm_token' => 'required|string', + ]); + + $encryptedPhone = $this->encryption->encrypt($request->input('phone')); + + if (Driver::where('phone', $encryptedPhone)->exists()) { + return $this->failure('Phone number already registered'); + } + + $driverId = Str::uuid()->toString(); + + $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'), + ]); + + 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_temp', $request->input('fingerprint'), 300); + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 300, + 'user_id' => $driverId, + 'api_key' => $driver->api_key, + 'api_secret' => $driver->api_secret, + ], 201); + } + + // ══════════════════════════════════════════════ + // WALLET LOGIN (Higher security) + // ══════════════════════════════════════════════ + + /** + * POST /v2/auth/passenger/wallet-login + * Replaces: loginWallet.php + */ + public function passengerWalletLogin(Request $request): JsonResponse + { + $request->validate([ + 'phone' => 'required|string', + 'password' => 'required|string', + 'fingerprint' => 'required|string', + ]); + + // Stricter rate limit for wallet + $rateLimitKey = 'wallet_login:' . $request->ip(); + if (Cache::get($rateLimitKey, 0) >= 3) { + return $this->failure('Too many attempts', 429); + } + Cache::increment($rateLimitKey); + Cache::put($rateLimitKey, Cache::get($rateLimitKey), 120); + + $encryptedPhone = $this->encryption->encrypt($request->input('phone')); + $passenger = Passenger::active()->where('phone', $encryptedPhone)->first(); + + if (!$passenger || !password_verify($request->input('password'), $passenger->password)) { + return $this->failure('Invalid credentials'); + } + + // Short-lived token for wallet operations (5 min) + $jwt = $this->createJwt($passenger->id, 'passenger_wallet', $request->input('fingerprint'), 300); + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 300, + 'user_id' => $passenger->id, + ]); + } + + /** + * POST /v2/auth/driver/wallet-login + * Replaces: loginJwtWalletDriver.php + */ + public function driverWalletLogin(Request $request): JsonResponse + { + $request->validate([ + 'phone' => 'required|string', + 'password' => 'required|string', + 'fingerprint' => 'required|string', + ]); + + $rateLimitKey = 'wallet_login_driver:' . $request->ip(); + if (Cache::get($rateLimitKey, 0) >= 3) { + return $this->failure('Too many attempts', 429); + } + Cache::increment($rateLimitKey); + Cache::put($rateLimitKey, Cache::get($rateLimitKey), 120); + + $encryptedPhone = $this->encryption->encrypt($request->input('phone')); + $driver = Driver::active()->where('phone', $encryptedPhone)->first(); + + if (!$driver || !password_verify($request->input('password'), $driver->password)) { + return $this->failure('Invalid credentials'); + } + + $jwt = $this->createJwt($driver->id, 'driver_wallet', $request->input('fingerprint'), 60); + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 60, + 'user_id' => $driver->id, + ]); + } + + // ══════════════════════════════════════════════ + // ADMIN LOGIN + // ══════════════════════════════════════════════ + + /** + * POST /v2/auth/admin/login + * Replaces: loginAdmin.php (NOW WITH ACTUAL PASSWORD CHECK!) + */ + public function adminLogin(Request $request): JsonResponse + { + $request->validate([ + 'device_number' => 'required|string', + 'password' => 'required|string', + ]); + + $admin = DB::connection('primary') + ->table('adminUser') + ->where('device_number', $request->input('device_number')) + ->first(); + + if (!$admin) { + 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); + + return $this->success([ + 'token' => $jwt, + 'expires_in' => 900, + 'user_id' => $admin->id, + ]); + } + + // ══════════════════════════════════════════════ + // HELPERS + // ══════════════════════════════════════════════ + + private function createJwt(string $userId, string $userType, string $fingerprint, int $expiry): string + { + $payload = [ + 'user_id' => $userId, + 'user_type' => $userType, + 'fingerprint' => $fingerprint, + 'iat' => time(), + 'exp' => time() + $expiry, + 'jti' => Str::uuid()->toString(), + ]; + + return JWT::encode($payload, config('intaleq.jwt_secret'), 'HS256'); + } + + private function generateApiKeys($user): void + { + $apiKey = 'intq_' . Str::random(32); + $apiSecret = hash('sha256', Str::random(64) . time()); + + $user->update([ + 'api_key' => $apiKey, + 'api_secret' => $apiSecret, + ]); + } + + private function success(array $data, int $code = 200): JsonResponse + { + return response()->json(['status' => 'success', 'data' => $data], $code); + } + + private function failure(string $message, int $code = 401): JsonResponse + { + return response()->json(['status' => 'failure', 'message' => $message], $code); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e1fc5de --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +input('_jwt_user_id'); + $userType = $request->input('_jwt_user_type'); + $page = (int) $request->input('page', 1); + $limit = min((int) $request->input('limit', 20), 50); + + if ($userType === 'driver') { + $notifications = DB::connection('primary')->table('notificationCaptain') + ->where('driverID', $userId) + ->orderBy('dateCreated', 'desc') + ->skip(($page - 1) * $limit)->take($limit) + ->get(); + } else { + $notifications = DB::connection('primary')->table('notifications') + ->where('passenger_id', $userId) + ->orderBy('created_at', 'desc') + ->skip(($page - 1) * $limit)->take($limit) + ->get(); + } + + return response()->json(['status' => 'success', 'data' => $notifications]); + } + + /** PUT /v2/notifications/{id}/read */ + public function markRead(Request $request, int $id): JsonResponse + { + $userType = $request->input('_jwt_user_type'); + $table = $userType === 'driver' ? 'notificationCaptain' : 'notifications'; + + DB::connection('primary')->table($table) + ->where('id', $id) + ->update(['isShown' => 'true']); + + return response()->json(['status' => 'success']); + } +} diff --git a/app/Http/Controllers/OtpController.php b/app/Http/Controllers/OtpController.php new file mode 100644 index 0000000..efb0c0b --- /dev/null +++ b/app/Http/Controllers/OtpController.php @@ -0,0 +1,153 @@ +validate(['phone' => 'required|string']); + + $phone = $request->input('phone'); + + // Rate limit: 3 OTP per phone per 5 minutes + $key = "otp_limit:{$phone}"; + if (Cache::get($key, 0) >= 3) { + return response()->json(['status' => 'failure', 'message' => '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); + $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(), + ] + ); + + // TODO: Send SMS via external provider + // For now, return success (SMS sending is provider-specific) + + return response()->json([ + 'status' => 'success', + 'message' => 'OTP sent', + 'expires_at' => $expiration->toIso8601String(), + ]); + } + + /** POST /v2/otp/verify */ + public function verify(Request $request): JsonResponse + { + $request->validate([ + 'phone' => 'required|string', + 'otp' => 'required|string|size:6', + ]); + + $phone = $request->input('phone'); + $otp = $request->input('otp'); + + $record = DB::connection('primary')->table('phone_verification') + ->where('phone_number', $phone) + ->where('is_verified', 0) + ->where('expiration_time', '>', now()) + ->first(); + + if (!$record) { + return response()->json(['status' => 'failure', 'message' => 'OTP expired or not found'], 400); + } + + // 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']); + } + + /** POST /v2/otp/email/send */ + public function sendEmail(Request $request): JsonResponse + { + $request->validate(['email' => 'required|email']); + + $email = $request->input('email'); + $token = Str::random(32); + + DB::connection('primary')->table('email_verifications')->updateOrInsert( + ['email' => $email], + [ + 'token' => password_hash($token, PASSWORD_BCRYPT), + 'verified' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + + // TODO: Send email with token link + + return response()->json(['status' => 'success', 'message' => 'Verification email sent']); + } + + /** POST /v2/otp/email/verify */ + public function verifyEmail(Request $request): JsonResponse + { + $request->validate([ + 'email' => 'required|email', + 'token' => 'required|string', + ]); + + $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); + } + + 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']); + } + + /** GET /v2/otp/check-phone?phone=XXX */ + public function checkPhone(Request $request): JsonResponse + { + $request->validate(['phone' => 'required|string']); + + $verified = DB::connection('primary')->table('phone_verification') + ->where('phone_number', $request->input('phone')) + ->where('is_verified', 1) + ->exists(); + + return response()->json([ + 'status' => 'success', + 'data' => ['verified' => $verified], + ]); + } +} diff --git a/app/Http/Controllers/PlaceController.php b/app/Http/Controllers/PlaceController.php new file mode 100644 index 0000000..282a6db --- /dev/null +++ b/app/Http/Controllers/PlaceController.php @@ -0,0 +1,71 @@ +input('q', ''); + $lat = $request->input('lat'); + $lng = $request->input('lng'); + $limit = min((int) $request->input('limit', 20), 50); + + $query = DB::connection('primary')->table('palces11'); + + if (!empty($q)) { + // Fulltext search (palces11 has FULLTEXT index) + $query->whereRaw( + "MATCH(name, name_ar, name_en, address, category) AGAINST(? IN BOOLEAN MODE)", + [$q . '*'] + ); + } + + // If coordinates provided, sort by distance + if ($lat && $lng) { + $query->selectRaw("*, + ST_Distance_Sphere( + POINT(CAST(longitude AS DECIMAL(10,7)), CAST(latitude AS DECIMAL(10,7))), + POINT(?, ?) + ) AS distance_meters", [(float)$lng, (float)$lat]) + ->orderBy('distance_meters'); + } + + $places = $query->limit($limit)->get(); + + return response()->json(['status' => 'success', 'data' => $places]); + } + + /** POST /v2/places */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'latitude' => 'required|numeric', + 'longitude' => 'required|numeric', + 'name' => 'required|string|max:180', + 'category' => 'required|string|max:55', + ]); + + DB::connection('primary')->table('palces11')->insert([ + 'latitude' => $request->input('latitude'), + 'longitude' => $request->input('longitude'), + 'name' => $request->input('name'), + 'name_ar' => $request->input('name_ar'), + 'name_en' => $request->input('name_en'), + 'address' => $request->input('address'), + 'category' => $request->input('category'), + 'created_at' => now(), + ]); + + return response()->json(['status' => 'success'], 201); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..63890aa --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,148 @@ +enc = $enc; + } + + /** + * GET /v2/profile/passenger + */ + public function passenger(Request $request): JsonResponse + { + $id = $request->input('_jwt_user_id'); + $passenger = Passenger::active()->find($id); + + if (!$passenger) { + return response()->json(['status' => 'failure', 'message' => 'Not found'], 404); + } + + $data = $passenger->toArray(); + $data = $this->enc->decryptFields($data, Passenger::ENCRYPTED_FIELDS); + unset($data['password'], $data['api_secret']); + + // Attach wallet balance + $wallet = DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id)->first(); + $data['wallet_balance'] = $wallet->balance ?? '0.00'; + + // Attach rating + $rating = DB::connection('primary')->table('ratingPassenger') + ->where('passenger_id', $id)->avg('rating'); + $data['rating'] = round($rating ?? 5.0, 2); + + return response()->json(['status' => 'success', 'data' => $data]); + } + + /** + * GET /v2/profile/driver + */ + public function driver(Request $request): JsonResponse + { + $id = $request->input('_jwt_user_id'); + $driver = Driver::active()->byId($id)->first(); + + if (!$driver) { + return response()->json(['status' => 'failure', 'message' => 'Not found'], 404); + } + + $data = $driver->toArray(); + $data = $this->enc->decryptFields($data, Driver::ENCRYPTED_FIELDS); + unset($data['password'], $data['api_secret']); + + // Car info + $car = CarRegistration::where('driverID', $id)->where('isDefault', 1)->first(); + if ($car) { + $carData = $car->toArray(); + $data['car'] = $this->enc->decryptFields($carData, CarRegistration::ENCRYPTED_FIELDS); + } + + // Profile image + $image = ImageProfileCaptain::where('driverID', $id)->first(); + $data['profile_image'] = $image->link ?? null; + + // Rating + $data['rating'] = $driver->getAverageRating(); + + // Ride count + $data['ride_count'] = DB::connection('ride')->table('ride') + ->where('driver_id', $id)->where('status', 'finish')->count(); + + return response()->json(['status' => 'success', 'data' => $data]); + } + + /** + * PUT /v2/profile/passenger + */ + public function updatePassenger(Request $request): JsonResponse + { + $id = $request->input('_jwt_user_id'); + $passenger = Passenger::active()->find($id); + + if (!$passenger) { + return response()->json(['status' => 'failure', 'message' => 'Not found'], 404); + } + + $updates = []; + $encryptableFields = ['first_name', 'last_name', 'gender', 'birthdate', 'sosPhone']; + + foreach ($encryptableFields as $field) { + if ($request->has($field)) { + $updates[$field] = $this->enc->encrypt($request->input($field)); + } + } + + $plainFields = ['education', 'employmentType', 'maritalStatus', 'site']; + foreach ($plainFields as $field) { + if ($request->has($field)) { + $updates[$field] = $request->input($field); + } + } + + if (!empty($updates)) { + $passenger->update($updates); + } + + return response()->json(['status' => 'success', 'message' => 'Profile updated']); + } + + /** + * PUT /v2/profile/driver/email + */ + public function updateDriverEmail(Request $request): JsonResponse + { + $request->validate(['email' => 'required|email']); + + $id = $request->input('_jwt_user_id'); + $driver = Driver::active()->byId($id)->first(); + + if (!$driver) { + return response()->json(['status' => 'failure', 'message' => 'Not found'], 404); + } + + $driver->update([ + 'email' => $this->enc->encrypt($request->input('email')), + ]); + + return response()->json(['status' => 'success', 'message' => 'Email updated']); + } +} diff --git a/app/Http/Controllers/PromoController.php b/app/Http/Controllers/PromoController.php new file mode 100644 index 0000000..12a44c1 --- /dev/null +++ b/app/Http/Controllers/PromoController.php @@ -0,0 +1,108 @@ +input('_jwt_user_id'); + + $promos = DB::connection('primary')->table('promos') + ->where('passengerID', $passengerId) + ->orWhere('passengerID', 'none') + ->where(function ($q) { + $q->whereNull('validity_end_date') + ->orWhere('validity_end_date', '>=', now()->toDateString()); + }) + ->get(); + + return response()->json(['status' => 'success', 'data' => $promos]); + } + + /** GET /v2/promos/check?code=XXX */ + public function check(Request $request): JsonResponse + { + $request->validate(['code' => 'required|string']); + + $promo = DB::connection('primary')->table('promos') + ->where('promo_code', $request->input('code')) + ->where(function ($q) { + $q->whereNull('validity_end_date') + ->orWhere('validity_end_date', '>=', now()->toDateString()); + }) + ->first(); + + if (!$promo) { + return response()->json(['status' => 'failure', 'message' => 'Invalid promo code'], 404); + } + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'code' => $promo->promo_code, + 'amount' => $promo->amount, + 'description' => $promo->description, + ], + ]); + } + + /** POST /v2/promos */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'promo_code' => 'required|string|max:14', + 'amount' => 'required|string', + ]); + + $passengerId = $request->input('_jwt_user_id'); + + $exists = DB::connection('primary')->table('promos') + ->where('passengerID', $passengerId)->exists(); + if ($exists) { + return response()->json(['status' => 'failure', 'message' => 'Promo already assigned'], 409); + } + + DB::connection('primary')->table('promos')->insert([ + 'promo_code' => $request->input('promo_code'), + 'amount' => $request->input('amount'), + 'description' => $request->input('description'), + 'passengerID' => $passengerId, + 'validity_start_date' => $request->input('start_date'), + 'validity_end_date' => $request->input('end_date'), + ]); + + return response()->json(['status' => 'success'], 201); + } + + /** PUT /v2/promos/{id} */ + public function update(Request $request, int $id): JsonResponse + { + DB::connection('primary')->table('promos') + ->where('id', $id) + ->update(array_filter([ + 'promo_code' => $request->input('promo_code'), + 'amount' => $request->input('amount'), + 'description' => $request->input('description'), + 'validity_end_date' => $request->input('end_date'), + ])); + + return response()->json(['status' => 'success']); + } + + /** DELETE /v2/promos/{id} */ + public function destroy(int $id): JsonResponse + { + DB::connection('primary')->table('promos')->where('id', $id)->delete(); + return response()->json(['status' => 'success']); + } +} diff --git a/app/Http/Controllers/RatingController.php b/app/Http/Controllers/RatingController.php new file mode 100644 index 0000000..729637b --- /dev/null +++ b/app/Http/Controllers/RatingController.php @@ -0,0 +1,144 @@ +validate([ + 'driver_id' => 'required|string', + 'ride_id' => 'required|integer', + 'rating' => 'required|numeric|min:1|max:5', + 'comment' => 'nullable|string|max:500', + ]); + + $passengerId = $request->input('_jwt_user_id'); + + // Prevent duplicate ratings + $exists = DB::connection('primary')->table('ratingDriver') + ->where('ride_id', $request->input('ride_id'))->exists(); + if ($exists) { + return response()->json(['status' => 'failure', 'message' => 'Already rated'], 409); + } + + DB::connection('primary')->table('ratingDriver')->insert([ + 'passenger_id' => $passengerId, + 'driver_id' => $request->input('driver_id'), + 'ride_id' => $request->input('ride_id'), + 'rating' => $request->input('rating'), + 'comment' => $request->input('comment', ''), + 'created_at' => now(), + ]); + + return response()->json(['status' => 'success'], 201); + } + + /** POST /v2/ratings/passenger — driver rates a passenger */ + public function ratePassenger(Request $request): JsonResponse + { + $request->validate([ + 'passenger_id' => 'required|string', + 'ride_id' => 'required', + 'rating' => 'required|numeric|min:1|max:5', + 'comment' => 'nullable|string|max:500', + ]); + + $driverId = $request->input('_jwt_user_id'); + + $exists = DB::connection('primary')->table('ratingPassenger') + ->where('rideId', $request->input('ride_id'))->exists(); + if ($exists) { + return response()->json(['status' => 'failure', 'message' => 'Already rated'], 409); + } + + DB::connection('primary')->table('ratingPassenger')->insert([ + 'passenger_id' => $request->input('passenger_id'), + 'driverID' => $driverId, + 'rideId' => $request->input('ride_id'), + 'rating' => $request->input('rating'), + 'comment' => $request->input('comment', ''), + 'created_at' => now(), + ]); + + return response()->json(['status' => 'success'], 201); + } + + /** POST /v2/ratings/app */ + public function rateApp(Request $request): JsonResponse + { + $request->validate([ + 'rating' => 'required|numeric|min:1|max:5', + 'comment' => 'nullable|string|max:300', + ]); + + $userId = $request->input('_jwt_user_id'); + $userType = $request->input('_jwt_user_type'); + + DB::connection('primary')->table('ratingApp')->insert([ + 'name' => $request->input('name', ''), + 'email' => $request->input('email', ''), + 'phone' => $request->input('phone', ''), + 'userId' => $userId, + 'userType' => $userType, + 'rating' => $request->input('rating'), + 'comment' => $request->input('comment', ''), + 'created_at' => now(), + ]); + + return response()->json(['status' => 'success'], 201); + } + + /** GET /v2/ratings/driver/{id} */ + public function driverRating(string $id): JsonResponse + { + $ratings = DB::connection('primary')->table('ratingDriver') + ->where('driver_id', $id) + ->orderBy('created_at', 'desc') + ->limit(50) + ->get(); + + $avg = DB::connection('primary')->table('ratingDriver') + ->where('driver_id', $id)->avg('rating'); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'average' => round($avg ?? 5.0, 2), + 'count' => $ratings->count(), + 'ratings' => $ratings, + ], + ]); + } + + /** GET /v2/ratings/passenger/{id} */ + public function passengerRating(string $id): JsonResponse + { + $ratings = DB::connection('primary')->table('ratingPassenger') + ->where('passenger_id', $id) + ->orderBy('created_at', 'desc') + ->limit(50) + ->get(); + + $avg = DB::connection('primary')->table('ratingPassenger') + ->where('passenger_id', $id)->avg('rating'); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'average' => round($avg ?? 5.0, 2), + 'count' => $ratings->count(), + 'ratings' => $ratings, + ], + ]); + } +} diff --git a/app/Http/Controllers/RideController.php b/app/Http/Controllers/RideController.php new file mode 100644 index 0000000..7084f4a --- /dev/null +++ b/app/Http/Controllers/RideController.php @@ -0,0 +1,544 @@ +encryption = $encryption; + $this->fcm = $fcm; + $this->socket = $socket; + } + + /** + * POST /v2/rides + * Replaces: ride/rides/add_ride.php + */ + public function store(Request $request): JsonResponse + { + $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', + ]); + + $passengerId = $request->input('_jwt_user_id'); + + // Prevent duplicate active rides + $activeRide = Ride::forPassenger($passengerId)->active()->first(); + if ($activeRide) { + return response()->json([ + 'status' => 'failure', + 'message' => 'You already have an active ride', + ], 409); + } + + // Begin transaction across both databases + 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'), + ]); + + // 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'), + ]); + + 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'), + ]); + + return response()->json([ + 'status' => 'success', + 'data' => ['ride_id' => $ride->id], + ], 201); + + } catch (\Exception $e) { + DB::connection('ride')->rollBack(); + return response()->json([ + 'status' => 'failure', + 'message' => 'Failed to create ride', + ], 500); + } + } + + /** + * POST /v2/rides/{id}/accept + * Replaces: ride/rides/acceptRide.php + */ + public function accept(Request $request, int $rideId): JsonResponse + { + $driverId = $request->input('_jwt_user_id'); + + DB::connection('ride')->beginTransaction(); + try { + // Lock the ride row to prevent race conditions + $ride = Ride::lockForUpdate()->find($rideId); + + if (!$ride || !in_array($ride->status, ['waiting', 'wait', 'Apply'])) { + DB::connection('ride')->rollBack(); + return response()->json([ + 'status' => 'failure', + 'message' => 'Ride not available', + ], 409); + } + + // Update ride status atomically + $ride->update([ + 'driver_id' => $driverId, + 'status' => 'Applied', + 'DriverIsGoingToPassenger' => now(), + ]); + + // Update driver order + DriverOrder::where('order_id', (string) $rideId) + ->where('driver_id', $driverId) + ->update(['status' => 'accepted']); + + // Remove from waiting rides + DB::connection('primary') + ->table('waitingRides') + ->where('id', (string) $rideId) + ->update(['status' => 'Applied']); + + // Sync to primary DB ride table + DB::connection('primary') + ->table('ride') + ->where('id', $rideId) + ->update([ + 'driver_id' => $driverId, + 'status' => 'Applied', + ]); + + DB::connection('ride')->commit(); + + // Notify passenger via FCM + $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); + if ($passengerToken) { + $decryptedToken = $this->encryption->decrypt($passengerToken->token); + $this->fcm->sendToDevice( + $decryptedToken, + 'تم قبول طلبك', + 'السائق في الطريق إليك', + ['ride_id' => (string) $rideId, 'status' => 'Applied'], + 'ride_accepted' + ); + } + + // Notify passenger via socket + $this->socket->notifyPassenger($ride->passenger_id, [ + 'ride_id' => $rideId, + 'status' => 'Applied', + 'driver_id' => $driverId, + ]); + + return response()->json(['status' => 'success']); + + } catch (\Exception $e) { + DB::connection('ride')->rollBack(); + return response()->json([ + 'status' => 'failure', + 'message' => 'Failed to accept ride', + ], 500); + } + } + + /** + * POST /v2/rides/{id}/start + * Replaces: ride/rides/start_ride.php + */ + public function start(Request $request, int $rideId): JsonResponse + { + $driverId = $request->input('_jwt_user_id'); + + $ride = Ride::where('id', $rideId) + ->where('driver_id', $driverId) + ->whereIn('status', ['Applied', 'Arrived']) + ->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not found or not ready'], 404); + } + + $ride->update([ + 'status' => 'Begin', + 'rideTimeStart' => now(), + ]); + + // Sync to primary + DB::connection('primary')->table('ride') + ->where('id', $rideId) + ->update(['status' => 'Begin', 'rideTimeStart' => now()]); + + // Notify passenger + $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); + if ($passengerToken) { + $this->fcm->sendToDevice( + $this->encryption->decrypt($passengerToken->token), + 'الرحلة بدأت', + 'رحلة سعيدة!', + ['ride_id' => (string) $rideId, 'status' => 'Begin'], + 'ride_started' + ); + } + + $this->socket->notifyPassenger($ride->passenger_id, [ + 'ride_id' => $rideId, + 'status' => 'Begin', + ]); + + return response()->json(['status' => 'success']); + } + + /** + * POST /v2/rides/{id}/arrive + * Replaces: ride/rides/arrive_ride.php + */ + public function arrive(Request $request, int $rideId): JsonResponse + { + $driverId = $request->input('_jwt_user_id'); + + $ride = Ride::where('id', $rideId) + ->where('driver_id', $driverId) + ->where('status', 'Applied') + ->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not found'], 404); + } + + $ride->update(['status' => 'Arrived']); + + DB::connection('primary')->table('ride') + ->where('id', $rideId)->update(['status' => 'Arrived']); + + $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); + if ($passengerToken) { + $this->fcm->sendToDevice( + $this->encryption->decrypt($passengerToken->token), + 'السائق وصل', + 'السائق في انتظارك', + ['ride_id' => (string) $rideId, 'status' => 'Arrived'], + 'driver_arrived' + ); + } + + return response()->json(['status' => 'success']); + } + + /** + * POST /v2/rides/{id}/finish + * Replaces: ride/rides/finish_ride_updates.php + */ + public function finish(Request $request, int $rideId): JsonResponse + { + $driverId = $request->input('_jwt_user_id'); + + $request->validate([ + 'price_for_driver' => 'required|numeric', + 'price_for_passenger' => 'required|numeric', + 'distance' => 'required|numeric', + ]); + + $ride = Ride::where('id', $rideId) + ->where('driver_id', $driverId) + ->where('status', 'Begin') + ->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not found'], 404); + } + + DB::connection('ride')->beginTransaction(); + try { + $ride->update([ + 'status' => 'finish', + 'rideTimeFinish' => now(), + 'endtime' => now()->toTimeString(), + 'price_for_driver' => $request->input('price_for_driver'), + 'price_for_passenger' => $request->input('price_for_passenger'), + 'distance' => $request->input('distance'), + ]); + + // Sync to primary + DB::connection('primary')->table('ride') + ->where('id', $rideId) + ->update([ + 'status' => 'finish', + 'rideTimeFinish' => now(), + 'price_for_driver' => $request->input('price_for_driver'), + 'price_for_passenger' => $request->input('price_for_passenger'), + 'distance' => $request->input('distance'), + ]); + + // Create payment record + DB::connection('primary')->table('payments')->insert([ + 'id' => uniqid('pay_'), + 'amount' => $request->input('price_for_passenger'), + 'payment_method' => $ride->paymentMethod, + 'passengerID' => $ride->passenger_id, + 'rideId' => (string) $rideId, + 'driverID' => $driverId, + ]); + + DB::connection('ride')->commit(); + + // Notify passenger + $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); + if ($passengerToken) { + $this->fcm->sendToDevice( + $this->encryption->decrypt($passengerToken->token), + 'الرحلة انتهت', + 'شكراً لاستخدامك انطلق', + [ + 'ride_id' => (string) $rideId, + 'status' => 'finish', + 'price' => (string) $request->input('price_for_passenger'), + ], + 'ride_finished' + ); + } + + return response()->json(['status' => 'success']); + + } catch (\Exception $e) { + DB::connection('ride')->rollBack(); + return response()->json(['status' => 'failure', 'message' => 'Failed to finish ride'], 500); + } + } + + /** + * POST /v2/rides/{id}/cancel/passenger + * Replaces: ride/rides/cancel_ride_by_passenger.php + */ + public function cancelByPassenger(Request $request, int $rideId): JsonResponse + { + $passengerId = $request->input('_jwt_user_id'); + + $ride = Ride::where('id', $rideId) + ->where('passenger_id', $passengerId) + ->active() + ->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not found'], 404); + } + + $ride->update(['status' => 'CancelByPassenger']); + + DB::connection('primary')->table('ride') + ->where('id', $rideId)->update(['status' => 'CancelByPassenger']); + + DB::connection('primary')->table('waitingRides') + ->where('id', (string) $rideId)->update(['status' => 'CancelByPassenger']); + + // Log cancellation + DB::connection('ride')->table('canecl')->insert([ + 'driverID' => $ride->driver_id ?? 'none', + 'passengerID' => $passengerId, + 'rideID' => (string) $rideId, + 'note' => $request->input('reason', 'nothing'), + ]); + + // Notify driver if assigned + if ($ride->driver_id !== 'none') { + $driverToken = DriverToken::where('captain_id', $ride->driver_id)->first(); + if ($driverToken) { + $this->fcm->sendToDevice( + $this->encryption->decrypt($driverToken->token), + 'تم إلغاء الرحلة', + 'الراكب ألغى الطلب', + ['ride_id' => (string) $rideId, 'status' => 'CancelByPassenger'], + 'ride_cancelled' + ); + } + + $this->socket->sendToLocationServer('cancel_ride', [ + 'driver_id' => $ride->driver_id, + 'ride_id' => $rideId, + ]); + } + + return response()->json(['status' => 'success']); + } + + /** + * POST /v2/rides/{id}/cancel/driver + * Replaces: ride/rides/cancel_ride_by_driver.php + */ + public function cancelByDriver(Request $request, int $rideId): JsonResponse + { + $driverId = $request->input('_jwt_user_id'); + + $ride = Ride::where('id', $rideId) + ->where('driver_id', $driverId) + ->active() + ->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not found'], 404); + } + + $ride->update(['status' => 'CancelByDriver', 'driver_id' => 'none']); + + DB::connection('primary')->table('ride') + ->where('id', $rideId)->update(['status' => 'CancelByDriver']); + + // Re-add to waiting rides for re-search + DB::connection('primary')->table('waitingRides') + ->where('id', (string) $rideId)->update(['status' => 'waiting']); + + // Log cancellation + DB::connection('ride')->table('canecl')->insert([ + 'driverID' => $driverId, + 'passengerID' => $ride->passenger_id, + 'rideID' => (string) $rideId, + 'note' => $request->input('reason', 'nothing'), + ]); + + // Notify passenger + $passengerToken = PassengerToken::where('passengerID', $ride->passenger_id)->first(); + if ($passengerToken) { + $this->fcm->sendToDevice( + $this->encryption->decrypt($passengerToken->token), + 'تم إلغاء الرحلة', + 'السائق ألغى الطلب، جاري البحث...', + ['ride_id' => (string) $rideId, 'status' => 'CancelByDriver'], + 'ride_cancelled' + ); + } + + return response()->json(['status' => 'success']); + } + + /** + * GET /v2/rides/{id} + * Replaces: ride/rides/getRideOrderID.php + */ + public function show(int $rideId): JsonResponse + { + $ride = Ride::find($rideId); + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not found'], 404); + } + return response()->json(['status' => 'success', 'data' => $ride]); + } + + /** + * GET /v2/rides/active + * Replaces: ride/rides/getRideStatusFromStartApp.php + */ + public function active(Request $request): JsonResponse + { + $userId = $request->input('_jwt_user_id'); + $userType = $request->input('_jwt_user_type'); + + $query = Ride::active(); + if ($userType === 'driver') { + $query->forDriver($userId); + } else { + $query->forPassenger($userId); + } + + $ride = $query->orderBy('id', 'desc')->first(); + + return response()->json([ + 'status' => 'success', + 'data' => $ride, + ]); + } + + /** + * GET /v2/rides + * Replaces: ride/rides/get.php + */ + public function index(Request $request): JsonResponse + { + $userId = $request->input('_jwt_user_id'); + $userType = $request->input('_jwt_user_type'); + $page = $request->input('page', 1); + $limit = min($request->input('limit', 20), 50); + + $query = Ride::query(); + if ($userType === 'driver') { + $query->forDriver($userId); + } else { + $query->forPassenger($userId); + } + + $rides = $query->orderBy('id', 'desc') + ->skip(($page - 1) * $limit) + ->take($limit) + ->get(); + + return response()->json(['status' => 'success', 'data' => $rides]); + } +} diff --git a/app/Http/Controllers/TrackingController.php b/app/Http/Controllers/TrackingController.php new file mode 100644 index 0000000..22ddb6a --- /dev/null +++ b/app/Http/Controllers/TrackingController.php @@ -0,0 +1,141 @@ +table('ride') + ->where('id', $rideId) + ->whereIn('status', ['Applied', 'Arrived', 'Begin']) + ->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not active'], 404); + } + + $location = DB::connection('tracking')->table('car_locations') + ->where('driver_id', $ride->driver_id) + ->first(); + + if (!$location) { + return response()->json(['status' => 'failure', 'message' => 'Driver location not available'], 404); + } + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'latitude' => $location->latitude, + 'longitude' => $location->longitude, + 'heading' => $location->heading, + 'speed' => $location->speed, + 'updated_at' => $location->updated_at, + ], + ]); + } + + /** + * GET /v2/tracking/public/{rideId}?hash=XXX + * Public tracking link for parents/friends — uses HMAC hash instead of auth + */ + public function publicTrack(Request $request, int $rideId): JsonResponse + { + $hash = $request->input('hash'); + if (!$hash) { + return response()->json(['status' => 'failure', 'message' => 'Missing hash'], 400); + } + + // Verify hash: HMAC-SHA256(ride_id, secret_salt) + $expectedHash = hash_hmac('sha256', (string) $rideId, config('intaleq.secret_salt_parent')); + if (!hash_equals($expectedHash, $hash)) { + return response()->json(['status' => 'failure', 'message' => 'Invalid hash'], 403); + } + + $ride = DB::connection('ride')->table('ride') + ->where('id', $rideId) + ->whereIn('status', ['Applied', 'Arrived', 'Begin']) + ->first(); + + if (!$ride) { + return response()->json(['status' => 'failure', 'message' => 'Ride not active'], 404); + } + + $location = DB::connection('tracking')->table('car_locations') + ->where('driver_id', $ride->driver_id) + ->first(); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'latitude' => $location->latitude ?? null, + 'longitude' => $location->longitude ?? null, + 'heading' => $location->heading ?? null, + 'ride_status' => $ride->status, + ], + ]); + } + + /** GET /v2/tracking/heatmap */ + 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)) + ->get(); + + return response()->json([ + 'status' => 'success', + 'data' => $drivers, + ]); + } + + /** GET /v2/tracking/captain-stats */ + public function captainStats(Request $request): JsonResponse + { + $driverId = $request->input('_jwt_user_id'); + + $totalRides = DB::connection('ride')->table('ride') + ->where('driver_id', $driverId) + ->where('status', 'finish') + ->count(); + + $todayRides = DB::connection('ride')->table('ride') + ->where('driver_id', $driverId) + ->where('status', 'finish') + ->whereDate('rideTimeFinish', today()) + ->count(); + + $todayEarnings = DB::connection('ride')->table('ride') + ->where('driver_id', $driverId) + ->where('status', 'finish') + ->whereDate('rideTimeFinish', today()) + ->sum('price_for_driver'); + + $workHours = DB::connection('tracking')->table('driver_daily_summary') + ->where('driver_id', $driverId) + ->where('date', today()->toDateString()) + ->value('total_seconds') ?? 0; + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'total_rides' => $totalRides, + 'today_rides' => $todayRides, + 'today_earnings' => round($todayEarnings, 2), + 'today_work_seconds' => $workHours, + ], + ]); + } +} diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php new file mode 100644 index 0000000..41858f7 --- /dev/null +++ b/app/Http/Controllers/UploadController.php @@ -0,0 +1,171 @@ +handleImageUpload($request, 'card_images', 'cards'); + } + + /** POST /v2/uploads/profile-image */ + public function profileImage(Request $request): JsonResponse + { + return $this->handleImageUpload($request, 'imageProfileCaptain', 'profiles'); + } + + /** POST /v2/uploads/document */ + public function document(Request $request): JsonResponse + { + $request->validate([ + 'image' => 'required|file', + 'doc_type' => 'required|string|in:license,registration,criminal,id_front,id_back', + ]); + + return $this->handleImageUpload($request, 'driver_documents', 'documents'); + } + + /** POST /v2/uploads/id-front */ + public function idFront(Request $request): JsonResponse + { + return $this->handleImageUpload($request, 'card_images', 'ids/front'); + } + + /** POST /v2/uploads/id-back */ + public function idBack(Request $request): JsonResponse + { + return $this->handleImageUpload($request, 'card_images', 'ids/back'); + } + + /** POST /v2/uploads/audio */ + public function audio(Request $request): JsonResponse + { + $request->validate(['audio' => 'required|file']); + + $file = $request->file('audio'); + + // Validate MIME + $mime = $file->getMimeType(); + if (!in_array($mime, self::ALLOWED_AUDIO_MIMES)) { + return response()->json(['status' => 'failure', 'message' => 'Invalid audio format'], 400); + } + + if ($file->getSize() > self::MAX_AUDIO_SIZE) { + return response()->json(['status' => 'failure', 'message' => 'File too large'], 400); + } + + $userId = $request->input('_jwt_user_id'); + $ext = $file->getClientOriginalExtension() ?: 'mp3'; + $filename = 'audio_' . Str::random(24) . '.' . $ext; + + $uploadPath = public_path('uploads/audio'); + if (!is_dir($uploadPath)) mkdir($uploadPath, 0755, true); + + $file->move($uploadPath, $filename); + + $link = config('intaleq.upload_base_url') . '/uploads/audio/' . $filename; + + return response()->json([ + 'status' => 'success', + 'data' => ['link' => $link, 'filename' => $filename], + ], 201); + } + + /** + * Core image upload handler + */ + private function handleImageUpload(Request $request, string $table, string $subDir): JsonResponse + { + $request->validate(['image' => 'required|file']); + + $file = $request->file('image'); + + // Validate MIME type + $mime = $file->getMimeType(); + if (!in_array($mime, self::ALLOWED_IMAGE_MIMES)) { + return response()->json(['status' => 'failure', 'message' => 'Invalid image format. Allowed: JPG, PNG, WebP'], 400); + } + + // Validate file size + if ($file->getSize() > self::MAX_IMAGE_SIZE) { + return response()->json(['status' => 'failure', 'message' => 'File too large (max 5MB)'], 400); + } + + // Validate magic bytes (defense in depth) + $firstBytes = file_get_contents($file->getRealPath(), false, null, 0, 4); + if (!$this->validateMagicBytes($firstBytes, $mime)) { + return response()->json(['status' => 'failure', 'message' => 'File content does not match type'], 400); + } + + // Generate safe filename + $userId = $request->input('_jwt_user_id'); + $ext = match ($mime) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + default => 'jpg', + }; + $filename = $subDir . '_' . Str::random(24) . '.' . $ext; + + $uploadPath = public_path('uploads/' . $subDir); + if (!is_dir($uploadPath)) mkdir($uploadPath, 0755, true); + + $file->move($uploadPath, $filename); + + $link = config('intaleq.upload_base_url') . '/uploads/' . $subDir . '/' . $filename; + + // Save to DB + $dbData = [ + 'driverID' => $userId, + 'image_name' => $filename, + 'link' => $link, + 'upload_date' => now(), + ]; + + if ($request->has('doc_type')) { + $dbData['doc_type'] = $request->input('doc_type'); + } + + DB::connection('ride')->table($table)->insert($dbData); + + return response()->json([ + 'status' => 'success', + 'data' => ['link' => $link, 'filename' => $filename], + ], 201); + } + + /** + * Validate file magic bytes match the declared MIME type + */ + private function validateMagicBytes(string $bytes, string $mime): bool + { + return match ($mime) { + 'image/jpeg' => str_starts_with($bytes, "\xFF\xD8\xFF"), + 'image/png' => str_starts_with($bytes, "\x89\x50\x4E\x47"), + 'image/webp' => str_starts_with($bytes, "RIFF"), + default => false, + }; + } +} diff --git a/app/Http/Controllers/WalletController.php b/app/Http/Controllers/WalletController.php new file mode 100644 index 0000000..be18649 --- /dev/null +++ b/app/Http/Controllers/WalletController.php @@ -0,0 +1,151 @@ +input('_jwt_user_id'); + $wallet = DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id)->first(); + + return response()->json([ + 'status' => 'success', + 'data' => $wallet ?? ['passenger_id' => $id, 'balance' => '0.00'], + ]); + } + + /** GET /v2/wallet/passenger/balance */ + public function balance(Request $request): JsonResponse + { + $id = $request->input('_jwt_user_id'); + $bal = DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id)->value('balance') ?? '0.00'; + + return response()->json(['status' => 'success', 'data' => ['balance' => $bal]]); + } + + /** POST /v2/wallet/passenger */ + public function addFunds(Request $request): JsonResponse + { + $request->validate([ + 'amount' => 'required|numeric|min:0.01', + 'payment_method' => 'required|string', + ]); + + $id = $request->input('_jwt_user_id'); + + DB::connection('primary')->beginTransaction(); + try { + $wallet = DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id)->lockForUpdate()->first(); + + if ($wallet) { + DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id) + ->increment('balance', $request->input('amount')); + } else { + DB::connection('primary')->table('passengerWallet')->insert([ + 'passenger_id' => $id, + 'balance' => $request->input('amount'), + ]); + } + + // Record transaction + DB::connection('primary')->table('passengerWalletTransactions')->insert([ + 'passenger_id' => $id, + 'amount' => $request->input('amount'), + 'type' => 'credit', + 'payment_method' => $request->input('payment_method'), + 'created_at' => now(), + ]); + + DB::connection('primary')->commit(); + + $newBalance = DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id)->value('balance'); + + return response()->json([ + 'status' => 'success', + 'data' => ['balance' => $newBalance], + ]); + } catch (\Exception $e) { + DB::connection('primary')->rollBack(); + return response()->json(['status' => 'failure', 'message' => 'Transaction failed'], 500); + } + } + + /** PUT /v2/wallet/passenger */ + public function update(Request $request): JsonResponse + { + $request->validate(['balance' => 'required|numeric|min:0']); + + $id = $request->input('_jwt_user_id'); + + DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id) + ->update(['balance' => $request->input('balance')]); + + return response()->json(['status' => 'success']); + } + + /** DELETE /v2/wallet/passenger */ + public function destroy(Request $request): JsonResponse + { + $id = $request->input('_jwt_user_id'); + DB::connection('primary')->table('passengerWallet') + ->where('passenger_id', $id)->delete(); + + return response()->json(['status' => 'success']); + } + + /** GET /v2/wallet/passenger/transactions */ + public function transactions(Request $request): JsonResponse + { + $id = $request->input('_jwt_user_id'); + $page = (int) $request->input('page', 1); + $limit = min((int) $request->input('limit', 20), 50); + + // Get from payments table (completed rides) + $payments = DB::connection('primary')->table('payments') + ->where('passengerID', $id) + ->orderBy('created_at', 'desc') + ->skip(($page - 1) * $limit) + ->take($limit) + ->get(); + + return response()->json(['status' => 'success', 'data' => $payments]); + } + + /** POST /v2/wallet/passenger/token */ + public function addToken(Request $request): JsonResponse + { + $request->validate([ + 'token' => 'required|string', + 'amount' => 'required|numeric|min:0.01', + ]); + + $id = $request->input('_jwt_user_id'); + + DB::connection('primary')->table('payment_tokens_passenger')->insert([ + 'token' => $request->input('token'), + 'passengerId' => $id, + 'dateCreated' => now(), + 'amount' => $request->input('amount'), + 'isUsed' => 0, + ]); + + return response()->json(['status' => 'success'], 201); + } +} diff --git a/app/Http/Middleware/AdminMiddleware.php b/app/Http/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..f15dfb7 --- /dev/null +++ b/app/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,27 @@ +input('_jwt_user_type'); + + if ($userType !== 'admin') { + return response()->json([ + 'status' => 'failure', + 'message' => 'Unauthorized — admin access required', + ], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/HmacAuthMiddleware.php b/app/Http/Middleware/HmacAuthMiddleware.php new file mode 100644 index 0000000..c06e91e --- /dev/null +++ b/app/Http/Middleware/HmacAuthMiddleware.php @@ -0,0 +1,136 @@ +header('X-API-Key'); + $timestamp = $request->header('X-Timestamp'); + $signature = $request->header('X-Signature'); + $nonce = $request->header('X-Nonce'); + + // 1. Check required headers + if (!$apiKey || !$timestamp || !$signature) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Missing authentication headers' + ], 401); + } + + // 2. Validate timestamp (prevent replay attacks) + $tolerance = (int) config('intaleq.hmac_tolerance', 300); + $timeDiff = abs(time() - (int) $timestamp); + + if ($timeDiff > $tolerance) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Request expired' + ], 401); + } + + // 3. Check nonce uniqueness (if provided) + if ($nonce) { + $nonceKey = "nonce:{$nonce}"; + if (Cache::has($nonceKey)) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Duplicate request' + ], 401); + } + // Store nonce for double the tolerance window + Cache::put($nonceKey, true, $tolerance * 2); + } + + // 4. Lookup API secret from database + $credentials = $this->getApiCredentials($apiKey); + + if (!$credentials) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Invalid API key' + ], 401); + } + + // 5. Reconstruct and verify HMAC signature + $payload = $request->getContent(); + $message = "{$timestamp}|{$apiKey}|{$payload}"; + $expectedSignature = hash_hmac(self::ALGORITHM, $message, $credentials->api_secret); + + if (!hash_equals($expectedSignature, $signature)) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Invalid signature' + ], 401); + } + + // 6. Attach user info to request for controllers + $request->merge([ + '_auth_user_id' => $credentials->user_id, + '_auth_user_type' => $credentials->user_type, + ]); + + return $next($request); + } + + /** + * Get API credentials with Redis caching (5 min) + */ + private function getApiCredentials(string $apiKey): ?object + { + $cacheKey = "api_cred:{$apiKey}"; + + return Cache::remember($cacheKey, 300, function () use ($apiKey) { + // Check both driver and passenger tables for API keys + $driver = DB::connection('primary') + ->table('driver') + ->select('id as user_id', 'api_secret') + ->selectRaw("'driver' as user_type") + ->where('api_key', $apiKey) + ->where('status', 'notDeleted') + ->first(); + + if ($driver) return $driver; + + $passenger = DB::connection('primary') + ->table('passengers') + ->select('id as user_id', 'api_secret') + ->selectRaw("'passenger' as user_type") + ->where('api_key', $apiKey) + ->where('status', 'notDeleted') + ->first(); + + if ($passenger) return $passenger; + + // Check admin users + $admin = DB::connection('primary') + ->table('adminUser') + ->select('id as user_id', 'api_secret') + ->selectRaw("'admin' as user_type") + ->where('api_key', $apiKey) + ->first(); + + return $admin; + }); + } +} diff --git a/app/Http/Middleware/JwtAuthMiddleware.php b/app/Http/Middleware/JwtAuthMiddleware.php new file mode 100644 index 0000000..8347383 --- /dev/null +++ b/app/Http/Middleware/JwtAuthMiddleware.php @@ -0,0 +1,56 @@ +header('Authorization'); + + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Missing or invalid Authorization header' + ], 401); + } + + $token = substr($authHeader, 7); + + try { + $decoded = JWT::decode($token, new Key(config('intaleq.jwt_secret'), 'HS256')); + + // Attach JWT claims to request + $request->merge([ + '_jwt_user_id' => $decoded->user_id ?? null, + '_jwt_user_type' => $decoded->user_type ?? null, + '_jwt_fingerprint' => $decoded->fingerprint ?? null, + ]); + + return $next($request); + + } catch (ExpiredException $e) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Token expired' + ], 401); + } catch (\Exception $e) { + return response()->json([ + 'status' => 'failure', + 'message' => 'Invalid token' + ], 401); + } + } +} diff --git a/app/Models/CarLocation.php b/app/Models/CarLocation.php new file mode 100644 index 0000000..68680a9 --- /dev/null +++ b/app/Models/CarLocation.php @@ -0,0 +1,14 @@ +hasOne(DriverToken::class, 'captain_id', 'id'); + } + + public function car() + { + return $this->hasOne(CarRegistration::class, 'driverID', 'id'); + } + + public function orders() + { + return $this->hasMany(DriverOrder::class, 'driver_id', 'id'); + } + + public function ratings() + { + return $this->hasMany(RatingDriver::class, 'driver_id', 'id'); + } + + public function documents() + { + return $this->hasMany(DriverDocument::class, 'driverID', 'id'); + } + + public function location() + { + return $this->hasOne(CarLocation::class, 'driver_id', 'id'); + } + + public function profileImage() + { + return $this->hasOne(ImageProfileCaptain::class, 'driverID', 'id'); + } + + public function healthAssurance() + { + return $this->hasOne(DriverHealthAssurance::class, 'driver_id', 'id'); + } + + public function gift() + { + return $this->hasOne(DriverGift::class, 'driver_id', 'id'); + } + + // ── Scopes ── + + public function scopeActive($query) + { + return $query->where('status', 'notDeleted'); + } + + public function scopeById($query, string $driverId) + { + return $query->where('id', $driverId); + } + + // ── Helpers ── + + public function getAverageRating(): float + { + return round($this->ratings()->avg('rating') ?? 5.0, 2); + } +} diff --git a/app/Models/DriverDocument.php b/app/Models/DriverDocument.php new file mode 100644 index 0000000..30d64eb --- /dev/null +++ b/app/Models/DriverDocument.php @@ -0,0 +1,10 @@ +hasOne(PassengerToken::class, 'passengerID', 'id'); + } + + public function wallet() + { + return $this->hasMany(PassengerWallet::class, 'passenger_id', 'id'); + } + + public function rides() + { + return $this->hasMany(Ride::class, 'passenger_id', 'id'); + } + + public function ratings() + { + return $this->hasMany(RatingPassenger::class, 'passenger_id', 'id'); + } + + public function scopeActive($query) + { + return $query->where('status', 'notDeleted'); + } +} diff --git a/app/Models/PassengerToken.php b/app/Models/PassengerToken.php new file mode 100644 index 0000000..53d13fc --- /dev/null +++ b/app/Models/PassengerToken.php @@ -0,0 +1,12 @@ + 'decimal:2']; +} diff --git a/app/Models/RatingDriver.php b/app/Models/RatingDriver.php new file mode 100644 index 0000000..1eae024 --- /dev/null +++ b/app/Models/RatingDriver.php @@ -0,0 +1,10 @@ + 'decimal:2', + 'price_for_driver' => 'decimal:2', + 'price_for_passenger' => 'decimal:2', + 'distance' => 'float', + ]; + + // ── Relationships (cross-database via Primary) ── + + public function driver() + { + return $this->belongsTo(Driver::class, 'driver_id', 'id'); + } + + public function passenger() + { + return $this->belongsTo(Passenger::class, 'passenger_id', 'id'); + } + + // ── Scopes ── + + public function scopeActive($query) + { + return $query->whereIn('status', ['waiting', 'wait', 'Apply', 'Applied', 'Arrived', 'Begin']); + } + + public function scopeForPassenger($query, string $passengerId) + { + return $query->where('passenger_id', $passengerId); + } + + public function scopeForDriver($query, string $driverId) + { + return $query->where('driver_id', $driverId); + } + + public function scopeWaiting($query) + { + return $query->whereIn('status', ['waiting', 'wait']); + } +} diff --git a/app/Models/WaitingRide.php b/app/Models/WaitingRide.php new file mode 100644 index 0000000..c075772 --- /dev/null +++ b/app/Models/WaitingRide.php @@ -0,0 +1,27 @@ + 'decimal:7', 'start_lng' => 'decimal:7', + 'end_lat' => 'decimal:7', 'end_lng' => 'decimal:7', + 'price' => 'decimal:2', 'price_for_passenger' => 'decimal:2', + ]; + public function scopeWaiting($query) { + return $query->where('status', 'waiting'); + } +} diff --git a/app/Services/FcmService.php b/app/Services/FcmService.php new file mode 100644 index 0000000..b3883e5 --- /dev/null +++ b/app/Services/FcmService.php @@ -0,0 +1,177 @@ +credentialsPath = config('intaleq.fcm_credentials_path'); + $this->cachePath = config('intaleq.fcm_cache_path'); + } + + /** + * Send FCM notification to a specific device token + */ + public function sendToDevice(string $token, string $title, string $body, array $data = [], string $category = ''): array + { + if (empty($token)) { + 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; + } + + $payload = [ + 'message' => [ + 'token' => $token, + 'notification' => [ + 'title' => $title, + 'body' => $body, + ], + 'data' => $stringData, + 'android' => [ + 'priority' => 'high', + ], + 'apns' => [ + 'payload' => [ + 'aps' => [ + 'sound' => 'default', + 'badge' => 1, + ], + ], + ], + ], + ]; + + return $this->sendRequest($payload); + } + + /** + * Send to FCM topic + */ + public function sendToTopic(string $topic, string $title, string $body, array $data = []): array + { + $payload = [ + 'message' => [ + 'topic' => $topic, + 'notification' => [ + 'title' => $title, + 'body' => $body, + ], + 'data' => array_map('strval', $data), + ], + ]; + + return $this->sendRequest($payload); + } + + private function sendRequest(array $payload): array + { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return ['status' => 'error', 'message' => 'Failed to get access token']; + } + + $credentials = json_decode(file_get_contents($this->credentialsPath), true); + $projectId = $credentials['project_id'] ?? ''; + $url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer {$accessToken}", + 'Content-Type: application/json', + ], + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $result = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200) { + return ['status' => 'success', 'response' => json_decode($result, true)]; + } + + Log::error("[FCM] Error {$httpCode}: {$result}"); + return ['status' => 'error', 'code' => $httpCode, 'response' => $result]; + } + + private function getAccessToken(): ?string + { + // Check cache + if (file_exists($this->cachePath)) { + $cached = json_decode(file_get_contents($this->cachePath), true); + if ($cached && ($cached['expires_at'] ?? 0) > time() + 60) { + return $cached['token']; + } + } + + if (!file_exists($this->credentialsPath)) return null; + + $credentials = json_decode(file_get_contents($this->credentialsPath), true); + $clientEmail = $credentials['client_email']; + $privateKey = $credentials['private_key']; + + $now = time(); + $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '='); + $claim = rtrim(strtr(base64_encode(json_encode([ + 'iss' => $clientEmail, + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ])), '+/', '-_'), '='); + + $signature = ''; + openssl_sign("{$header}.{$claim}", $signature, $privateKey, 'SHA256'); + $jwt = "{$header}.{$claim}." . rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); + + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]), + CURLOPT_RETURNTRANSFER => true, + ]); + $res = curl_exec($ch); + curl_close($ch); + + $token = json_decode($res, true)['access_token'] ?? null; + + if ($token) { + file_put_contents($this->cachePath, json_encode([ + 'token' => $token, + 'expires_at' => time() + 3500, + ])); + } + + return $token; + } +} diff --git a/app/Services/PayloadCrypto.php b/app/Services/PayloadCrypto.php new file mode 100644 index 0000000..adc4e9e --- /dev/null +++ b/app/Services/PayloadCrypto.php @@ -0,0 +1,104 @@ +key = hash_hkdf('sha256', $rawKey, 32, 'intaleq-v2-gcm'); + } + + /** + * Encrypt payload for sending to Flutter app + * + * @param array|string $data Data to encrypt + * @return string Base64 encoded (IV + ciphertext + tag) + */ + public function encrypt($data): string + { + $plaintext = is_array($data) ? json_encode($data) : $data; + $iv = random_bytes(self::IV_LENGTH); + $tag = ''; + + $ciphertext = openssl_encrypt( + $plaintext, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $iv, + $tag, + '', // Additional Authenticated Data (AAD) + self::TAG_LENGTH + ); + + if ($ciphertext === false) { + throw new \RuntimeException('Encryption failed'); + } + + // Pack: IV (12) + ciphertext (variable) + tag (16) + return base64_encode($iv . $ciphertext . $tag); + } + + /** + * Decrypt payload received from Flutter app + * + * @param string $encoded Base64 encoded (IV + ciphertext + tag) + * @return string|null Decrypted plaintext or null on failure + */ + public function decrypt(string $encoded): ?string + { + $raw = base64_decode($encoded, true); + if ($raw === false || strlen($raw) < self::IV_LENGTH + self::TAG_LENGTH + 1) { + return null; + } + + $iv = substr($raw, 0, self::IV_LENGTH); + $tag = substr($raw, -self::TAG_LENGTH); + $ciphertext = substr($raw, self::IV_LENGTH, -self::TAG_LENGTH); + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + return $plaintext !== false ? $plaintext : null; + } + + /** + * Decrypt and decode JSON payload + */ + public function decryptJson(string $encoded): ?array + { + $plaintext = $this->decrypt($encoded); + if (!$plaintext) return null; + + $data = json_decode($plaintext, true); + return is_array($data) ? $data : null; + } +} diff --git a/app/Services/SocketService.php b/app/Services/SocketService.php new file mode 100644 index 0000000..8094d66 --- /dev/null +++ b/app/Services/SocketService.php @@ -0,0 +1,74 @@ +locationServerUrl = config('intaleq.location_server_url'); + $this->rideSocketUrl = config('intaleq.ride_socket_url'); + + $keyPath = config('intaleq.internal_socket_key_path'); + $this->internalKey = file_exists($keyPath) ? trim(file_get_contents($keyPath)) : ''; + } + + /** + * Notify passenger via ride socket server + */ + public function notifyPassenger(string $passengerId, array $payload): void + { + $this->sendAsync($this->rideSocketUrl, array_merge($payload, [ + 'action' => 'notify_passenger', + 'passenger_id' => $passengerId, + ])); + } + + /** + * Send event to location server (e.g., ride_taken, cancel_ride) + */ + public function sendToLocationServer(string $event, array $data): void + { + $this->sendAsync($this->locationServerUrl, array_merge($data, [ + 'action' => $event, + ])); + } + + /** + * Non-blocking HTTP POST (fire and forget with short timeout) + */ + private function sendAsync(string $url, array $data): void + { + try { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($data), + CURLOPT_TIMEOUT_MS => 500, + CURLOPT_NOSIGNAL => 1, + ]); + + if ($this->internalKey) { + curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: {$this->internalKey}"]); + } + + curl_exec($ch); + curl_close($ch); + } catch (\Exception $e) { + Log::warning("[Socket] Failed to send to {$url}: " . $e->getMessage()); + } + } +} diff --git a/artisan b/artisan new file mode 100644 index 0000000..954a58b --- /dev/null +++ b/artisan @@ -0,0 +1,11 @@ +#!/usr/bin/env php +handleCommand(new Symfony\Component\Console\Input\ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..e0a1e4f --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,52 @@ +withRouting( + api: __DIR__.'/../routes/api.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + // Register custom middleware aliases + $middleware->alias([ + 'hmac.auth' => \App\Http\Middleware\HmacAuthMiddleware::class, + 'jwt.auth' => \App\Http\Middleware\JwtAuthMiddleware::class, + 'admin' => \App\Http\Middleware\AdminMiddleware::class, + ]); + + // Global API middleware + $middleware->api(prepend: [ + \Illuminate\Http\Middleware\HandleCors::class, + ]); + + // Rate limiting for API + $middleware->throttleWithRedis(); + }) + ->withExceptions(function (Exceptions $exceptions) { + // Never expose internal errors to API consumers + $exceptions->render(function (\Throwable $e) { + if (request()->expectsJson() || request()->is('v2/*')) { + $status = method_exists($e, 'getStatusCode') ? $e->getStatusCode() : 500; + + $response = [ + 'status' => 'failure', + 'message' => $status === 500 ? 'Internal server error' : $e->getMessage(), + ]; + + // Only include debug info in non-production + if (config('app.debug') && $status === 500) { + $response['debug'] = [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile() . ':' . $e->getLine(), + ]; + } + + return response()->json($response, $status); + } + }); + }) + ->create(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3068e6b --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "name": "intaleq/api-v2", + "type": "project", + "description": "Intaleq V2 Secure API Gateway", + "require": { + "php": "^8.1", + "laravel/framework": "^11.0", + "firebase/php-jwt": "^6.10", + "predis/predis": "^2.2" + }, + "require-dev": { + "fakerphp/faker": "^1.23", + "laravel/pint": "^1.13", + "phpunit/phpunit": "^10.5" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi" + ], + "post-update-cmd": [ + "@php artisan vendor:publish --tag=laravel-assets --ansi --force" + ] + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..ccf2939 --- /dev/null +++ b/config/app.php @@ -0,0 +1,20 @@ + env('APP_NAME', 'IntaleqV2'), + 'env' => env('APP_ENV', 'production'), + 'debug' => (bool) env('APP_DEBUG', false), + 'url' => env('APP_URL', 'https://api-v2.intaleq.xyz'), + 'timezone' => 'UTC', + 'locale' => 'ar', + 'fallback_locale' => 'en', + 'faker_locale' => 'ar_SA', + 'cipher' => 'AES-256-CBC', + 'key' => env('APP_KEY'), + 'previous_keys' => [ + ...array_filter(explode(',', env('APP_PREVIOUS_KEYS', ''))), + ], + 'maintenance' => [ + 'driver' => 'file', + ], +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..f00933b --- /dev/null +++ b/config/cache.php @@ -0,0 +1,21 @@ + env('CACHE_DRIVER', 'redis'), + 'stores' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'cache', + 'lock_connection' => 'default', + ], + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + ], + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + ], + 'prefix' => env('CACHE_PREFIX', 'intaleq_v2'), +]; diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..f848700 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,25 @@ + ['v2/*'], + 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + 'allowed_origins' => [ + 'https://intaleq.xyz', + 'https://admin.intaleq.xyz', + 'https://api-v2.intaleq.xyz', + ], + 'allowed_origins_patterns' => [], + 'allowed_headers' => [ + 'Content-Type', + 'Authorization', + 'X-API-Key', + 'X-Timestamp', + 'X-Signature', + 'X-Nonce', + 'Accept', + 'X-Requested-With', + ], + 'exposed_headers' => [], + 'max_age' => 86400, + 'supports_credentials' => false, +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..d6c8036 --- /dev/null +++ b/config/database.php @@ -0,0 +1,104 @@ + env('DB_CONNECTION', 'primary'), + + 'connections' => [ + + /* + |-------------------------------------------------------------------------- + | Primary Database (intaleqDB1) — Main Server + | Tables: driver, passengers, tokens, CarRegistration, ride, wallets, etc. + |-------------------------------------------------------------------------- + */ + 'primary' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'intaleqDB1'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_general_ci', + 'prefix' => '', + 'strict' => false, + 'engine' => 'InnoDB', + 'options' => extension_loaded('pdo_mysql') ? [ + PDO::ATTR_PERSISTENT => true, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci", + ] : [], + ], + + /* + |-------------------------------------------------------------------------- + | Ride Database (intaleq-ridesDB) — Ride Server + | Tables: ride, driver, car_locations, driver_orders, etc. + |-------------------------------------------------------------------------- + */ + 'ride' => [ + 'driver' => 'mysql', + 'host' => env('DB_RIDE_HOST', '127.0.0.1'), + 'port' => env('DB_RIDE_PORT', '3306'), + 'database' => env('DB_RIDE_DATABASE', 'intaleq-ridesDB'), + 'username' => env('DB_RIDE_USERNAME', 'root'), + 'password' => env('DB_RIDE_PASSWORD', ''), + 'unix_socket' => env('DB_RIDE_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_general_ci', + 'prefix' => '', + 'strict' => false, + 'engine' => 'InnoDB', + 'options' => extension_loaded('pdo_mysql') ? [ + PDO::ATTR_PERSISTENT => true, + ] : [], + ], + + /* + |-------------------------------------------------------------------------- + | Tracking Database (locationDB) — Location Server + | Tables: car_locations, car_tracks, driver_daily_summary, driver_daily_work + |-------------------------------------------------------------------------- + */ + 'tracking' => [ + 'driver' => 'mysql', + 'host' => env('DB_TRACKING_HOST', '127.0.0.1'), + 'port' => env('DB_TRACKING_PORT', '3306'), + 'database' => env('DB_TRACKING_DATABASE', 'locationDB'), + 'username' => env('DB_TRACKING_USERNAME', 'root'), + 'password' => env('DB_TRACKING_PASSWORD', ''), + 'unix_socket' => env('DB_TRACKING_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_general_ci', + 'prefix' => '', + 'strict' => false, + 'engine' => 'InnoDB', + 'options' => extension_loaded('pdo_mysql') ? [ + PDO::ATTR_PERSISTENT => true, + ] : [], + ], + + ], + + 'redis' => [ + 'client' => env('REDIS_CLIENT', 'predis'), + 'default' => [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', 6379), + 'database' => env('REDIS_DB', 0), + ], + 'cache' => [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', 6379), + 'database' => env('REDIS_CACHE_DB', 1), + ], + ], + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], +]; diff --git a/config/intaleq.php b/config/intaleq.php new file mode 100644 index 0000000..7176cce --- /dev/null +++ b/config/intaleq.php @@ -0,0 +1,38 @@ + env('JWT_SECRET'), + 'hmac_tolerance' => env('HMAC_TOLERANCE_SECONDS', 300), + + // Encryption + 'legacy_enc_key_path' => env('LEGACY_ENC_KEY_PATH', '/home/intaleq-api/.enckey'), + 'legacy_iv' => env('LEGACY_IV', ''), + + // FCM + 'fcm_credentials_path' => env('FCM_CREDENTIALS_PATH', '/home/intaleq-api/firebase-credentials.json'), + 'fcm_cache_path' => env('FCM_CACHE_PATH', '/home/intaleq-api/fcm_token_cache.json'), + + // Internal Services + 'location_server_url' => env('LOCATION_SERVER_URL', 'http://localhost:2021'), + 'ride_socket_url' => env('RIDE_SOCKET_URL', 'http://localhost:3031'), + 'internal_socket_key_path' => env('INTERNAL_SOCKET_KEY_PATH', '/home/intaleq-api/.internal_socket_key'), + + // Rate Limiting + 'rate_limit_login' => (int) env('RATE_LIMIT_LOGIN', 5), + 'rate_limit_login_decay' => (int) env('RATE_LIMIT_LOGIN_DECAY', 60), + 'rate_limit_api' => (int) env('RATE_LIMIT_API', 60), + 'rate_limit_api_decay' => (int) env('RATE_LIMIT_API_DECAY', 60), + + // Upload + 'upload_max_size' => (int) env('UPLOAD_MAX_SIZE', 5242880), + 'upload_allowed_types' => explode(',', env('UPLOAD_ALLOWED_TYPES', 'jpg,jpeg,png,webp')), + 'upload_base_url' => env('UPLOAD_BASE_URL', 'https://intaleq.xyz'), + + // Secret Salt + 'secret_salt_parent' => env('SECRET_SALT_PARENT', ''), +]; 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 new file mode 100644 index 0000000..050bf01 --- /dev/null +++ b/database/migrations/2026_04_22_000001_add_api_keys_and_missing_indexes.php @@ -0,0 +1,110 @@ +hasColumn('driver', 'api_key')) { + Schema::connection('primary')->table('driver', function (Blueprint $table) { + $table->string('api_key', 64)->nullable()->after('expirationDate'); + $table->string('api_secret', 128)->nullable()->after('api_key'); + $table->index('api_key', 'idx_driver_api_key'); + }); + } + + // Add api_key and api_secret to passengers table + if (!Schema::connection('primary')->hasColumn('passengers', 'api_key')) { + Schema::connection('primary')->table('passengers', function (Blueprint $table) { + $table->string('api_key', 64)->nullable()->after('maritalStatus'); + $table->string('api_secret', 128)->nullable()->after('api_key'); + $table->index('api_key', 'idx_passenger_api_key'); + }); + } + + // Add api_key to adminUser + if (!Schema::connection('primary')->hasColumn('adminUser', 'api_key')) { + Schema::connection('primary')->table('adminUser', function (Blueprint $table) { + $table->string('api_key', 64)->nullable()->after('name'); + $table->string('api_secret', 128)->nullable()->after('api_key'); + $table->string('password_hash', 255)->nullable()->after('api_secret'); + }); + } + + // ══════════════════════════════════════════════ + // MISSING INDEXES — Performance optimization + // ══════════════════════════════════════════════ + + // driver_orders: missing index on order_id (used in every ride lifecycle query) + $this->addIndexIfMissing('primary', 'driver_orders', 'idx_do_order', 'order_id'); + $this->addCompoundIndexIfMissing('primary', 'driver_orders', 'idx_do_driver_status', ['driver_id', 'status']); + + // ride: compound index for status queries (most common query pattern) + $this->addCompoundIndexIfMissing('primary', 'ride', 'idx_ride_pass_status', ['passenger_id', 'status']); + $this->addCompoundIndexIfMissing('primary', 'ride', 'idx_ride_status_id', ['status', 'id']); + + // tokens: index on fingerPrint for device verification queries + $this->addCompoundIndexIfMissing('primary', 'tokens', 'idx_tokens_fp', ['passengerID', 'fingerPrint']); + + // ══════════════════════════════════════════════ + // RIDE DATABASE — Same indexes + // ══════════════════════════════════════════════ + $this->addIndexIfMissing('ride', 'driver_orders', 'idx_do_order', 'order_id'); + $this->addCompoundIndexIfMissing('ride', 'driver_orders', 'idx_do_driver_status', ['driver_id', 'status']); + + // ══════════════════════════════════════════════ + // TRACKING DATABASE — car_tracks index (missing) + // ══════════════════════════════════════════════ + // Note: tracking DB already has idx_driver_time on car_tracks + } + + public function down(): void + { + // Reversible: drop added columns and indexes + Schema::connection('primary')->table('driver', function (Blueprint $table) { + $table->dropIndex('idx_driver_api_key'); + $table->dropColumn(['api_key', 'api_secret']); + }); + Schema::connection('primary')->table('passengers', function (Blueprint $table) { + $table->dropIndex('idx_passenger_api_key'); + $table->dropColumn(['api_key', 'api_secret']); + }); + } + + // ── Helper Methods ── + + private function addIndexIfMissing(string $connection, string $table, string $indexName, string $column): void + { + $exists = DB::connection($connection) + ->select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + + if (empty($exists)) { + DB::connection($connection)->statement("CREATE INDEX `{$indexName}` ON `{$table}` (`{$column}`)"); + } + } + + private function addCompoundIndexIfMissing(string $connection, string $table, string $indexName, array $columns): void + { + $exists = DB::connection($connection) + ->select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + + if (empty($exists)) { + $cols = implode('`, `', $columns); + DB::connection($connection)->statement("CREATE INDEX `{$indexName}` ON `{$table}` (`{$cols}`)"); + } + } +}; diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..4fac916 --- /dev/null +++ b/public/index.php @@ -0,0 +1,24 @@ +make(Illuminate\Contracts\Http\Kernel::class); + +$response = $kernel->handle( + $request = Request::capture() +)->send(); + +$kernel->terminate($request, $response); diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..34bca8d --- /dev/null +++ b/routes/api.php @@ -0,0 +1,153 @@ +group(function () { + // Passenger + Route::post('/passenger/login', [AuthController::class, 'passengerLogin']); + Route::post('/passenger/register', [AuthController::class, 'passengerRegister']); + Route::post('/passenger/wallet-login', [AuthController::class, 'passengerWalletLogin']); + + // Driver + Route::post('/driver/login', [AuthController::class, 'driverLogin']); + Route::post('/driver/register', [AuthController::class, 'driverRegister']); + Route::post('/driver/wallet-login', [AuthController::class, 'driverWalletLogin']); + + // Admin & Service + Route::post('/admin/login', [AuthController::class, 'adminLogin']); +}); + +// OTP (public, but rate-limited) +Route::prefix('v2/otp')->middleware('throttle:10,1')->group(function () { + Route::post('/send', [OtpController::class, 'send']); + Route::post('/verify', [OtpController::class, 'verify']); + Route::post('/email/send', [OtpController::class, 'sendEmail']); + Route::post('/email/verify', [OtpController::class, 'verifyEmail']); + Route::get('/check-phone', [OtpController::class, 'checkPhone']); +}); + +// ══════════════════════════════════════════════ +// PROTECTED — Require JWT + HMAC +// ══════════════════════════════════════════════ +Route::prefix('v2')->middleware(['hmac.auth', 'jwt.auth'])->group(function () { + + // ── Rides ── + Route::post('/rides', [RideController::class, 'store']); + Route::get('/rides', [RideController::class, 'index']); + Route::get('/rides/active', [RideController::class, 'active']); + Route::get('/rides/{id}', [RideController::class, 'show']); + Route::post('/rides/{id}/accept', [RideController::class, 'accept']); + Route::post('/rides/{id}/arrive', [RideController::class, 'arrive']); + Route::post('/rides/{id}/start', [RideController::class, 'start']); + Route::post('/rides/{id}/finish', [RideController::class, 'finish']); + Route::post('/rides/{id}/cancel/passenger', [RideController::class, 'cancelByPassenger']); + Route::post('/rides/{id}/cancel/driver', [RideController::class, 'cancelByDriver']); + Route::post('/rides/{id}/retry', [RideController::class, 'retrySearch']); + Route::put('/rides/{id}', [RideController::class, 'update']); + + // ── Tracking ── + Route::get('/tracking/driver/{rideId}', [TrackingController::class, 'driverLocation']); + Route::get('/tracking/heatmap', [TrackingController::class, 'heatmap']); + Route::get('/tracking/captain-stats', [TrackingController::class, 'captainStats']); + + // ── Profile ── + Route::get('/profile/passenger', [ProfileController::class, 'passenger']); + Route::get('/profile/driver', [ProfileController::class, 'driver']); + Route::put('/profile/passenger', [ProfileController::class, 'updatePassenger']); + Route::put('/profile/driver/email', [ProfileController::class, 'updateDriverEmail']); + + // ── Wallet ── + Route::get('/wallet/passenger', [WalletController::class, 'index']); + Route::get('/wallet/passenger/balance', [WalletController::class, 'balance']); + Route::post('/wallet/passenger', [WalletController::class, 'addFunds']); + Route::put('/wallet/passenger', [WalletController::class, 'update']); + Route::get('/wallet/passenger/transactions', [WalletController::class, 'transactions']); + Route::post('/wallet/passenger/token', [WalletController::class, 'addToken']); + + // ── Ratings ── + Route::post('/ratings/driver', [RatingController::class, 'rateDriver']); + Route::post('/ratings/passenger', [RatingController::class, 'ratePassenger']); + Route::post('/ratings/app', [RatingController::class, 'rateApp']); + Route::get('/ratings/driver/{id}', [RatingController::class, 'driverRating']); + Route::get('/ratings/passenger/{id}', [RatingController::class, 'passengerRating']); + + // ── Promos ── + Route::get('/promos', [PromoController::class, 'index']); + Route::get('/promos/check', [PromoController::class, 'check']); + Route::post('/promos', [PromoController::class, 'store']); + Route::put('/promos/{id}', [PromoController::class, 'update']); + Route::delete('/promos/{id}', [PromoController::class, 'destroy']); + + // ── Uploads ── + Route::post('/uploads/card-image', [UploadController::class, 'cardImage']); + Route::post('/uploads/profile-image', [UploadController::class, 'profileImage']); + Route::post('/uploads/document', [UploadController::class, 'document']); + Route::post('/uploads/id-front', [UploadController::class, 'idFront']); + Route::post('/uploads/id-back', [UploadController::class, 'idBack']); + Route::post('/uploads/audio', [UploadController::class, 'audio']); + + // ── Places ── + Route::get('/places/search', [PlaceController::class, 'search']); + Route::post('/places', [PlaceController::class, 'store']); + + // ── Notifications ── + Route::get('/notifications', [NotificationController::class, 'index']); + Route::put('/notifications/{id}/read', [NotificationController::class, 'markRead']); +}); + +// ══════════════════════════════════════════════ +// PUBLIC Tracking (special — uses hash auth like V1) +// ══════════════════════════════════════════════ +Route::get('v2/tracking/public/{rideId}', [TrackingController::class, 'publicTrack']); + +// ══════════════════════════════════════════════ +// ADMIN ROUTES (require admin JWT) +// ══════════════════════════════════════════════ +Route::prefix('v2/admin')->middleware(['hmac.auth', 'jwt.auth', 'admin'])->group(function () { + // Driver management + Route::get('/drivers', [Admin\DriverManagementController::class, 'index']); + Route::get('/drivers/search', [Admin\DriverManagementController::class, 'search']); + Route::post('/drivers/{id}/activate', [Admin\DriverManagementController::class, 'activate']); + Route::post('/drivers/{id}/deactivate', [Admin\DriverManagementController::class, 'deactivate']); + Route::post('/drivers/{id}/add-car', [Admin\DriverManagementController::class, 'addCar']); + Route::post('/drivers/{id}/notes', [Admin\DriverManagementController::class, 'addNote']); + + // Passenger management + Route::get('/passengers', [Admin\PassengerManagementController::class, 'index']); + Route::get('/passengers/search', [Admin\PassengerManagementController::class, 'search']); + + // Ride management + Route::get('/rides', [Admin\RideManagementController::class, 'index']); + Route::get('/rides/{id}', [Admin\RideManagementController::class, 'show']); + + // Stats + Route::get('/stats/overview', [Admin\StatsController::class, 'overview']); + Route::get('/stats/rides', [Admin\StatsController::class, 'rides']); + Route::get('/stats/drivers-monthly', [Admin\StatsController::class, 'driversMonthly']); + Route::get('/stats/employees', [Admin\StatsController::class, 'employees']); +}); diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..41cb781 --- /dev/null +++ b/setup.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +############################################### +# Intaleq V2 — Server Setup Script +# Run this ONCE on the server after uploading +############################################### + +set -e + +echo "=== Intaleq V2 Setup ===" + +# 1. Install dependencies +echo "[1/6] Installing Composer dependencies..." +composer install --no-dev --optimize-autoloader + +# 2. Copy environment file +if [ ! -f .env ]; then + echo "[2/6] Creating .env from template..." + cp .env.example .env + echo "⚠️ IMPORTANT: Edit .env with your actual credentials!" +else + echo "[2/6] .env already exists, skipping..." +fi + +# 3. Generate app key +echo "[3/6] Generating application key..." +php artisan key:generate + +# 4. Cache config for performance +echo "[4/6] Caching configuration..." +php artisan config:cache +php artisan route:cache + +# 5. Set permissions +echo "[5/6] Setting permissions..." +chmod -R 775 storage bootstrap/cache +chown -R www-data:www-data storage bootstrap/cache + +# 6. Run migrations (add indexes and api columns) +echo "[6/6] Running database migrations..." +echo "⚠️ This will add api_key/api_secret columns and missing indexes." +echo "⚠️ It will NOT delete or modify existing data." +read -p "Continue? (y/n): " confirm +if [ "$confirm" = "y" ]; then + php artisan migrate + echo "✅ Migrations complete!" +else + echo "⏭️ Migrations skipped. Run 'php artisan migrate' manually." +fi + +echo "" +echo "=== Setup Complete ===" +echo "" +echo "Next steps:" +echo "1. Edit .env with real DB credentials, JWT secret, etc." +echo "2. Configure Nginx to point to public/ directory" +echo "3. Run: php artisan config:cache" +echo "4. Test: curl https://your-domain/v2/auth/passenger/login" +echo "" +echo "Nginx config example:" +echo " location /v2 {" +echo " try_files \$uri \$uri/ /index.php?\$query_string;" +echo " }"