Initial V2 commit
This commit is contained in:
90
.env.example
Normal file
90
.env.example
Normal 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
17
.gitignore
vendored
Normal 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
|
||||
97
app/Helpers/LegacyEncryption.php
Normal file
97
app/Helpers/LegacyEncryption.php
Normal 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);
|
||||
}
|
||||
}
|
||||
179
app/Http/Controllers/Admin/DriverManagementController.php
Normal file
179
app/Http/Controllers/Admin/DriverManagementController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/Admin/PassengerManagementController.php
Normal file
74
app/Http/Controllers/Admin/PassengerManagementController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Admin/RideManagementController.php
Normal file
55
app/Http/Controllers/Admin/RideManagementController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Admin/StatsController.php
Normal file
85
app/Http/Controllers/Admin/StatsController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
465
app/Http/Controllers/AuthController.php
Normal file
465
app/Http/Controllers/AuthController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
abstract class Controller extends BaseController
|
||||
{
|
||||
//
|
||||
}
|
||||
52
app/Http/Controllers/NotificationController.php
Normal file
52
app/Http/Controllers/NotificationController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
153
app/Http/Controllers/OtpController.php
Normal file
153
app/Http/Controllers/OtpController.php
Normal 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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/PlaceController.php
Normal file
71
app/Http/Controllers/PlaceController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
148
app/Http/Controllers/ProfileController.php
Normal file
148
app/Http/Controllers/ProfileController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
108
app/Http/Controllers/PromoController.php
Normal file
108
app/Http/Controllers/PromoController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
144
app/Http/Controllers/RatingController.php
Normal file
144
app/Http/Controllers/RatingController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
544
app/Http/Controllers/RideController.php
Normal file
544
app/Http/Controllers/RideController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
141
app/Http/Controllers/TrackingController.php
Normal file
141
app/Http/Controllers/TrackingController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/UploadController.php
Normal file
171
app/Http/Controllers/UploadController.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
151
app/Http/Controllers/WalletController.php
Normal file
151
app/Http/Controllers/WalletController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
app/Http/Middleware/AdminMiddleware.php
Normal file
27
app/Http/Middleware/AdminMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
136
app/Http/Middleware/HmacAuthMiddleware.php
Normal file
136
app/Http/Middleware/HmacAuthMiddleware.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
56
app/Http/Middleware/JwtAuthMiddleware.php
Normal file
56
app/Http/Middleware/JwtAuthMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/Models/CarLocation.php
Normal file
14
app/Models/CarLocation.php
Normal 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'];
|
||||
}
|
||||
12
app/Models/CarRegistration.php
Normal file
12
app/Models/CarRegistration.php
Normal 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
105
app/Models/Driver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
10
app/Models/DriverDocument.php
Normal file
10
app/Models/DriverDocument.php
Normal 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
10
app/Models/DriverGift.php
Normal 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'];
|
||||
}
|
||||
10
app/Models/DriverHealthAssurance.php
Normal file
10
app/Models/DriverHealthAssurance.php
Normal 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'];
|
||||
}
|
||||
10
app/Models/DriverOrder.php
Normal file
10
app/Models/DriverOrder.php
Normal 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'];
|
||||
}
|
||||
12
app/Models/DriverToken.php
Normal file
12
app/Models/DriverToken.php
Normal 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'];
|
||||
}
|
||||
10
app/Models/ImageProfileCaptain.php
Normal file
10
app/Models/ImageProfileCaptain.php
Normal 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
56
app/Models/Passenger.php
Normal 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');
|
||||
}
|
||||
}
|
||||
12
app/Models/PassengerToken.php
Normal file
12
app/Models/PassengerToken.php
Normal 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'];
|
||||
}
|
||||
13
app/Models/PassengerWallet.php
Normal file
13
app/Models/PassengerWallet.php
Normal 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'];
|
||||
}
|
||||
10
app/Models/RatingDriver.php
Normal file
10
app/Models/RatingDriver.php
Normal 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'];
|
||||
}
|
||||
10
app/Models/RatingPassenger.php
Normal file
10
app/Models/RatingPassenger.php
Normal 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
68
app/Models/Ride.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
27
app/Models/WaitingRide.php
Normal file
27
app/Models/WaitingRide.php
Normal 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
177
app/Services/FcmService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
104
app/Services/PayloadCrypto.php
Normal file
104
app/Services/PayloadCrypto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
app/Services/SocketService.php
Normal file
74
app/Services/SocketService.php
Normal 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
11
artisan
Normal 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
52
bootstrap/app.php
Normal 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
53
composer.json
Normal 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
20
config/app.php
Normal 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
21
config/cache.php
Normal 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
25
config/cors.php
Normal 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
104
config/database.php
Normal 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
38
config/intaleq.php
Normal 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', ''),
|
||||
];
|
||||
@@ -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
24
public/index.php
Normal 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
153
routes/api.php
Normal 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
63
setup.sh
Executable 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 " }"
|
||||
Reference in New Issue
Block a user