Initial V2 commit

This commit is contained in:
Hamza-Ayed
2026-04-22 21:59:56 +03:00
commit 4706404488
53 changed files with 4392 additions and 0 deletions

90
.env.example Normal file
View File

@@ -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

17
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Helpers;
/**
* Legacy Encryption Helper
*
* Backward-compatible encryption for data stored in the database.
* Uses AES-256-CBC with static IV (same as V1) to read existing encrypted data.
*
* WARNING: This class uses a static IV for backward compatibility only.
* For new payload encryption between Flutter and server, use PayloadCrypto service.
*/
class LegacyEncryption
{
private string $key;
private string $iv;
private string $cipher = 'aes-256-cbc';
public function __construct()
{
$keyPath = config('intaleq.legacy_enc_key_path', '/home/intaleq-api/.enckey');
if (!file_exists($keyPath)) {
throw new \RuntimeException("Encryption key file not found: {$keyPath}");
}
$this->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);
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Helpers\LegacyEncryption;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Admin Driver Management Controller
* Replaces: serviceapp/getDriverByPhone.php, getDriverByNational.php,
* getDriversWaitingActive.php, getDriverDetailsForActivate.php,
* updateDriverToActive.php, registerDriverAndCarService.php, etc.
*/
class DriverManagementController extends Controller
{
private LegacyEncryption $enc;
public function __construct(LegacyEncryption $enc) { $this->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']);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Passenger;
use App\Helpers\LegacyEncryption;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Admin Passenger Management Controller
*/
class PassengerManagementController extends Controller
{
private LegacyEncryption $enc;
public function __construct(LegacyEncryption $enc)
{
$this->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]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Admin Ride Management Controller
*/
class RideManagementController extends Controller
{
/** GET /v2/admin/rides */
public function index(Request $request): JsonResponse
{
$status = $request->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]);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Admin Stats Controller
* Replaces: serviceapp/getRidesStatic.php, getPassengersStatic.php,
* getEmployeeStatic.php, getdriverstotalMonthly.php, getEditorStatsCalls.php
*/
class StatsController extends Controller
{
/** GET /v2/admin/stats/overview */
public function overview(): JsonResponse
{
$totalDrivers = DB::connection('ride')->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]);
}
}

View File

@@ -0,0 +1,465 @@
<?php
namespace App\Http\Controllers;
use App\Models\Driver;
use App\Models\Passenger;
use App\Models\DriverToken;
use App\Models\PassengerToken;
use App\Helpers\LegacyEncryption;
use Firebase\JWT\JWT;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* Authentication Controller
*
* Unifies all V1 login flows:
* login.php → passengerLogin
* loginFirstTime.php → passengerRegister
* loginJwtDriver.php → driverLogin
* loginFirstTimeDriver.php → driverRegister
* loginWallet.php → passengerWalletLogin
* loginJwtWalletDriver.php → driverWalletLogin
* loginAdmin.php → adminLogin
*/
class AuthController extends Controller
{
private LegacyEncryption $encryption;
public function __construct(LegacyEncryption $encryption)
{
$this->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);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
{
//
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Notification Controller
* Replaces: ride/notification/*.php
*/
class NotificationController extends Controller
{
/** GET /v2/notifications */
public function index(Request $request): JsonResponse
{
$userId = $request->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']);
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* OTP Controller
* Replaces: auth/otpmessage.php, verifyOtpMessage.php, sendVerifyEmail.php, etc.
*/
class OtpController extends Controller
{
/** POST /v2/otp/send */
public function send(Request $request): JsonResponse
{
$request->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],
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Place Controller
* Replaces: ride/places/add.php, ride/places_syria/*.php
*/
class PlaceController extends Controller
{
/** GET /v2/places/search?q=XXX&lat=XX&lng=XX */
public function search(Request $request): JsonResponse
{
$q = $request->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);
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers;
use App\Models\Driver;
use App\Models\Passenger;
use App\Models\CarRegistration;
use App\Models\ImageProfileCaptain;
use App\Helpers\LegacyEncryption;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Profile Controller
* Replaces: ride/profile/get.php, getCaptainProfile.php, update.php, updateDriverEmail.php
*/
class ProfileController extends Controller
{
private LegacyEncryption $enc;
public function __construct(LegacyEncryption $enc)
{
$this->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']);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Promo Controller
* Replaces: ride/promo/*.php
*/
class PromoController extends Controller
{
/** GET /v2/promos */
public function index(Request $request): JsonResponse
{
$passengerId = $request->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']);
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Rating Controller
* Replaces: ride/rate/*.php
*/
class RatingController extends Controller
{
/** POST /v2/ratings/driver — passenger rates a driver */
public function rateDriver(Request $request): JsonResponse
{
$request->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,
],
]);
}
}

View File

@@ -0,0 +1,544 @@
<?php
namespace App\Http\Controllers;
use App\Models\Ride;
use App\Models\Driver;
use App\Models\DriverToken;
use App\Models\PassengerToken;
use App\Models\DriverOrder;
use App\Models\CarLocation;
use App\Helpers\LegacyEncryption;
use App\Services\FcmService;
use App\Services\SocketService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Ride Controller
*
* Handles the complete ride lifecycle:
* create → search drivers → accept → arrive → start → finish/cancel
*/
class RideController extends Controller
{
private LegacyEncryption $encryption;
private FcmService $fcm;
private SocketService $socket;
public function __construct(LegacyEncryption $encryption, FcmService $fcm, SocketService $socket)
{
$this->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]);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Tracking Controller
* Replaces: ride/rides/get_driver_location.php, public_track_location.php, getRealTimeHeatmap.php
*/
class TrackingController extends Controller
{
/** GET /v2/tracking/driver/{rideId} */
public function driverLocation(Request $request, int $rideId): JsonResponse
{
$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();
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,
],
]);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Upload Controller
* Replaces: uploadImage.php, uploadImagePortrate.php, uploadImageType.php, etc.
*
* Security improvements over V1:
* - MIME type + magic bytes validation (not just extension)
* - Randomized filenames (prevents path traversal)
* - Max file size enforcement
* - No directory traversal possible
*/
class UploadController extends Controller
{
private const ALLOWED_IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/webp'];
private const ALLOWED_AUDIO_MIMES = ['audio/mpeg', 'audio/mp4', 'audio/wav', 'audio/ogg'];
private const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
private const MAX_AUDIO_SIZE = 10 * 1024 * 1024; // 10MB
/** POST /v2/uploads/card-image */
public function cardImage(Request $request): JsonResponse
{
return $this->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,
};
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Wallet Controller
* Replaces: ride/passengerWallet/*.php, ride/driverWallet/*.php
*/
class WalletController extends Controller
{
/** GET /v2/wallet/passenger */
public function index(Request $request): JsonResponse
{
$id = $request->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);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
/**
* Admin Role Middleware
* Ensures the authenticated user has admin privileges
*/
class AdminMiddleware
{
public function handle(Request $request, Closure $next)
{
$userType = $request->input('_jwt_user_type');
if ($userType !== 'admin') {
return response()->json([
'status' => 'failure',
'message' => 'Unauthorized — admin access required',
], 403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
/**
* HMAC Signature Validation Middleware
*
* Validates every API request using HMAC-SHA256 signatures.
* Prevents: Replay attacks, Man-in-the-Middle, Tampering.
*
* Required Headers:
* X-API-Key: unique per user (stored in DB)
* X-Timestamp: Unix timestamp (must be within TOLERANCE window)
* X-Signature: HMAC-SHA256(timestamp|api_key|request_body, api_secret)
* X-Nonce: unique per request (prevents replay attacks)
*/
class HmacAuthMiddleware
{
private const ALGORITHM = 'sha256';
public function handle(Request $request, Closure $next)
{
$apiKey = $request->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;
});
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
/**
* JWT Authentication Middleware
*
* Validates JWT tokens from the Authorization header.
* Works in conjunction with HMAC middleware for double-layer security.
*/
class JwtAuthMiddleware
{
public function handle(Request $request, Closure $next)
{
$authHeader = $request->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);
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CarLocation extends Model
{
protected $connection = 'tracking';
protected $table = 'car_locations';
protected $primaryKey = 'driver_id';
protected $keyType = 'string';
public $incrementing = false;
public $timestamps = false;
protected $fillable = ['driver_id', 'latitude', 'longitude', 'heading', 'speed', 'distance', 'status', 'carType'];
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CarRegistration extends Model
{
protected $connection = 'primary';
protected $table = 'CarRegistration';
public $timestamps = false;
protected $fillable = ['driverID', 'vin', 'car_plate', 'make', 'model', 'year', 'expiration_date', 'color', 'owner', 'color_hex', 'fuel', 'isDefault', 'status', 'vehicle_category_id', 'fuel_type_id'];
public const ENCRYPTED_FIELDS = ['car_plate', 'owner'];
}

105
app/Models/Driver.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Driver Model (Primary DB)
*
* Note: Primary key is `idn` (auto-increment), but the business ID is `id` (varchar).
* All endpoints use `id` (varchar) for lookups, not `idn`.
*/
class Driver extends Model
{
protected $connection = 'primary';
protected $table = 'driver';
protected $primaryKey = 'idn';
public $timestamps = true;
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
protected $fillable = [
'id', 'phone', 'email', 'password', 'gender', 'license_type',
'national_number', 'name_arabic', 'issue_date', 'expiry_date',
'license_categories', 'address', 'licenseIssueDate', 'status',
'birthdate', 'site', 'first_name', 'last_name', 'accountBank',
'bankCode', 'employmentType', 'maritalStatus', 'fullNameMaritial',
'expirationDate', 'api_key', 'api_secret',
];
protected $hidden = ['password', 'api_secret'];
/** Encrypted fields that need legacy decryption */
public const ENCRYPTED_FIELDS = [
'first_name', 'last_name', 'phone', 'gender', 'email',
'national_number', 'name_arabic', 'address', 'birthdate',
];
// ── Relationships ──
public function token()
{
return $this->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);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DriverDocument extends Model {
protected $connection = 'primary';
protected $table = 'driver_documents';
public $timestamps = false;
protected $fillable = ['driverID', 'doc_type', 'image_name', 'link', 'upload_date'];
}

10
app/Models/DriverGift.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DriverGift extends Model {
protected $connection = 'primary';
protected $table = 'driver_gifts';
public $timestamps = false;
protected $fillable = ['driver_id', 'gift_description', 'gift_date', 'is_claimed'];
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DriverHealthAssurance extends Model {
protected $connection = 'primary';
protected $table = 'driver_health_assurance';
public $timestamps = false;
protected $fillable = ['driver_id', 'assured', 'date_created', 'health_insurance_provider'];
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DriverOrder extends Model {
protected $connection = 'primary';
protected $table = 'driver_orders';
public $timestamps = false;
protected $fillable = ['driver_id', 'order_id', 'notes', 'created_at', 'status'];
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DriverToken extends Model
{
protected $connection = 'primary';
protected $table = 'driverToken';
public $timestamps = false;
protected $fillable = ['token', 'captain_id', 'fingerPrint', 'created_at'];
public const ENCRYPTED_FIELDS = ['token'];
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ImageProfileCaptain extends Model {
protected $connection = 'primary';
protected $table = 'imageProfileCaptain';
public $timestamps = false;
protected $fillable = ['driverID', 'image_name', 'upload_date', 'link'];
}

56
app/Models/Passenger.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Passenger extends Model
{
protected $connection = 'primary';
protected $table = 'passengers';
protected $primaryKey = 'id';
protected $keyType = 'string';
public $incrementing = false;
public $timestamps = true;
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
protected $fillable = [
'id', 'phone', 'email', 'password', 'gender', 'status',
'birthdate', 'site', 'first_name', 'last_name', 'sosPhone',
'education', 'employmentType', 'maritalStatus',
'api_key', 'api_secret',
];
protected $hidden = ['password', 'api_secret'];
public const ENCRYPTED_FIELDS = [
'first_name', 'last_name', 'phone', 'gender', 'email', 'birthdate',
];
public function token()
{
return $this->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');
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PassengerToken extends Model
{
protected $connection = 'primary';
protected $table = 'tokens';
public $timestamps = false;
protected $fillable = ['token', 'passengerID', 'fingerPrint', 'status'];
public const ENCRYPTED_FIELDS = ['token'];
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PassengerWallet extends Model {
protected $connection = 'primary';
protected $table = 'passengerWallet';
public $timestamps = true;
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
protected $fillable = ['passenger_id', 'balance'];
protected $casts = ['balance' => 'decimal:2'];
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RatingDriver extends Model {
protected $connection = 'primary';
protected $table = 'ratingDriver';
public $timestamps = false;
protected $fillable = ['passenger_id', 'driver_id', 'ride_id', 'rating', 'comment', 'created_at'];
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RatingPassenger extends Model {
protected $connection = 'primary';
protected $table = 'ratingPassenger';
public $timestamps = false;
protected $fillable = ['passenger_id', 'driverID', 'rideId', 'rating', 'comment', 'created_at'];
}

68
app/Models/Ride.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Ride Model
*
* Exists on BOTH Primary and Ride databases.
* Default connection is 'ride' (for real-time operations).
* Use Ride::on('primary') when querying the primary DB copy.
*/
class Ride extends Model
{
protected $connection = 'ride';
protected $table = 'ride';
public $timestamps = false; // Uses custom timestamp columns
protected $fillable = [
'start_location', 'end_location', 'date', 'time', 'endtime',
'price', 'passenger_id', 'driver_id', 'status', 'paymentMethod',
'carType', 'created_at', 'updated_at', 'DriverIsGoingToPassenger',
'rideTimeStart', 'rideTimeFinish', 'price_for_driver',
'price_for_passenger', 'distance',
];
protected $casts = [
'price' => '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']);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WaitingRide extends Model {
protected $connection = 'primary';
protected $table = 'waitingRides';
protected $primaryKey = 'id';
protected $keyType = 'string';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'id', 'start_location', 'start_lat', 'start_lng', 'end_location',
'end_lat', 'end_lng', 'date', 'time', 'price', 'passenger_id',
'status', 'carType', 'passengerRate', 'created_at',
'price_for_passenger', 'distance', 'duration', 'payment_method',
'passenger_wallet',
];
protected $casts = [
'start_lat' => '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');
}
}

177
app/Services/FcmService.php Normal file
View File

@@ -0,0 +1,177 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
/**
* FCM Notification Service
*
* Sends push notifications via Firebase Cloud Messaging (HTTP v1 API).
* Replaces the scattered sendFCM_Internal() calls from V1.
*/
class FcmService
{
private ?string $accessToken = null;
private string $credentialsPath;
private string $cachePath;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Services;
/**
* Payload Crypto Service — AES-256-GCM
*
* Dynamic encryption for all payloads between Flutter apps and the API.
* Unlike LegacyEncryption which uses static IV, this generates a unique IV per request.
*
* Format: base64(IV + ciphertext + tag)
* - IV: 12 bytes (random per encryption)
* - Tag: 16 bytes (integrity verification)
*/
class PayloadCrypto
{
private string $key;
private const CIPHER = 'aes-256-gcm';
private const IV_LENGTH = 12;
private const TAG_LENGTH = 16;
public function __construct()
{
$keyPath = config('intaleq.legacy_enc_key_path');
if (!file_exists($keyPath)) {
throw new \RuntimeException('Encryption key not found');
}
// Derive a 32-byte key from the stored key using HKDF
$rawKey = trim(file_get_contents($keyPath));
$this->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;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
/**
* Socket Communication Service
*
* Communicates with Node.js socket servers for real-time events.
* Replaces hardcoded IPs in V1 with .env configuration.
*/
class SocketService
{
private string $locationServerUrl;
private string $rideSocketUrl;
private string $internalKey;
public function __construct()
{
$this->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());
}
}
}

11
artisan Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env php
<?php
define('LARAVEL_START', microtime(true));
require __DIR__.'/vendor/autoload.php';
$status = (require_once __DIR__.'/bootstrap/app.php')
->handleCommand(new Symfony\Component\Console\Input\ArgvInput);
exit($status);

52
bootstrap/app.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->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();

53
composer.json Normal file
View File

@@ -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
}

20
config/app.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
return [
'name' => 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',
],
];

21
config/cache.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
return [
'default' => 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'),
];

25
config/cors.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
return [
'paths' => ['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,
];

104
config/database.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
return [
'default' => 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,
],
];

38
config/intaleq.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
/**
* Intaleq V2 — Custom configuration
* All secrets and paths are externalized to .env
*/
return [
// JWT
'jwt_secret' => 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', ''),
];

View File

@@ -0,0 +1,110 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
/**
* Add missing indexes + api_key/api_secret columns for HMAC auth
*
* SAFE: Only adds columns/indexes — does NOT modify existing data
*/
return new class extends Migration
{
public function up(): void
{
// ══════════════════════════════════════════════
// PRIMARY DATABASE — Add API columns
// ══════════════════════════════════════════════
// Add api_key and api_secret to driver table
if (!Schema::connection('primary')->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}`)");
}
}
};

24
public/index.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the incoming request
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Request::capture()
)->send();
$kernel->terminate($request, $response);

153
routes/api.php Normal file
View File

@@ -0,0 +1,153 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\RideController;
use App\Http\Controllers\TrackingController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\WalletController;
use App\Http\Controllers\RatingController;
use App\Http\Controllers\PromoController;
use App\Http\Controllers\OtpController;
use App\Http\Controllers\UploadController;
use App\Http\Controllers\PlaceController;
use App\Http\Controllers\NotificationController;
/*
|--------------------------------------------------------------------------
| Intaleq V2 API Routes
|--------------------------------------------------------------------------
|
| All routes are prefixed with /v2 and use JSON responses.
| Public routes: auth endpoints only.
| Protected routes: require JWT + HMAC middleware.
|
*/
// ══════════════════════════════════════════════
// PUBLIC — Authentication (no middleware)
// ══════════════════════════════════════════════
Route::prefix('v2/auth')->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']);
});

63
setup.sh Executable file
View File

@@ -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 " }"