Clone Nabih Pure PHP Architecture
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# Application Settings
|
||||
APP_NAME=Nabeh
|
||||
APP_ENV=development
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
# Main Master Database Configuration
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=nabeh_master
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
# AI Model Configuration
|
||||
GEMINI_API_KEY=
|
||||
ELEVENLABS_API_KEY=
|
||||
ELEVENLABS_VOICE_ID=EXAVITQu4vr4xnSDxMaL
|
||||
|
||||
# Messaging Gateway Settings
|
||||
WHATSAPP_GATEWAY_URL=http://localhost:3722
|
||||
|
||||
# OWASP Security Settings
|
||||
# Generate a secure 32-byte (256-bit) key for AES encryption
|
||||
ENCRYPTION_KEY=d3b07384d113edec49eaa6238ad5ff00f898129dfdeca34289adcd11a00a89d1
|
||||
# Secret key/salt for blind index hashes
|
||||
HMAC_SALT=nabeh_secure_blind_index_salt_key_123
|
||||
# Secret key for JWT signatures
|
||||
JWT_SECRET=nabeh_jwt_secret_signature_key_987654
|
||||
|
||||
# Redis Settings (Optional caching - falls back to DB if disabled/unavailable)
|
||||
REDIS_ENABLED=false
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Cache helper class providing Redis-based caching with automatic database fallback.
|
||||
*/
|
||||
class Cache
|
||||
{
|
||||
private static ?\Redis $client = null;
|
||||
private static bool $connectionAttempted = false;
|
||||
|
||||
/**
|
||||
* Get initialized Redis client or null if unavailable/disabled.
|
||||
*/
|
||||
private static function getClient(): ?\Redis
|
||||
{
|
||||
if (self::$connectionAttempted) {
|
||||
return self::$client;
|
||||
}
|
||||
|
||||
self::$connectionAttempted = true;
|
||||
|
||||
if (!Env::get('REDIS_ENABLED', false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!class_exists('\Redis')) {
|
||||
error_log('[Cache Warning] phpredis extension is not installed.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$redis = new \Redis();
|
||||
$host = Env::get('REDIS_HOST', '127.0.0.1');
|
||||
$port = (int)Env::get('REDIS_PORT', 6379);
|
||||
$password = Env::get('REDIS_PASSWORD', null);
|
||||
$db = (int)Env::get('REDIS_DB', 0);
|
||||
|
||||
// Connect with 1.0s timeout to prevent request blocking
|
||||
$connected = $redis->connect($host, $port, 1.0);
|
||||
if (!$connected) {
|
||||
error_log("[Cache Warning] Failed to connect to Redis at {$host}:{$port}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($password !== null && $password !== '' && strtolower($password) !== 'null') {
|
||||
$redis->auth($password);
|
||||
}
|
||||
|
||||
if ($db > 0) {
|
||||
$redis->select($db);
|
||||
}
|
||||
|
||||
self::$client = $redis;
|
||||
} catch (\Exception $e) {
|
||||
error_log('[Cache Error] Redis connection error: ' . $e->getMessage());
|
||||
self::$client = null;
|
||||
}
|
||||
|
||||
return self::$client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get prefixed key
|
||||
*/
|
||||
private static function getPrefixedKey(string $key): string
|
||||
{
|
||||
return 'nabeh:' . $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cache item by key.
|
||||
*/
|
||||
public static function get(string $key)
|
||||
{
|
||||
$client = self::getClient();
|
||||
if (!$client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$prefixedKey = self::getPrefixedKey($key);
|
||||
$value = $client->get($prefixedKey);
|
||||
return $value !== false ? json_decode($value, true) : null;
|
||||
} catch (\Exception $e) {
|
||||
error_log('[Cache Error] Redis get failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache item by key with TTL.
|
||||
*/
|
||||
public static function set(string $key, $value, int $ttl = 3600): bool
|
||||
{
|
||||
$client = self::getClient();
|
||||
if (!$client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$prefixedKey = self::getPrefixedKey($key);
|
||||
return $client->setex($prefixedKey, $ttl, json_encode($value));
|
||||
} catch (\Exception $e) {
|
||||
error_log('[Cache Error] Redis set failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache item by key.
|
||||
*/
|
||||
public static function delete(string $key): bool
|
||||
{
|
||||
$client = self::getClient();
|
||||
if (!$client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$prefixedKey = self::getPrefixedKey($key);
|
||||
return $client->del($prefixedKey) > 0;
|
||||
} catch (\Exception $e) {
|
||||
error_log('[Cache Error] Redis delete failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cache item, or run callback and store results if cache misses.
|
||||
*/
|
||||
public static function remember(string $key, int $ttl, callable $callback)
|
||||
{
|
||||
$value = self::get($key);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value = $callback();
|
||||
self::set($key, $value, $ttl);
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* PDO Database wrapper using Singleton pattern.
|
||||
*/
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
/**
|
||||
* Get active PDO database instance
|
||||
*
|
||||
* @return PDO
|
||||
* @throws PDOException
|
||||
*/
|
||||
public static function getConnection(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
$host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||
$port = getenv('DB_PORT') ?: '3306';
|
||||
$dbName = getenv('DB_DATABASE') ?: 'nabeh_master';
|
||||
$username = getenv('DB_USERNAME') ?: 'root';
|
||||
$password = getenv('DB_PASSWORD') ?: '';
|
||||
|
||||
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
try {
|
||||
self::$instance = new PDO($dsn, $username, $password, $options);
|
||||
} catch (PDOException $e) {
|
||||
// Log the exact error internally but hide sensitive DSN on production
|
||||
error_log("Database Connection Error: " . $e->getMessage());
|
||||
throw new PDOException("Could not connect to the database. Check database settings.");
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand execute statement with parameters
|
||||
*
|
||||
* @param string $sql
|
||||
* @param array $params
|
||||
* @return \PDOStatement
|
||||
*/
|
||||
public static function query(string $sql, array $params = []): \PDOStatement
|
||||
{
|
||||
$pdo = self::getConnection();
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all matching records
|
||||
*/
|
||||
public static function select(string $sql, array $params = []): array
|
||||
{
|
||||
return self::query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve single matching record
|
||||
*/
|
||||
public static function selectOne(string $sql, array $params = [])
|
||||
{
|
||||
return self::query($sql, $params)->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert record and return last inserted ID
|
||||
*/
|
||||
public static function insert(string $sql, array $params = []): string
|
||||
{
|
||||
$pdo = self::getConnection();
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute generic non-query SQL (Update/Delete) and return affected rows
|
||||
*/
|
||||
public static function execute(string $sql, array $params = []): int
|
||||
{
|
||||
return self::query($sql, $params)->rowCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Lightweight secure environment variable loader
|
||||
*/
|
||||
class Env
|
||||
{
|
||||
/**
|
||||
* Load environment variables from a file path
|
||||
*
|
||||
* @param string $path
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function load(string $path): void
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
// Create a default if it doesn't exist to prevent crash, or throw
|
||||
if (file_exists($path . '.example')) {
|
||||
copy($path . '.example', $path);
|
||||
} else {
|
||||
throw new \Exception("Environment file not found at: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (empty($line) || strpos($line, '#') === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split by the first equals sign
|
||||
if (strpos($line, '=') !== false) {
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
|
||||
// Strip surrounding quotes
|
||||
if (preg_match('/^"(.+)"$/', $value, $matches) || preg_match("/^'(.+)'$/", $value, $matches)) {
|
||||
$value = $matches[1];
|
||||
}
|
||||
|
||||
// Inject into PHP superglobals and env
|
||||
putenv("{$key}={$value}");
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve environment variable with optional default value
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get(string $key, $default = null)
|
||||
{
|
||||
$val = getenv($key);
|
||||
if ($val === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
switch (strtolower($val)) {
|
||||
case 'true':
|
||||
case '(true)':
|
||||
return true;
|
||||
case 'false':
|
||||
case '(false)':
|
||||
return false;
|
||||
case 'null':
|
||||
case '(null)':
|
||||
return null;
|
||||
case 'empty':
|
||||
case '(empty)':
|
||||
return '';
|
||||
}
|
||||
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
/**
|
||||
* BaseFlow
|
||||
* Abstract base class for all conversation flow states.
|
||||
*/
|
||||
abstract class BaseFlow
|
||||
{
|
||||
/**
|
||||
* Process a step in the conversation flow.
|
||||
*
|
||||
* @param string $step The current step identifier ('start' for new flows)
|
||||
* @param array $messageData The incoming WhatsApp message payload (body, phone, etc.)
|
||||
* @param array $context Reference to the persistent JSON context array
|
||||
* @return FlowResult
|
||||
*/
|
||||
abstract public function handleStep(string $step, array $messageData, array &$context): FlowResult;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
use App\Services\SiroService;
|
||||
|
||||
/**
|
||||
* ComplaintFlow — Submit a trip complaint via WhatsApp
|
||||
*
|
||||
* Flow: start → await_description → await_ride → await_confirmation → finished
|
||||
*
|
||||
* Steps:
|
||||
* start → resolve user, ask for problem description
|
||||
* await_description → collect description text (or voice transcription)
|
||||
* await_ride → fetch recent rides, let user pick one
|
||||
* await_confirmation → show full details, ask to confirm
|
||||
* finished → show AI analysis result
|
||||
*/
|
||||
class ComplaintFlow extends BaseFlow
|
||||
{
|
||||
public function handleStep(string $step, array $messageData, array &$context): FlowResult
|
||||
{
|
||||
$phone = $messageData['phone'] ?? '';
|
||||
$text = $messageData['body'] ?? $messageData['text'] ?? '';
|
||||
$country = $context['country'] ?? SiroService::detectCountry($phone);
|
||||
|
||||
switch ($step) {
|
||||
// ─────────────────────────────────────────────────
|
||||
// START: resolve user, ask for description
|
||||
// ─────────────────────────────────────────────────
|
||||
case 'start':
|
||||
$context['country'] = $country;
|
||||
|
||||
// Resolve user type via Siro
|
||||
try {
|
||||
$driverData = SiroService::checkDriverStatus($phone, $country);
|
||||
if ($driverData && !empty($driverData['data']['driver_id'])) {
|
||||
$context['user_type'] = 'driver';
|
||||
$context['user_id'] = $driverData['data']['driver_id'];
|
||||
$context['user_name'] = $driverData['data']['name'] ?? '';
|
||||
} else {
|
||||
$context['user_type'] = 'passenger';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$context['user_type'] = 'driver';
|
||||
}
|
||||
|
||||
return new FlowResult(
|
||||
"أهلاً بك في نظام الشكاوى.\n\n"
|
||||
. "📝 يرجى وصف المشكلة التي حدثت بالتفصيل.\n"
|
||||
. "مثال: \"السائق تأخر 20 دقيقة واتصلت فيه وما رد\"\n"
|
||||
. "يمكنك إرسال النص أو تسجيل مقطع صوتي.\n\n"
|
||||
. "🟡 للخروج اكتب: إلغاء",
|
||||
"await_description"
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// AWAIT_DESCRIPTION: collect complaint text
|
||||
// ─────────────────────────────────────────────────
|
||||
case 'await_description':
|
||||
if (empty(trim($text))) {
|
||||
return new FlowResult(
|
||||
"الرجاء كتابة وصف المشكلة أو تسجيل مقطع صوتي:",
|
||||
"await_description"
|
||||
);
|
||||
}
|
||||
|
||||
$context['complaint_text'] = trim($text);
|
||||
|
||||
// Fetch recent rides from Siro
|
||||
try {
|
||||
$rides = SiroService::getUserRides($country, $phone, 5);
|
||||
$context['rides'] = $rides ?? [];
|
||||
} catch (\Exception $e) {
|
||||
$context['rides'] = [];
|
||||
}
|
||||
|
||||
if (empty($context['rides'])) {
|
||||
return new FlowResult(
|
||||
"تم حفظ وصف المشكلة. ✅\n\n"
|
||||
. "لم نتمكن من العثور على رحلات حديثة لحسابك.\n"
|
||||
. "الرجاء إرسال رقم الرحلة (مثال: 831):",
|
||||
"await_ride"
|
||||
);
|
||||
}
|
||||
|
||||
$rideList = "تم حفظ وصف المشكلة. ✅\n\n"
|
||||
. "🚖 آخر رحلاتك:\n\n";
|
||||
foreach ($context['rides'] as $i => $r) {
|
||||
$num = $i + 1;
|
||||
$date = $r['date'] ?? '';
|
||||
$time = $r['time'] ?? '';
|
||||
$from = $r['start_location'] ?? '---';
|
||||
$to = $r['end_location'] ?? '---';
|
||||
$price = $r['price'] ?? '0';
|
||||
$status = $r['status'] ?? '';
|
||||
$rideList .= "{$num}. رحلة #{$r['id']} | {$date} {$time}\n"
|
||||
. " من: {$from} → إلى: {$to}\n"
|
||||
. " السعر: {$price} | الحالة: {$status}\n\n";
|
||||
}
|
||||
$rideList .= "الرجاء إرسال رقم الرحلة من القائمة (1-{$num})\n"
|
||||
. "أو اكتب رقم الرحلة كاملاً (مثال: 831):";
|
||||
|
||||
return new FlowResult($rideList, "await_ride");
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// AWAIT_RIDE: let user pick a ride
|
||||
// ─────────────────────────────────────────────────
|
||||
case 'await_ride':
|
||||
$selectedRide = null;
|
||||
$rides = $context['rides'] ?? [];
|
||||
|
||||
// Check if user entered a list number (1, 2, 3...)
|
||||
$cleanNum = preg_replace('/[^0-9]/', '', $text);
|
||||
if (!empty($cleanNum) && is_numeric($cleanNum)) {
|
||||
$index = (int)$cleanNum - 1;
|
||||
if (isset($rides[$index])) {
|
||||
$selectedRide = $rides[$index];
|
||||
}
|
||||
// If not found in list, try as ride_id directly
|
||||
if (!$selectedRide) {
|
||||
foreach ($rides as $r) {
|
||||
if ((string)$r['id'] === $cleanNum) {
|
||||
$selectedRide = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If still not found, try the number as ride_id
|
||||
if (!$selectedRide) {
|
||||
$selectedRide = ['id' => $cleanNum];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$selectedRide) {
|
||||
return new FlowResult(
|
||||
"لم نتعرف على الرقم. الرجاء إرسال رقم الرحلة من القائمة:",
|
||||
"await_ride"
|
||||
);
|
||||
}
|
||||
|
||||
$context['ride_id'] = $selectedRide['id'];
|
||||
$context['ride_details'] = $selectedRide;
|
||||
|
||||
$locFrom = $selectedRide['start_location'] ?? '---';
|
||||
$locTo = $selectedRide['end_location'] ?? '---';
|
||||
$date = $selectedRide['date'] ?? '---';
|
||||
$time = $selectedRide['time'] ?? '---';
|
||||
$price = $selectedRide['price'] ?? '---';
|
||||
$status = $selectedRide['status'] ?? '---';
|
||||
|
||||
return new FlowResult(
|
||||
"🚖 تفاصيل الرحلة المحددة:\n"
|
||||
. "• رقم الرحلة: {$selectedRide['id']}\n"
|
||||
. "• التاريخ: {$date} {$time}\n"
|
||||
. "• من: {$locFrom}\n"
|
||||
. "• إلى: {$locTo}\n"
|
||||
. "• السعر: {$price}\n"
|
||||
. "• الحالة: {$status}\n\n"
|
||||
. "📋 وصف المشكلة:\n"
|
||||
. "{$context['complaint_text']}\n\n"
|
||||
. "هل تريد تأكيد إرسال الشكوى؟\n"
|
||||
. "✅ أرسل: تأكيد\n"
|
||||
. "🔄 أرسل: تعديل (لإعادة كتابة الوصف)\n"
|
||||
. "🟡 أرسل: إلغاء",
|
||||
"await_confirmation"
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// AWAIT_CONFIRMATION: confirm and submit
|
||||
// ─────────────────────────────────────────────────
|
||||
case 'await_confirmation':
|
||||
$clean = trim(mb_strtolower($text));
|
||||
|
||||
if (in_array($clean, ['تعديل', 'edit', 'تعديل الوصف'])) {
|
||||
return new FlowResult(
|
||||
"الرجاء إرسال وصف المشكلة الجديد:",
|
||||
"await_description"
|
||||
);
|
||||
}
|
||||
|
||||
if (!in_array($clean, ['تأكيد', 'نعم', 'اكيد', 'ok', 'yes', 'confirm', 'تاكيد', 'okay'])) {
|
||||
return new FlowResult(
|
||||
"❌ لم يتم التأكيد.\n"
|
||||
. "✅ للتأكيد أرسل: تأكيد\n"
|
||||
. "🔄 للتعديل أرسل: تعديل\n"
|
||||
. "🟡 للإلغاء أرسل: إلغاء",
|
||||
"await_confirmation"
|
||||
);
|
||||
}
|
||||
|
||||
// Submit complaint via Siro
|
||||
try {
|
||||
$result = SiroService::submitComplaint(
|
||||
$country,
|
||||
$phone,
|
||||
$context['ride_id'],
|
||||
$context['complaint_text']
|
||||
);
|
||||
|
||||
if ($result && ($result['status'] ?? '') === 'success') {
|
||||
$aiResult = $result['ai_result'] ?? [];
|
||||
$report = $result['report'] ?? [];
|
||||
$reportTitle = $report['title'] ?? '';
|
||||
$reportBody = $report['body'] ?? '';
|
||||
|
||||
$reply = "✅ تم إرسال شكواك بنجاح!\n\n"
|
||||
. "📋 نتيجة التحليل:\n"
|
||||
. "• تصنيف الشكوى: " . ($aiResult['complaint_type'] ?? '---') . "\n"
|
||||
. "• الطرف المخطئ: " . ($aiResult['fault_determination'] ?? '---') . "\n"
|
||||
. "• طبيعة الشكوى: " . ($aiResult['complaint_nature'] ?? '---') . "\n\n";
|
||||
|
||||
if ($reportBody) {
|
||||
$reply .= "📄 {$reportTitle}\n{$reportBody}\n\n";
|
||||
}
|
||||
|
||||
$reply .= "سيتم التواصل معك من قبل فريق الدعم إذا لزم الأمر.";
|
||||
|
||||
return new FlowResult($reply, "finished", true);
|
||||
}
|
||||
|
||||
return new FlowResult(
|
||||
"⚠️ حدث خطأ في إرسال الشكوى. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
error_log("[ComplaintFlow] Submit error: " . $e->getMessage());
|
||||
return new FlowResult(
|
||||
"⚠️ تعذر إرسال الشكوى حالياً. يرجى المحاولة لاحقاً.",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return new FlowResult("حدث خطأ في المسار.", "finished", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
use App\Models\ConversationState;
|
||||
use App\Models\MessageLog;
|
||||
|
||||
/**
|
||||
* ConversationFlowEngine
|
||||
* Orchestrates multi-stage interactive conversation flows.
|
||||
*/
|
||||
class ConversationFlowEngine
|
||||
{
|
||||
/**
|
||||
* Map of registered flow names to their classes
|
||||
*/
|
||||
private static array $flows = [
|
||||
'test_flow' => TestFlow::class,
|
||||
'driver_registration_flow' => DriverRegistrationFlow::class,
|
||||
'payment_flow' => PaymentFlow::class,
|
||||
'complaint_flow' => ComplaintFlow::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Map of keyword triggers to start a specific flow
|
||||
*/
|
||||
private static array $startTriggers = [
|
||||
'test' => 'test_flow',
|
||||
'اختبار' => 'test_flow',
|
||||
'سجل' => 'driver_registration_flow',
|
||||
'تسجيل' => 'driver_registration_flow',
|
||||
'register' => 'driver_registration_flow',
|
||||
'دفع' => 'payment_flow',
|
||||
'وصل' => 'payment_flow',
|
||||
'تسديد' => 'payment_flow',
|
||||
'رصيد' => 'payment_flow',
|
||||
'شكوى' => 'complaint_flow',
|
||||
'مشكلة' => 'complaint_flow',
|
||||
'بلاغ' => 'complaint_flow',
|
||||
'تظلم' => 'complaint_flow',
|
||||
'شكوي' => 'complaint_flow',
|
||||
'complaint' => 'complaint_flow',
|
||||
];
|
||||
|
||||
/**
|
||||
* Process incoming message.
|
||||
* Returns true if handled by the flow engine, false otherwise.
|
||||
*/
|
||||
public static function processMessage(array $session, array $msgData): bool
|
||||
{
|
||||
// 1. Housekeeping: remove expired sessions
|
||||
ConversationState::cleanExpired();
|
||||
|
||||
$phone = $msgData['phone'];
|
||||
$companyId = $session['company_id'];
|
||||
$text = isset($msgData['body']) ? trim($msgData['body']) : '';
|
||||
|
||||
// If incoming message is audio, transcribe it via Gemini (if limits permit)
|
||||
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
|
||||
if ($isAudio) {
|
||||
if ($companyId !== 1) {
|
||||
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
|
||||
if (!$activeSub) {
|
||||
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription for audio transcription.");
|
||||
return false;
|
||||
}
|
||||
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
|
||||
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its request limit for audio transcription.");
|
||||
return false;
|
||||
}
|
||||
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'voice')) {
|
||||
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its voice limit for audio transcription.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
|
||||
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
|
||||
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
|
||||
if (!empty($apiKey)) {
|
||||
$transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']);
|
||||
if ($transcription) {
|
||||
$text = $transcription;
|
||||
$msgData['body'] = $transcription;
|
||||
// Increment usage stats for successful transcription
|
||||
if ($companyId !== 1) {
|
||||
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'voice');
|
||||
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Lookup existing active flow
|
||||
$state = ConversationState::findActive($companyId, $phone);
|
||||
|
||||
$flowName = '';
|
||||
$currentStep = '';
|
||||
$context = [];
|
||||
|
||||
if ($state) {
|
||||
$flowName = $state['flow_name'];
|
||||
$currentStep = $state['current_step'];
|
||||
$context = json_decode($state['context_data'] ?: '{}', true) ?: [];
|
||||
} else {
|
||||
// Check if message is a starting trigger for a new flow
|
||||
$normalizedText = strtolower(trim($text));
|
||||
if (isset(self::$startTriggers[$normalizedText])) {
|
||||
$flowName = self::$startTriggers[$normalizedText];
|
||||
$currentStep = 'start';
|
||||
$context = [];
|
||||
}
|
||||
}
|
||||
|
||||
// If no active flow and no trigger matches, pass control back to normal bot rules
|
||||
if (empty($flowName) || !isset(self::$flows[$flowName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check subscription limits for active/starting flow
|
||||
if ($companyId !== 1) {
|
||||
$activeSub = \App\Models\CompanySubscription::findActiveByCompany($companyId);
|
||||
if (!$activeSub) {
|
||||
error_log("[Flow Engine Warning] Company {$companyId} has no active subscription.");
|
||||
self::sendReply($session, $phone, "⚠️ عذراً، لا يوجد اشتراك نشط لهذا المتجر حالياً.");
|
||||
return true;
|
||||
}
|
||||
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request')) {
|
||||
error_log("[Flow Engine Warning] Company {$companyId} has exceeded its general request limit.");
|
||||
self::sendReply($session, $phone, "⚠️ عذراً، لقد استهلك هذا المتجر كامل الحد المسموح له من الرسائل والطلبات لهذا الشهر.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User cancel flow option
|
||||
$normalizedCancel = strtolower(trim($text));
|
||||
if (in_array($normalizedCancel, ['إلغاء', 'خروج', 'cancel', 'exit'])) {
|
||||
if ($state) {
|
||||
ConversationState::deleteState($state['id']);
|
||||
$cancelMsg = in_array($normalizedCancel, ['cancel', 'exit'])
|
||||
? 'Interactive flow cancelled.'
|
||||
: 'تم إلغاء المحادثة التفاعلية.';
|
||||
self::sendReply($session, $phone, $cancelMsg);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Instantiate and execute the flow
|
||||
$flowClass = self::$flows[$flowName];
|
||||
/** @var BaseFlow $flowInstance */
|
||||
$flowInstance = new $flowClass();
|
||||
|
||||
try {
|
||||
$context['company_id'] = $companyId;
|
||||
$result = $flowInstance->handleStep($currentStep, $msgData, $context);
|
||||
|
||||
if ($result->isFinished()) {
|
||||
// Flow has reached terminal state: clean up DB record
|
||||
if ($state) {
|
||||
ConversationState::deleteState($state['id']);
|
||||
}
|
||||
} else {
|
||||
// Flow has next step: save or update state record (TTL: 1 hour)
|
||||
ConversationState::saveState([
|
||||
'company_id' => $companyId,
|
||||
'contact_phone' => $phone,
|
||||
'flow_name' => $flowName,
|
||||
'current_step' => $result->getNextStep(),
|
||||
'context_data' => json_encode($context, JSON_UNESCAPED_UNICODE),
|
||||
'expires_at' => date('Y-m-d H:i:s', strtotime('+1 hour'))
|
||||
]);
|
||||
}
|
||||
|
||||
// 5. Send reply if one is provided
|
||||
$replySent = false;
|
||||
if ($companyId === 1 && $flowName === 'driver_registration_flow' && $result->getReplyText() !== '') {
|
||||
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
|
||||
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
|
||||
$apiKey = \App\Services\GeminiService::getGeminiApiKey($configuredGeminiKey);
|
||||
|
||||
if (!empty($apiKey)) {
|
||||
$configuredElKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : null;
|
||||
$elApiKey = \App\Services\GeminiService::getElevenLabsApiKey($configuredElKey);
|
||||
|
||||
$configuredVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : null;
|
||||
$elVoiceId = \App\Services\GeminiService::getElevenLabsVoiceId($configuredVoiceId);
|
||||
|
||||
// Generate the audio voice note
|
||||
$audioData = \App\Services\GeminiService::generateAudioResponse(
|
||||
$apiKey,
|
||||
"أنت سارة، مساعدة فريق خدمة العملاء في تطبيق سيرو (Siro) — أول منصة نقل ذكية وبنية تحتية رقمية للتنقل في سوريا والأردن ومصر. تتحدثين بلهجة سورية دافئة ومحببة ومهنية جداً كأنك إنسان حقيقي.
|
||||
|
||||
معلومات عن سيرو:
|
||||
- التطبيق: سيرو (Siro) — من تطوير سيرو لنقل الركاب
|
||||
- العمولة: 11% فقط (أقل عمولة في السوق السوري)
|
||||
- التحميل: أندروید https://play.google.com/store/apps/details?id=com.Siro.siro | آيفون https://apps.apple.com/app/id6748075179
|
||||
- طرق الشحن: سيريتيل كاش، شام كاش، المحفظة الداخلية
|
||||
- أنواع الرحلات: 10 أنواع (مريح، سريع، سائقات، سكوتر، فان، VIP، اقتصاد، سعر ثابت، رايح جاي، كهربائي)
|
||||
- المميزات: خرائط مملوكة SiroMaps، ترخيص حكومي NANS، 4 تطبيقات متكاملة، توثيق بالذكاء الاصطناعي، زر SOS، رحلات للسائقات
|
||||
- الأمان: JWT + بصمة جهاز، تشفير AES-256-GCM، حماية من الاحتيال
|
||||
- البلدان: سوريا (شغال)، مصر (جاهز)، الأردن (قريباً)
|
||||
- الدعم: support@intaleqapp.com | support@siromove.com
|
||||
|
||||
ملاحظة: إذا المستخدم كتب بالإنجليزية، ردي بالإنجليزية. إذا كتب بالعربية، ردي بالعربية (اللهجة السورية).",
|
||||
$result->getReplyText(),
|
||||
$result->getReplyText(),
|
||||
'Puck',
|
||||
$elApiKey,
|
||||
$elVoiceId
|
||||
);
|
||||
|
||||
if ($audioData && !empty($audioData['audio'])) {
|
||||
// Send the text message first
|
||||
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
|
||||
// Then send the voice note
|
||||
self::sendReply($session, $phone, '', null, $audioData['audio'], $audioData['mimeType']);
|
||||
$replySent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$replySent && ($result->getReplyText() !== '' || $result->getMediaUrl() !== null)) {
|
||||
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
error_log("[ConversationFlowEngine Exception] Flow '{$flowName}' failed: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send outbound message reply via the Baileys Gateway
|
||||
*/
|
||||
public static function sendReply(
|
||||
array $session,
|
||||
string $phone,
|
||||
string $message,
|
||||
?string $mediaUrl = null,
|
||||
?string $audioBase64 = null,
|
||||
?string $mimetype = null,
|
||||
?string $imageBase64 = null
|
||||
): bool {
|
||||
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
|
||||
if (substr($gatewayUrl, -4) === '/api') {
|
||||
$sendUrl = $gatewayUrl . '/messages/send';
|
||||
} else {
|
||||
$sendUrl = $gatewayUrl . '/api/messages/send';
|
||||
}
|
||||
|
||||
$payloadData = [
|
||||
'session_key' => $session['session_key'],
|
||||
'phone' => $phone
|
||||
];
|
||||
|
||||
if ($message !== '') {
|
||||
$payloadData['message'] = $message;
|
||||
}
|
||||
if ($mediaUrl !== null) {
|
||||
$payloadData['media_url'] = $mediaUrl;
|
||||
}
|
||||
if ($audioBase64 !== null) {
|
||||
$payloadData['audio'] = $audioBase64;
|
||||
}
|
||||
if ($mimetype !== null) {
|
||||
$payloadData['mimetype'] = $mimetype;
|
||||
}
|
||||
if ($imageBase64 !== null) {
|
||||
$payloadData['image'] = $imageBase64;
|
||||
}
|
||||
|
||||
$payload = json_encode($payloadData);
|
||||
|
||||
$ch = curl_init($sendUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'X-Webhook-Secret: ' . getenv('WEBHOOK_SECRET')
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$status = 'failed';
|
||||
$errorMsg = null;
|
||||
$waMsgId = null;
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$status = 'sent';
|
||||
$resData = json_decode($response, true);
|
||||
$waMsgId = $resData['data']['key']['id'] ?? null;
|
||||
} else {
|
||||
$resData = json_decode($response, true);
|
||||
$errorMsg = $resData['error'] ?? 'HTTP Code ' . $httpCode;
|
||||
error_log("[Flow Engine Gateway Error] Failed to send: " . $errorMsg);
|
||||
}
|
||||
|
||||
$msgType = ($audioBase64 !== null || $mediaUrl !== null) ? 'audio' : 'text';
|
||||
|
||||
// Log the outbound auto-reply message
|
||||
MessageLog::logMessage([
|
||||
'company_id' => $session['company_id'],
|
||||
'session_id' => $session['id'],
|
||||
'contact_phone' => $phone,
|
||||
'direction' => 'outbound',
|
||||
'message_type' => $msgType,
|
||||
'message_body' => $message,
|
||||
'media_url' => $mediaUrl,
|
||||
'whatsapp_message_id' => $waMsgId,
|
||||
'status' => $status,
|
||||
'error_message' => $errorMsg
|
||||
]);
|
||||
|
||||
return $status === 'sent';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
use App\Services\GeminiService;
|
||||
use App\Services\SiroService;
|
||||
use App\Models\DriverOcrData;
|
||||
use App\Models\ChatbotRule;
|
||||
use App\Models\DriverReminder;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* DriverRegistrationFlow
|
||||
* Handles step-by-step driver and vehicle registration using Gemini OCR.
|
||||
* Integrates with Siro platform for driver registration across Syria, Jordan, and Egypt.
|
||||
*/
|
||||
class DriverRegistrationFlow extends BaseFlow
|
||||
{
|
||||
private array $prompts = [];
|
||||
private string $country = 'syria';
|
||||
|
||||
private array $stepToDocType = [
|
||||
'id_front' => 'id_front',
|
||||
'id_back' => 'id_back',
|
||||
'driving_license_front' => 'driver_license_front',
|
||||
'driving_license_back' => 'driver_license_back',
|
||||
'vehicle_license_front' => 'car_license_front',
|
||||
'vehicle_license_back' => 'car_license_back',
|
||||
'criminal_record' => 'criminal_record',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->prompts = SiroService::getDocumentPrompts($this->country);
|
||||
}
|
||||
|
||||
public function handleStep(string $step, array $messageData, array &$context): FlowResult
|
||||
{
|
||||
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
|
||||
$phone = $messageData['phone'];
|
||||
$companyId = $context['company_id'] ?? 1;
|
||||
|
||||
// Detect country from phone number
|
||||
$this->country = SiroService::detectCountry($phone);
|
||||
$context['country'] = $this->country;
|
||||
|
||||
// Set country-specific prompts
|
||||
$this->prompts = SiroService::getDocumentPrompts($this->country);
|
||||
|
||||
// Country name in Arabic for messages
|
||||
$countryNames = [
|
||||
'syria' => 'سوريا',
|
||||
'jordan' => 'الأردن',
|
||||
'egypt' => 'مصر',
|
||||
];
|
||||
$countryName = $countryNames[$this->country] ?? 'سوريا';
|
||||
|
||||
// App name based on country
|
||||
$appNames = [
|
||||
'syria' => 'سيرو',
|
||||
'jordan' => 'سيرو',
|
||||
'egypt' => 'سيرو',
|
||||
];
|
||||
$appName = $appNames[$this->country] ?? 'سيرو';
|
||||
|
||||
// If currently postponed and user sends a message, resume the flow
|
||||
if ($step === 'postponed') {
|
||||
$activeReminder = DriverReminder::findActive($companyId, $phone);
|
||||
if ($activeReminder) {
|
||||
DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']);
|
||||
}
|
||||
$step = $context['previous_step'] ?? 'ask_name';
|
||||
}
|
||||
|
||||
// Check if user requests postponement/delay (only if already started and not finished)
|
||||
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
|
||||
$rule = ChatbotRule::findActiveForRule($companyId);
|
||||
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
|
||||
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
|
||||
if (!empty($apiKey)) {
|
||||
$postponeData = $this->detectPostponement($text, $apiKey, $companyId);
|
||||
if ($postponeData !== null) {
|
||||
$hours = $postponeData['hours'];
|
||||
$postponeCount = ($context['postpone_count'] ?? 0) + 1;
|
||||
|
||||
if ($postponeCount > 3) {
|
||||
return new FlowResult(
|
||||
"عذراً كابتن، لقد تجاوزت الحد الأقصى لمرات التأجيل (3 مرات). تم إلغاء طلب التسجيل الحالي. يمكنك البدء من جديد عندما تكون جاهزاً بكتابة 'تسجيل'.",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
$context['postpone_count'] = $postponeCount;
|
||||
$context['previous_step'] = $step;
|
||||
|
||||
$scheduledAt = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
|
||||
DriverReminder::saveReminder([
|
||||
'company_id' => $companyId,
|
||||
'phone' => $phone,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'postpone_count' => $postponeCount,
|
||||
'status' => 'pending'
|
||||
]);
|
||||
|
||||
return new FlowResult(
|
||||
"حاضر كابتن، قمت بتأجيل التسجيل. سأقوم بتذكيرك بعد {$hours} ساعة لإكمال خطوات التسجيل. بالتوفيق!",
|
||||
"postponed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch ($step) {
|
||||
case 'start':
|
||||
return new FlowResult(
|
||||
"أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق {$appName} في {$countryName} 🚖.\nيرجى إرسال اسمك الثلاثي الكامل للبدء:",
|
||||
"ask_name"
|
||||
);
|
||||
|
||||
case 'ask_name':
|
||||
if (empty($text)) {
|
||||
return new FlowResult("يرجى إدخال اسمك الثلاثي الكامل للاستمرار:", "ask_name");
|
||||
}
|
||||
$context['name'] = $text;
|
||||
return new FlowResult(
|
||||
"شكراً كابتن {$text}.\nالآن يرجى إرسال صورة **الوجه الأمامي للهوية الشخصية** (تأكد من أن الصورة واضحة والإضاءة جيدة):",
|
||||
"id_front"
|
||||
);
|
||||
|
||||
case 'id_front':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"id_front_sy",
|
||||
"national_number",
|
||||
"عذراً كابتن، لم أتمكن من قراءة الرقم الوطني من الهوية بوضوح. يرجى إرسال صورة أخرى للوجه الأمامي للهوية الشخصية تكون أكثر وضوحاً:",
|
||||
"تم استخراج الرقم الوطني بنجاح ✅.\nالآن، يرجى إرسال صورة **الوجه الخلفي للهوية الشخصية**:",
|
||||
"id_back"
|
||||
);
|
||||
|
||||
case 'id_back':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"id_back_sy",
|
||||
"gender",
|
||||
"عذراً كابتن، لم أتمكن من قراءة بيانات الوجه الخلفي للهوية بوضوح. يرجى إرسال صورة أخرى للوجه الخلفي للهوية الشخصية:",
|
||||
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة القيادة**:",
|
||||
"driving_license_front"
|
||||
);
|
||||
|
||||
case 'driving_license_front':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"driving_license_sy_front",
|
||||
"national_number",
|
||||
"عذراً كابتن، لم أتمكن من قراءة رخصة القيادة بوضوح. يرجى إرسال صورة أخرى واضحة للوجه الأمامي لرخصة القيادة:",
|
||||
"تم استخراج بيانات رخصة القيادة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة القيادة**:",
|
||||
"driving_license_back"
|
||||
);
|
||||
|
||||
case 'driving_license_back':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"driving_license_sy_back",
|
||||
"license_number",
|
||||
"عذراً كابتن، لم أتمكن من قراءة الوجه الخلفي لرخصة القيادة بوضوح. يرجى إعادة إرسال الصورة بشكل أكثر وضوحاً:",
|
||||
"تم استخراج البيانات بنجاح ✅.\nيرجى إرسال صورة **الوجه الأمامي لرخصة السيارة (الرخصة البرتقالية)**:",
|
||||
"vehicle_license_front"
|
||||
);
|
||||
|
||||
case 'vehicle_license_front':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"vehicle_license_sy_front",
|
||||
"car_plate",
|
||||
"عذراً كابتن، لم أتمكن من قراءة رقم لوحة السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الأمامي لرخصة السيارة:",
|
||||
"تم استخراج رقم اللوحة بنجاح ✅.\nيرجى إرسال صورة **الوجه الخلفي لرخصة السيارة (الرخصة البرتقالية)**:",
|
||||
"vehicle_license_back"
|
||||
);
|
||||
|
||||
case 'vehicle_license_back':
|
||||
return $this->processOcrStep(
|
||||
$step,
|
||||
$messageData,
|
||||
$context,
|
||||
"vehicle_license_sy_back",
|
||||
"chassis",
|
||||
"عذراً كابتن، لم أتمكن من قراءة مواصفات السيارة بوضوح. يرجى إرسال صورة واضحة للوجه الخلفي لرخصة السيارة:",
|
||||
"تم استخراج مواصفات السيارة بنجاح ✅.\nيرجى إرسال صورة **وثيقة غير محكوم (لا حكم عليه)**:",
|
||||
"criminal_record"
|
||||
);
|
||||
|
||||
case 'criminal_record':
|
||||
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
|
||||
return new FlowResult("الرجاء إرسال صورة وثيقة غير محكوم (لا حكم عليه) للاستمرار:", "criminal_record");
|
||||
}
|
||||
|
||||
// Save non-OCR criminal record image
|
||||
$imageUrl = $this->saveIncomingImage($step, $phone, $messageData);
|
||||
if (!$imageUrl) {
|
||||
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة إرسال صورة الوثيقة:", "criminal_record");
|
||||
}
|
||||
|
||||
// Upload criminal record to Siro
|
||||
$fullPath = __DIR__ . '/../../../../public' . $imageUrl;
|
||||
$criminalSiroUrl = SiroService::uploadDocument(
|
||||
$this->country,
|
||||
SiroService::formatPhone($phone, $this->country),
|
||||
'criminal_record',
|
||||
$fullPath,
|
||||
$messageData['imageMimeType']
|
||||
);
|
||||
$context['criminal_record_siro_url'] = $criminalSiroUrl;
|
||||
|
||||
// Securely save registration data to local database
|
||||
try {
|
||||
DriverOcrData::saveSecure([
|
||||
'company_id' => $companyId,
|
||||
'phone' => $phone,
|
||||
'name' => $context['name'],
|
||||
'id_front_url' => $context['id_front_url'] ?? null,
|
||||
'id_front_ocr' => $context['id_front_ocr'] ?? null,
|
||||
'id_back_url' => $context['id_back_url'] ?? null,
|
||||
'id_back_ocr' => $context['id_back_ocr'] ?? null,
|
||||
'driving_license_front_url' => $context['driving_license_front_url'] ?? null,
|
||||
'driving_license_front_ocr' => $context['driving_license_front_ocr'] ?? null,
|
||||
'driving_license_back_url' => $context['driving_license_back_url'] ?? null,
|
||||
'driving_license_back_ocr' => $context['driving_license_back_ocr'] ?? null,
|
||||
'vehicle_license_front_url' => $context['vehicle_license_front_url'] ?? null,
|
||||
'vehicle_license_front_ocr' => $context['vehicle_license_front_ocr'] ?? null,
|
||||
'vehicle_license_back_url' => $context['vehicle_license_back_url'] ?? null,
|
||||
'vehicle_license_back_ocr' => $context['vehicle_license_back_ocr'] ?? null,
|
||||
'criminal_record_url' => $imageUrl,
|
||||
'status' => 'ocr_completed'
|
||||
]);
|
||||
} catch (\Exception $dbEx) {
|
||||
error_log("[Registration Flow Error] DB Write Failed: " . $dbEx->getMessage());
|
||||
return new FlowResult("عذراً، حدث خطأ أثناء حفظ طلبك في قاعدة البيانات. يرجى المحاولة مرة أخرى لاحقاً.", "criminal_record");
|
||||
}
|
||||
|
||||
// Register driver in Siro with Siro-hosted URLs
|
||||
$docUrls = [
|
||||
'id_front' => $context['id_front_siro_url'] ?? '',
|
||||
'id_back' => $context['id_back_siro_url'] ?? '',
|
||||
'driving_license_front' => $context['driving_license_front_siro_url'] ?? '',
|
||||
'driving_license_back' => $context['driving_license_back_siro_url'] ?? '',
|
||||
'vehicle_license_front' => $context['vehicle_license_front_siro_url'] ?? '',
|
||||
'vehicle_license_back' => $context['vehicle_license_back_siro_url'] ?? '',
|
||||
'criminal_record' => $criminalSiroUrl ?? '',
|
||||
];
|
||||
|
||||
$idOcr = $context['id_front_ocr'] ?? [];
|
||||
$vlOcr = $context['vehicle_license_front_ocr'] ?? [];
|
||||
$vlbOcr = $context['vehicle_license_back_ocr'] ?? [];
|
||||
$dlOcr = $context['driving_license_front_ocr'] ?? [];
|
||||
|
||||
$formattedPhone = SiroService::formatPhone($phone, $this->country);
|
||||
$driverId = 'DRV' . date('YmdHis') . rand(100, 999);
|
||||
|
||||
$driverData = [
|
||||
'phone' => $formattedPhone,
|
||||
'password' => substr(md5($formattedPhone . time()), 0, 12),
|
||||
'first_name' => explode(' ', $context['name'] ?? '')[0] ?? $context['name'],
|
||||
'last_name' => implode(' ', array_slice(explode(' ', $context['name'] ?? ''), 1)) ?: $context['name'],
|
||||
'name_arabic' => $context['name'] ?? '',
|
||||
'national_number' => $idOcr['national_number'] ?? '',
|
||||
'birthdate' => $idOcr['dob'] ?? '',
|
||||
'address' => $idOcr['address'] ?? '',
|
||||
'id' => $driverId,
|
||||
];
|
||||
|
||||
$carData = [
|
||||
'vin' => $vlbOcr['chassis'] ?? $vlOcr['vin'] ?? '',
|
||||
'car_plate' => $vlOcr['car_plate'] ?? '',
|
||||
'make' => $vlbOcr['make'] ?? '',
|
||||
'model' => $vlbOcr['model'] ?? '',
|
||||
'year' => $vlbOcr['year'] ?? '',
|
||||
'color' => $vlOcr['color'] ?? '',
|
||||
'color_hex' => $vlOcr['color_hex'] ?? '#000000',
|
||||
'owner' => $vlOcr['owner'] ?? '',
|
||||
'fuel' => $vlbOcr['fuel'] ?? '',
|
||||
'expiration_date' => $vlOcr['issue_date'] ?? '',
|
||||
'vehicle_category_id' => 1,
|
||||
];
|
||||
|
||||
$syroResult = SiroService::registerDriver($driverData, $carData, $docUrls, $this->country);
|
||||
|
||||
$appNames = [
|
||||
'syria' => 'سيرو',
|
||||
'jordan' => 'سيرو',
|
||||
'egypt' => 'سيرو',
|
||||
];
|
||||
$appName = $appNames[$this->country] ?? 'سيرو';
|
||||
|
||||
if ($syroResult && ($syroResult['status'] ?? '') === 'success') {
|
||||
DriverOcrData::saveSecure([
|
||||
'company_id' => $companyId,
|
||||
'phone' => $phone,
|
||||
'name' => $context['name'],
|
||||
'status' => 'registered'
|
||||
]);
|
||||
|
||||
return new FlowResult(
|
||||
"شكراً لك كابتن، تم تسجيلك بنجاح في تطبيق {$appName} 🚖✅\nسيتم مراجعة طلبك من قبل فريق الخدمة وتفعيل حسابك قريباً. يومك سعيد!",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
} else {
|
||||
$errMsg = $syroResult['message'] ?? 'خطأ غير معروف';
|
||||
error_log("[Registration Flow] Siro registration failed: " . json_encode($syroResult));
|
||||
|
||||
return new FlowResult(
|
||||
"تم حفظ مستنداتك بنجاح في نظامنا. لكن حدث تأخير في تسجيلك على تطبيق {$appName}. سيقوم فريقنا بمراجعة بياناتك وتفعيل حسابك في أقرب وقت. شكراً لصبرك! 🙏",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process document upload and OCR extraction step
|
||||
*/
|
||||
private function processOcrStep(
|
||||
string $step,
|
||||
array $messageData,
|
||||
array &$context,
|
||||
string $promptKey,
|
||||
string $requiredJsonKey,
|
||||
string $failMessage,
|
||||
string $successMessage,
|
||||
string $nextStep
|
||||
): FlowResult {
|
||||
if (empty($messageData['image']) || empty($messageData['imageMimeType'])) {
|
||||
return new FlowResult("الرجاء إرسال الصورة المطلوبة للمتابعة:", $step);
|
||||
}
|
||||
|
||||
$imageUrl = $this->saveIncomingImage($step, $messageData['phone'], $messageData);
|
||||
if (!$imageUrl) {
|
||||
return new FlowResult("عذراً، فشل حفظ الصورة. الرجاء إعادة المحاولة وإرسال الصورة:", $step);
|
||||
}
|
||||
|
||||
// Upload to Siro and store the signed URL
|
||||
$fullPath = __DIR__ . '/../../../../public' . $imageUrl;
|
||||
$siroUrl = SiroService::uploadDocument(
|
||||
$this->country,
|
||||
SiroService::formatPhone($messageData['phone'], $this->country),
|
||||
$this->stepToDocType[$step] ?? $step,
|
||||
$fullPath,
|
||||
$messageData['imageMimeType']
|
||||
);
|
||||
if ($siroUrl) {
|
||||
$context[$step . '_siro_url'] = $siroUrl;
|
||||
error_log("[DriverRegistrationFlow] Uploaded {$step} to Siro: {$siroUrl}");
|
||||
} else {
|
||||
$context[$step . '_siro_url'] = null;
|
||||
error_log("[DriverRegistrationFlow] Warning: Failed to upload {$step} to Siro, using local URL");
|
||||
}
|
||||
|
||||
$companyId = $context['company_id'] ?? 1;
|
||||
|
||||
// Check subscription limit for OCR
|
||||
if ($companyId !== 1) {
|
||||
if (!\App\Models\CompanySubscriptionUsage::hasRemainingLimit($companyId, 'ocr')) {
|
||||
error_log("[DriverRegistrationFlow] Company {$companyId} has exceeded its OCR limit.");
|
||||
return new FlowResult("⚠️ عذراً، لقد استهلك هذا المتجر الحد المسموح له من تحليل الصور والوصولات لهذا الشهر. يرجى إرسال استفسارك نصياً.", $step);
|
||||
}
|
||||
}
|
||||
|
||||
$rule = ChatbotRule::findActiveForRule($companyId);
|
||||
$configuredGeminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : null;
|
||||
$apiKey = GeminiService::getGeminiApiKey($configuredGeminiKey);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
error_log("[DriverRegistrationFlow] Gemini API key not configured.");
|
||||
return new FlowResult("عذراً، عطل فني في خادم معالجة الصور بالذكاء الاصطناعي. يرجى المحاولة لاحقاً.", $step);
|
||||
}
|
||||
|
||||
$prompt = $this->prompts[$step] ?? '';
|
||||
$rawOcr = GeminiService::generateOcrFromImage($apiKey, $prompt, $messageData['image'], $messageData['imageMimeType']);
|
||||
|
||||
if (!$rawOcr) {
|
||||
error_log("[DriverRegistrationFlow] OCR response empty or model request failed.");
|
||||
return new FlowResult($failMessage, $step);
|
||||
}
|
||||
|
||||
$ocrData = json_decode($rawOcr, true);
|
||||
if (!$ocrData || empty($ocrData[$requiredJsonKey])) {
|
||||
error_log("[DriverRegistrationFlow] Missing or empty required key '$requiredJsonKey' in OCR response: " . $rawOcr);
|
||||
return new FlowResult($failMessage, $step);
|
||||
}
|
||||
|
||||
// Increment stats on successful OCR processing
|
||||
if ($companyId !== 1) {
|
||||
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'ocr');
|
||||
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
|
||||
}
|
||||
|
||||
// Save URL and OCR JSON string in the conversation context
|
||||
$context[$step . '_url'] = $imageUrl;
|
||||
$context[$step . '_ocr'] = $ocrData;
|
||||
|
||||
return new FlowResult($successMessage, $nextStep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 image data and save it to the public directory
|
||||
*/
|
||||
private function saveIncomingImage(string $step, string $phone, array $messageData): ?string
|
||||
{
|
||||
try {
|
||||
$extension = 'jpg';
|
||||
if (strpos($messageData['imageMimeType'], 'png') !== false) {
|
||||
$extension = 'png';
|
||||
}
|
||||
|
||||
$uniqueName = 'driver_' . $step . '_' . md5($phone . time()) . '.' . $extension;
|
||||
$uploadDir = __DIR__ . '/../../../../public/uploads/documents/';
|
||||
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$uploadPath = $uploadDir . $uniqueName;
|
||||
$imgData = base64_decode($messageData['image']);
|
||||
|
||||
if (file_put_contents($uploadPath, $imgData) === false) {
|
||||
error_log("[DriverRegistrationFlow] Failed to write image file: " . $uploadPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/uploads/documents/' . $uniqueName;
|
||||
} catch (\Exception $e) {
|
||||
error_log("[DriverRegistrationFlow Exception] Failed to save image: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if user wants to postpone, and return hours_delay if so.
|
||||
*/
|
||||
private function detectPostponement(string $text, string $apiKey, int $companyId): ?array
|
||||
{
|
||||
if (empty($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quick heuristic check: if the message is too long, or clearly doesn't contain postponement keywords, skip to save API costs
|
||||
$keywords = ['بعدين', 'بكرا', 'بكرة', 'بعد', 'شوي', 'ثانية', 'مشغول', 'المسا', 'الليل', 'تأجيل', 'وقت ثاني', 'تعبان', 'بعدين برسل', 'بعدين ببعت', 'ببعثهم بعدين', 'ببعتهم بعدين', 'بعدين بكمل'];
|
||||
$hasKeyword = false;
|
||||
foreach ($keywords as $kw) {
|
||||
if (mb_strpos($text, $kw) !== false) {
|
||||
$hasKeyword = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasKeyword && mb_strlen($text) > 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$systemPrompt = "You are an assistant that detects if a user wants to postpone, delay, or complete a registration flow later. Analyze the Arabic message.";
|
||||
$userMessage = <<<EOT
|
||||
Analyze the following Arabic message (often in Syrian dialect) to determine if the user wants to postpone/delay sending documents or complete the registration later.
|
||||
|
||||
Message: "{$text}"
|
||||
|
||||
Respond with ONLY a valid JSON object matching this schema:
|
||||
{
|
||||
"wants_postpone": true/false,
|
||||
"hours_delay": 12
|
||||
}
|
||||
Do not include any markdown, code blocks, or explanations.
|
||||
EOT;
|
||||
|
||||
$response = GeminiService::generateResponse($apiKey, $systemPrompt, $userMessage);
|
||||
if (empty($response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Increment request limit on successful postponement check API call
|
||||
if ($companyId !== 1) {
|
||||
\App\Models\CompanySubscriptionUsage::incrementUsage($companyId, 'request');
|
||||
}
|
||||
|
||||
// Clean markdown block if present
|
||||
$response = trim(preg_replace('/```json|```/', '', $response));
|
||||
$data = json_decode($response, true);
|
||||
if (isset($data['wants_postpone']) && $data['wants_postpone'] === true) {
|
||||
return [
|
||||
'hours' => isset($data['hours_delay']) ? (int)$data['hours_delay'] : 12
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
/**
|
||||
* FlowResult
|
||||
* Holds response details after handling a single step in a conversation flow.
|
||||
*/
|
||||
class FlowResult
|
||||
{
|
||||
private string $replyText;
|
||||
private string $nextStep;
|
||||
private bool $finished;
|
||||
private ?string $mediaUrl;
|
||||
|
||||
public function __construct(string $replyText, string $nextStep, bool $finished = false, ?string $mediaUrl = null)
|
||||
{
|
||||
$this->replyText = $replyText;
|
||||
$this->nextStep = $nextStep;
|
||||
$this->finished = $finished;
|
||||
$this->mediaUrl = $mediaUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reply message text to send to the contact
|
||||
*/
|
||||
public function getReplyText(): string
|
||||
{
|
||||
return $this->replyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next step key
|
||||
*/
|
||||
public function getNextStep(): string
|
||||
{
|
||||
return $this->nextStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the flow is finished (to be destroyed)
|
||||
*/
|
||||
public function isFinished(): bool
|
||||
{
|
||||
return $this->finished;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target media URL (if any)
|
||||
*/
|
||||
public function getMediaUrl(): ?string
|
||||
{
|
||||
return $this->mediaUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
use App\Services\SiroService;
|
||||
|
||||
/**
|
||||
* PaymentFlow — Smart Payment Verification
|
||||
*
|
||||
* Flow: start → await_receipt → finished
|
||||
*
|
||||
* Smart features:
|
||||
* • Auto-detects country + payment method (shamcash/cliq)
|
||||
* • Auto-finds pending invoice by phone (no invoice number needed)
|
||||
* • AI receipt verification via payment server Gemini
|
||||
* • Postponement detection (keyword-based)
|
||||
* • Image validation + retry (max 3 attempts)
|
||||
* • Currency-aware messages (SYP/JOD)
|
||||
*/
|
||||
class PaymentFlow extends BaseFlow
|
||||
{
|
||||
private const MAX_RETRIES = 3;
|
||||
|
||||
public function handleStep(string $step, array $messageData, array &$context): FlowResult
|
||||
{
|
||||
$phone = $messageData['phone'] ?? '';
|
||||
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
|
||||
$image = $messageData['image'] ?? '';
|
||||
$imageMimeType = $messageData['imageMimeType'] ?? 'image/jpeg';
|
||||
|
||||
// ── Postponement check (only if flow is active, not on start/finished) ──
|
||||
if ($step !== 'start' && $step !== 'finished' && !empty($text)) {
|
||||
$postpone = $this->detectPostponement($text);
|
||||
if ($postpone !== null) {
|
||||
$context['previous_step'] = $step;
|
||||
$hours = $postpone;
|
||||
return new FlowResult(
|
||||
"حاضر كابتن، تم تأجيل طلب الدفع. سأذكرك بعد {$hours} ساعات.\n"
|
||||
. "للمتابعة لاحقاً، أرسل 'دفع' مرة أخرى.",
|
||||
"postponed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Country + method detection ──
|
||||
$country = $context['country'] ?? SiroService::detectCountry($phone);
|
||||
$context['country'] = $country;
|
||||
|
||||
$paymentMethod = $context['payment_method'] ?? match ($country) {
|
||||
'jordan' => 'cliq',
|
||||
default => 'shamcash',
|
||||
};
|
||||
$context['payment_method'] = $paymentMethod;
|
||||
|
||||
$methodName = $paymentMethod === 'cliq' ? 'كليك (Cliq)' : 'شام كاش (ShamCash)';
|
||||
$currency = $paymentMethod === 'cliq' ? 'دينار أردني' : 'ل.س';
|
||||
$countryName = match ($country) {
|
||||
'jordan' => 'الأردن',
|
||||
'egypt' => 'مصر',
|
||||
default => 'سوريا',
|
||||
};
|
||||
|
||||
switch ($step) {
|
||||
// ─────────────────────────────────────────────────
|
||||
// START
|
||||
// ─────────────────────────────────────────────────
|
||||
case 'start':
|
||||
$context['retry_count'] = 0;
|
||||
|
||||
return new FlowResult(
|
||||
"أهلاً بك في خدمة التحقق من الدفع.\n\n"
|
||||
. "📍 الدولة: {$countryName}\n"
|
||||
. "💰 طريقة الدفع: {$methodName}\n"
|
||||
. "💵 العملة: {$currency}\n\n"
|
||||
. "📸 يرجى إرسال صورة واضحة لإيصال الدفع أو صورة الشاشة.\n"
|
||||
. "سيتم التحقق من الفاتورة المعلقة تلقائياً.\n\n"
|
||||
. "🟡 للخروج اكتب: إلغاء\n"
|
||||
. "⏰ للتأجيل اكتب: بعدين",
|
||||
"await_receipt"
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// AWAIT_RECEIPT
|
||||
// ─────────────────────────────────────────────────
|
||||
case 'await_receipt':
|
||||
// ── No image sent ──
|
||||
if (empty($image)) {
|
||||
return new FlowResult(
|
||||
"📸 يرجى إرسال صورة الإيصال أو وصل التحويل.\n"
|
||||
. "تأكد من أن الصورة واضحة وتظهر المبلغ وتفاصيل التحويل.",
|
||||
"await_receipt"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Validate image size ──
|
||||
$decoded = base64_decode($image, true);
|
||||
if ($decoded === false || strlen($decoded) < 1024) {
|
||||
$retry = ($context['retry_count'] ?? 0) + 1;
|
||||
$context['retry_count'] = $retry;
|
||||
|
||||
if ($retry >= self::MAX_RETRIES) {
|
||||
return new FlowResult(
|
||||
"عذراً، لم نتمكن من قراءة الصورة بعد {$retry} محاولات.\n"
|
||||
. "يرجى التواصل مع خدمة العملاء لإتمام عملية الدفع يدوياً.",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return new FlowResult(
|
||||
"⚠️ الصورة غير واضحة أو صغيرة جداً.\n"
|
||||
. "يرجى إرسال صورة واضحة وحجم أكبر (محاولة {$retry} من " . self::MAX_RETRIES . "):",
|
||||
"await_receipt"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Normalize MIME type ──
|
||||
if (strpos($imageMimeType, ';') !== false) {
|
||||
$imageMimeType = trim(explode(';', $imageMimeType)[0]);
|
||||
}
|
||||
|
||||
// ── Send to payment server ──
|
||||
$companyId = $context['company_id'] ?? 1;
|
||||
|
||||
$result = \App\Controllers\WhatsAppController::verifyPaymentSlipStatic(
|
||||
companyId: $companyId,
|
||||
phone: $phone,
|
||||
jsonStr: '',
|
||||
userType: 'driver',
|
||||
paymentMethod: $paymentMethod,
|
||||
invoiceNumber: '',
|
||||
receiptImage: $image,
|
||||
imageMimeType: $imageMimeType,
|
||||
);
|
||||
|
||||
if ($result) {
|
||||
return new FlowResult($result, "finished", true);
|
||||
}
|
||||
|
||||
$retry = ($context['retry_count'] ?? 0) + 1;
|
||||
$context['retry_count'] = $retry;
|
||||
|
||||
if ($retry >= self::MAX_RETRIES) {
|
||||
return new FlowResult(
|
||||
"عذراً، تعذر التحقق من الدفع بعد {$retry} محاولات.\n"
|
||||
. "سيتم مراجعة العملية من قبل الإدارة.",
|
||||
"finished",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return new FlowResult(
|
||||
"لم نتمكن من التحقق من الدفع حالياً (محاولة {$retry} من " . self::MAX_RETRIES . ").\n"
|
||||
. "يرجى إرسال صورة أوضح والإضاءة جيدة:",
|
||||
"await_receipt"
|
||||
);
|
||||
|
||||
case 'postponed':
|
||||
// Resume from postponed
|
||||
$step = $context['previous_step'] ?? 'await_receipt';
|
||||
return new FlowResult(
|
||||
"مرحباً بك مرة أخرى! 👋\n"
|
||||
. "📸 أرسل صورة إيصال الدفع للمتابعة:",
|
||||
$step
|
||||
);
|
||||
|
||||
default:
|
||||
return new FlowResult("حدث خطأ في المسار.", "finished", true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if user wants to postpone (keyword-based, no AI call)
|
||||
*/
|
||||
private function detectPostponement(string $text): ?int
|
||||
{
|
||||
$keywords = [
|
||||
'بعدين' => 2, 'بكرا' => 12, 'بكرة' => 12, 'بعد' => 3,
|
||||
'شوي' => 1, 'مشغول' => 4, 'تأجيل' => 6, 'لاحقاً' => 6,
|
||||
'لاحقا' => 6, 'الحق' => 6, 'وقت ثاني' => 8, 'تعبان' => 6,
|
||||
'بعدين برسل' => 3, 'بعدين ببعت' => 3, 'بعدين بكمل' => 4,
|
||||
'ببعثها' => 3, 'ببعت' => 3, 'برسل' => 2,
|
||||
];
|
||||
|
||||
$normalized = trim(mb_strtolower($text));
|
||||
foreach ($keywords as $kw => $hours) {
|
||||
if (mb_strpos($normalized, $kw) !== false) {
|
||||
return $hours;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\Flows;
|
||||
|
||||
/**
|
||||
* TestFlow
|
||||
* A basic interactive flow for testing the multi-stage system.
|
||||
*/
|
||||
class TestFlow extends BaseFlow
|
||||
{
|
||||
public function handleStep(string $step, array $messageData, array &$context): FlowResult
|
||||
{
|
||||
$text = isset($messageData['body']) ? trim($messageData['body']) : '';
|
||||
|
||||
switch ($step) {
|
||||
case 'start':
|
||||
// Initiate step
|
||||
return new FlowResult("أهلاً بك في اختبار المسار التفاعلي! ما هو اسمك الكريم؟", "ask_name");
|
||||
|
||||
case 'ask_name':
|
||||
if (empty($text)) {
|
||||
return new FlowResult("يرجى إدخال اسمك للاستمرار:", "ask_name");
|
||||
}
|
||||
$context['name'] = $text;
|
||||
return new FlowResult("تشرفنا بك يا {$text}! من فضلك قم بتقييم خدمتنا من 1 إلى 5:", "ask_feedback");
|
||||
|
||||
case 'ask_feedback':
|
||||
if (!preg_match('/^[1-5]$/', $text)) {
|
||||
return new FlowResult("الرجاء إدخال رقم من 1 إلى 5 فقط للتقييم:", "ask_feedback");
|
||||
}
|
||||
$context['rating'] = (int)$text;
|
||||
return new FlowResult("شكراً لك يا {$context['name']}! لقد تم تسجيل تقييمك ({$text}/5) بنجاح. يومك سعيد!", "finished", true);
|
||||
|
||||
default:
|
||||
return new FlowResult("خطأ في تحديد خطوة المسار.", "finished", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Handles HTTP requests, extracting query params, body data, and headers.
|
||||
*/
|
||||
class Request
|
||||
{
|
||||
private string $method;
|
||||
private string $path;
|
||||
private array $queryParams;
|
||||
private array $bodyParams;
|
||||
private array $headers;
|
||||
|
||||
// Explicit properties to store authentication details to avoid deprecation warnings in PHP 8.2+
|
||||
public ?int $user_id = null;
|
||||
public ?int $company_id = null;
|
||||
public ?string $role = null;
|
||||
public bool $is_super_admin = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
|
||||
// Extract clean path without query parameters
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$path = explode('?', $uri)[0];
|
||||
$this->path = '/' . trim($path, '/');
|
||||
|
||||
$this->queryParams = $_GET;
|
||||
$this->headers = $this->extractHeaders();
|
||||
$this->bodyParams = $this->parseBody();
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function getQueryParams(): array
|
||||
{
|
||||
return $this->queryParams;
|
||||
}
|
||||
|
||||
public function getQuery(string $key, $default = null)
|
||||
{
|
||||
return $this->queryParams[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getBody(): array
|
||||
{
|
||||
return $this->bodyParams;
|
||||
}
|
||||
|
||||
public function setBody(array $bodyParams): void
|
||||
{
|
||||
$this->bodyParams = $bodyParams;
|
||||
}
|
||||
|
||||
public function setQueryParams(array $queryParams): void
|
||||
{
|
||||
$this->queryParams = $queryParams;
|
||||
}
|
||||
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
return $this->bodyParams[$key] ?? ($this->queryParams[$key] ?? $default);
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
public function getHeader(string $key, $default = null): ?string
|
||||
{
|
||||
$keyLower = strtolower($key);
|
||||
return $this->headers[$keyLower] ?? $default;
|
||||
}
|
||||
|
||||
private function extractHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $name => $value) {
|
||||
if (substr($name, 0, 5) == 'HTTP_') {
|
||||
$headers[strtolower(str_replace('_', '-', substr($name, 5)))] = $value;
|
||||
} elseif ($name == 'CONTENT_TYPE') {
|
||||
$headers['content-type'] = $value;
|
||||
} elseif ($name == 'CONTENT_LENGTH') {
|
||||
$headers['content-length'] = $value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private function parseBody(): array
|
||||
{
|
||||
if ($this->method === 'GET') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contentType = $this->getHeader('content-type', '');
|
||||
|
||||
if (strpos($contentType, 'application/json') !== false) {
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Handles generating and sending consistent API and HTML responses.
|
||||
*/
|
||||
class Response
|
||||
{
|
||||
private int $statusCode = 200;
|
||||
private array $headers = [];
|
||||
|
||||
public function setStatusCode(int $code): self
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function status(int $code): self
|
||||
{
|
||||
return $this->setStatusCode($code);
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function setHeader(string $name, string $value): self
|
||||
{
|
||||
$this->headers[$name] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response and terminate execution
|
||||
*
|
||||
* @param mixed $data
|
||||
* @param int $code
|
||||
* @return void
|
||||
*/
|
||||
public function json($data, int $code = 200): void
|
||||
{
|
||||
$this->setStatusCode($code);
|
||||
$this->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
// Setup CORS headers — restrict origin to the configured allowed domain
|
||||
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
|
||||
$this->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
|
||||
$this->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
$this->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
$this->setHeader('Vary', 'Origin'); // Required when Access-Control-Allow-Origin is not *
|
||||
|
||||
$this->sendHeaders();
|
||||
http_response_code($this->statusCode);
|
||||
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send HTML response and terminate execution
|
||||
*
|
||||
* @param string $html
|
||||
* @param int $code
|
||||
* @return void
|
||||
*/
|
||||
public function html(string $html, int $code = 200): void
|
||||
{
|
||||
$this->setStatusCode($code);
|
||||
$this->setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
|
||||
$this->sendHeaders();
|
||||
http_response_code($this->statusCode);
|
||||
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send success JSON response
|
||||
*/
|
||||
public function success(string $message, array $data = [], int $code = 200): void
|
||||
{
|
||||
$this->json([
|
||||
'status' => 'success',
|
||||
'message' => $message,
|
||||
'data' => $data
|
||||
], $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error JSON response
|
||||
*/
|
||||
public function error(string $message, int $code = 400, array $errors = []): void
|
||||
{
|
||||
$response = [
|
||||
'status' => 'error',
|
||||
'message' => $message
|
||||
];
|
||||
|
||||
if (!empty($errors)) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
$this->json($response, $code);
|
||||
}
|
||||
|
||||
public function sendHeaders(): void
|
||||
{
|
||||
if (headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->headers as $name => $value) {
|
||||
header("{$name}: {$value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Basic regex-based router supporting dynamic parameters, middlewares, and CORS OPTIONS.
|
||||
*/
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
private array $globalMiddleware = [];
|
||||
|
||||
/**
|
||||
* Define a GET route
|
||||
*/
|
||||
public function get(string $path, $handler, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $handler, $middleware);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a POST route
|
||||
*/
|
||||
public function post(string $path, $handler, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $handler, $middleware);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a PUT route
|
||||
*/
|
||||
public function put(string $path, $handler, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('PUT', $path, $handler, $middleware);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a DELETE route
|
||||
*/
|
||||
public function delete(string $path, $handler, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $handler, $middleware);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add global middleware applied to all routes
|
||||
*/
|
||||
public function use($middleware): void
|
||||
{
|
||||
$this->globalMiddleware[] = $middleware;
|
||||
}
|
||||
|
||||
private function addRoute(string $method, string $path, $handler, array $middleware): void
|
||||
{
|
||||
// Convert path matching expressions: /api/tickets/{id} -> regex
|
||||
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
|
||||
$this->routes[] = [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'pattern' => $pattern,
|
||||
'handler' => $handler,
|
||||
'middleware' => $middleware
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match current request and execute middleware and controller action
|
||||
*/
|
||||
public function dispatch(Request $request, Response $response): void
|
||||
{
|
||||
$method = $request->getMethod();
|
||||
$path = $request->getPath();
|
||||
|
||||
// Handle CORS Preflight Preemptively
|
||||
if ($method === 'OPTIONS') {
|
||||
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
|
||||
$response->setHeader('Access-Control-Allow-Origin', $allowedOrigin);
|
||||
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
$response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
$response->setHeader('Vary', 'Origin');
|
||||
$response->setStatusCode(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] === $method && preg_match($route['pattern'], $path, $matches)) {
|
||||
// Filter named captures from regex match
|
||||
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||
|
||||
// Run global middleware first
|
||||
foreach ($this->globalMiddleware as $mw) {
|
||||
$mwInstance = new $mw();
|
||||
$mwInstance->handle($request, $response);
|
||||
}
|
||||
|
||||
// Run route specific middleware
|
||||
foreach ($route['middleware'] as $mw) {
|
||||
$mwInstance = new $mw();
|
||||
$mwInstance->handle($request, $response);
|
||||
}
|
||||
|
||||
// Execute Controller
|
||||
$handler = $route['handler'];
|
||||
if (is_array($handler) && count($handler) === 2) {
|
||||
list($controllerClass, $action) = $handler;
|
||||
if (class_exists($controllerClass)) {
|
||||
$controller = new $controllerClass();
|
||||
if (method_exists($controller, $action)) {
|
||||
// Call action with Request, Response and URI dynamic parameters
|
||||
call_user_func_array([$controller, $action], array_merge([$request, $response], $params));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} elseif (is_callable($handler)) {
|
||||
call_user_func_array($handler, array_merge([$request, $response], $params));
|
||||
return;
|
||||
}
|
||||
|
||||
error_log("Handler error for route: [{$method}] {$path}");
|
||||
$response->error("Internal Server Error", 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route not found
|
||||
error_log("Route not found: [{$method}] {$path}");
|
||||
$response->error("Not Found", 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Advanced OWASP Security Helper
|
||||
* Handles AES-256-GCM encryption/decryption, HMAC Blind Indexing,
|
||||
* Bcrypt password hashing, and JWT validation.
|
||||
*/
|
||||
class Security
|
||||
{
|
||||
/**
|
||||
* Get the encryption key from environment (must be 32 bytes for AES-256)
|
||||
*/
|
||||
private static function getEncryptionKey(): string
|
||||
{
|
||||
$key = getenv('ENCRYPTION_KEY');
|
||||
if (!$key || strlen($key) < 16) {
|
||||
throw new \RuntimeException("ENCRYPTION_KEY environment variable is empty or too short. Cryptographic operations aborted.");
|
||||
}
|
||||
return substr(hash('sha256', $key, true), 0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HMAC Salt for Blind Indexing
|
||||
*/
|
||||
private static function getHmacSalt(): string
|
||||
{
|
||||
$salt = getenv('HMAC_SALT');
|
||||
if (!$salt) {
|
||||
throw new \RuntimeException("HMAC_SALT environment variable is empty. Cryptographic operations aborted.");
|
||||
}
|
||||
return $salt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT Secret
|
||||
*/
|
||||
private static function getJwtSecret(): string
|
||||
{
|
||||
$secret = getenv('JWT_SECRET');
|
||||
if (!$secret) {
|
||||
throw new \RuntimeException("JWT_SECRET environment variable is empty. Cryptographic operations aborted.");
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt text using AES-256-GCM
|
||||
*/
|
||||
public static function encrypt(string $plainText): string
|
||||
{
|
||||
if ($plainText === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = self::getEncryptionKey();
|
||||
$iv = openssl_random_pseudo_bytes(12); // GCM standard IV is 12 bytes
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plainText,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Return combined iv + tag + ciphertext base64 encoded
|
||||
return base64_encode($iv . $tag . $ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt text using AES-256-GCM
|
||||
*/
|
||||
public static function decrypt(string $encryptedText): string
|
||||
{
|
||||
if ($encryptedText === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = self::getEncryptionKey();
|
||||
$data = base64_decode($encryptedText);
|
||||
|
||||
// AES-256-GCM tag is 16 bytes, IV is 12 bytes
|
||||
if (strlen($data) < 28) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = substr($data, 0, 12);
|
||||
$tag = substr($data, 12, 16);
|
||||
$ciphertext = substr($data, 28);
|
||||
|
||||
$decrypted = openssl_decrypt(
|
||||
$ciphertext,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
return $decrypted !== false ? $decrypted : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Blind Index (HMAC-SHA256) for secure database queries
|
||||
*/
|
||||
public static function blindIndex(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$normalized = strtolower(trim($value));
|
||||
return hash_hmac('sha256', $normalized, self::getHmacSalt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
public static function hashPassword(string $password): string
|
||||
{
|
||||
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password
|
||||
*/
|
||||
public static function verifyPassword(string $password, string $hash): bool
|
||||
{
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT Token with HMAC-SHA256 signature
|
||||
* Includes user_id, company_id, role, iss, aud, and jti.
|
||||
*/
|
||||
public static function generateJWT(array $payload, int $expirySeconds = 86400): string
|
||||
{
|
||||
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
|
||||
// Standard OWASP Claims
|
||||
$payload['iat'] = time();
|
||||
$payload['exp'] = time() + $expirySeconds;
|
||||
$payload['iss'] = getenv('APP_URL'); // Issuer
|
||||
$payload['aud'] = 'nabeh_dashboard'; // Audience
|
||||
$payload['jti'] = bin2hex(random_bytes(16)); // JWT ID to prevent Replay Attacks
|
||||
|
||||
$payloadEncoded = self::base64UrlEncode(json_encode($payload));
|
||||
|
||||
$secret = self::getJwtSecret();
|
||||
$signature = hash_hmac('sha256', "$header.$payloadEncoded", $secret, true);
|
||||
$signatureEncoded = self::base64UrlEncode($signature);
|
||||
|
||||
return "$header.$payloadEncoded.$signatureEncoded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT Token and return payload if valid, false otherwise
|
||||
*/
|
||||
public static function verifyJWT(string $token)
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($headerEncoded, $payloadEncoded, $signatureEncoded) = $parts;
|
||||
|
||||
$secret = self::getJwtSecret();
|
||||
if (!$secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$signature = self::base64UrlDecode($signatureEncoded);
|
||||
$expectedSignature = hash_hmac('sha256', "$headerEncoded.$payloadEncoded", $secret, true);
|
||||
|
||||
if (!hash_equals($signature, $expectedSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode(self::base64UrlDecode($payloadEncoded), true);
|
||||
if (!$payload || !isset($payload['exp']) || time() >= $payload['exp']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate Issuer
|
||||
$expectedIssuer = getenv('APP_URL');
|
||||
if (isset($payload['iss']) && $payload['iss'] !== $expectedIssuer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
private static function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $data): string
|
||||
{
|
||||
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Core Validation Engine
|
||||
* Handles data validation before processing.
|
||||
*/
|
||||
class Validator
|
||||
{
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* Validate an array of data against rules.
|
||||
* Example rules: ['email' => 'required|email', 'password' => 'required|min:8']
|
||||
*/
|
||||
public function validate(array $data, array $rules): bool
|
||||
{
|
||||
$this->errors = [];
|
||||
|
||||
foreach ($rules as $field => $ruleString) {
|
||||
$rulesArray = explode('|', $ruleString);
|
||||
$value = $data[$field] ?? null;
|
||||
|
||||
foreach ($rulesArray as $rule) {
|
||||
$this->applyRule($field, $value, $rule);
|
||||
}
|
||||
}
|
||||
|
||||
return empty($this->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation errors.
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a specific rule to a field's value.
|
||||
*/
|
||||
private function applyRule(string $field, $value, string $rule): void
|
||||
{
|
||||
// Parse rule with parameters (e.g., min:8)
|
||||
$params = [];
|
||||
if (strpos($rule, ':') !== false) {
|
||||
list($rule, $paramStr) = explode(':', $rule, 2);
|
||||
$params = explode(',', $paramStr);
|
||||
}
|
||||
|
||||
switch ($rule) {
|
||||
case 'required':
|
||||
if ($value === null || trim((string)$value) === '') {
|
||||
$this->addError($field, "The {$field} field is required.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
if ($value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->addError($field, "The {$field} must be a valid email address.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'min':
|
||||
$min = (int)($params[0] ?? 0);
|
||||
if ($value && strlen((string)$value) < $min) {
|
||||
$this->addError($field, "The {$field} must be at least {$min} characters.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'max':
|
||||
$max = (int)($params[0] ?? 0);
|
||||
if ($value && strlen((string)$value) > $max) {
|
||||
$this->addError($field, "The {$field} must not exceed {$max} characters.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'numeric':
|
||||
if ($value && !is_numeric($value)) {
|
||||
$this->addError($field, "The {$field} must be a number.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'strong_password':
|
||||
// At least 8 chars, 1 uppercase, 1 lowercase, 1 number
|
||||
if ($value && !preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value)) {
|
||||
$this->addError($field, "The {$field} must be at least 8 characters long and contain uppercase, lowercase, and a number.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function addError(string $field, string $message): void
|
||||
{
|
||||
if (!isset($this->errors[$field])) {
|
||||
$this->errors[$field] = [];
|
||||
}
|
||||
$this->errors[$field][] = $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Security;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
/**
|
||||
* Verifies the JWT token and populates request properties.
|
||||
*/
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
$authHeader = $request->getHeader('authorization', '');
|
||||
|
||||
if (!$authHeader || !preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Token not provided or invalid format'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = $matches[1];
|
||||
$payload = Security::verifyJWT($token);
|
||||
|
||||
if (!$payload) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Invalid or expired token'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate required custom payload elements
|
||||
if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Attach user info to the Request instance dynamically so controllers can use it
|
||||
$request->user_id = $payload['user_id'];
|
||||
$request->company_id = $payload['company_id'];
|
||||
$request->role = $payload['role'];
|
||||
$request->is_super_admin = (int)$payload['company_id'] === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* Rate Limit Middleware
|
||||
* Limits the number of requests per IP address using file-based counters.
|
||||
* Protects sensitive endpoints (login, register) from Brute Force attacks.
|
||||
*/
|
||||
class RateLimitMiddleware
|
||||
{
|
||||
/**
|
||||
* Maximum allowed requests within the time window
|
||||
*/
|
||||
private int $maxAttempts;
|
||||
|
||||
/**
|
||||
* Time window in seconds
|
||||
*/
|
||||
private int $decaySeconds;
|
||||
|
||||
public function __construct(int $maxAttempts = 5, int $decaySeconds = 60)
|
||||
{
|
||||
$this->maxAttempts = $maxAttempts;
|
||||
$this->decaySeconds = $decaySeconds;
|
||||
}
|
||||
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$key = 'rate_' . md5($ip . '_' . $request->getPath());
|
||||
|
||||
$storageDir = APP_ROOT . '/storage/rate_limits';
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0750, true);
|
||||
}
|
||||
|
||||
$filePath = $storageDir . '/' . $key . '.json';
|
||||
|
||||
$data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds];
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
$raw = json_decode(file_get_contents($filePath), true);
|
||||
if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) {
|
||||
// Window still active — use existing data
|
||||
$data = $raw;
|
||||
}
|
||||
// If window expired, fall through and reset (overwrite with fresh data below)
|
||||
}
|
||||
|
||||
$data['count']++;
|
||||
|
||||
if ($data['count'] > $this->maxAttempts) {
|
||||
$retryAfter = max(0, $data['expires_at'] - time());
|
||||
$response->setHeader('Retry-After', (string)$retryAfter);
|
||||
$response->json([
|
||||
'error' => 'Too Many Requests',
|
||||
'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds."
|
||||
], 429);
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the updated counter
|
||||
file_put_contents($filePath, json_encode($data), LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real client IP, accounting for proxies
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
{
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'REMOTE_ADDR'
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// X-Forwarded-For can be a comma-separated list; take first
|
||||
$ip = trim(explode(',', $_SERVER[$header])[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
class SecurityMiddleware
|
||||
{
|
||||
/**
|
||||
* Applies OWASP security headers and sanitizes incoming body/query parameters
|
||||
* to protect against XSS and basic Injection.
|
||||
*/
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
// 1. Set OWASP Security Headers
|
||||
$response->setHeader('X-Frame-Options', 'DENY'); // Prevent Clickjacking
|
||||
$response->setHeader('X-XSS-Protection', '1; mode=block'); // Prevent Cross-Site Scripting (XSS)
|
||||
$response->setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME-sniffing
|
||||
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // HSTS
|
||||
$response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; object-src 'none';"); // CSP
|
||||
|
||||
// 2. Input Sanitization to prevent XSS (Recursive)
|
||||
$body = $request->getBody();
|
||||
if (is_array($body)) {
|
||||
$request->setBody($this->sanitizeArray($body));
|
||||
}
|
||||
|
||||
$query = $request->getQueryParams();
|
||||
if (is_array($query)) {
|
||||
$request->setQueryParams($this->sanitizeArray($query));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively trim input arrays
|
||||
*/
|
||||
private function sanitizeArray(array $data): array
|
||||
{
|
||||
$sanitized = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$sanitized[$key] = $this->sanitizeArray($value);
|
||||
} elseif (is_string($value)) {
|
||||
$sanitized[$key] = trim($value);
|
||||
} else {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Models\CompanySubscription;
|
||||
use App\Models\CompanySubscriptionUsage;
|
||||
|
||||
/**
|
||||
* SubscriptionMiddleware
|
||||
* Validates company subscription validity and request quotas before processing operations.
|
||||
*/
|
||||
class SubscriptionMiddleware
|
||||
{
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
// 1. Get company ID (populated by AuthMiddleware)
|
||||
$companyId = $request->company_id ?? null;
|
||||
|
||||
if (!$companyId) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Company details not found in request Context'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Allow Company 1 (Intaleq admin/demo) to bypass limits temporarily or have unlimited
|
||||
if ($companyId === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fetch active subscription
|
||||
$activeSub = CompanySubscription::findActiveByCompany($companyId);
|
||||
if (!$activeSub) {
|
||||
$response->json([
|
||||
'error' => 'Payment Required',
|
||||
'message' => 'This account does not have an active subscription or the current subscription has expired. Please subscribe to a plan to continue.'
|
||||
], 402);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Verify total requests limit
|
||||
$hasQuota = CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request');
|
||||
if (!$hasQuota) {
|
||||
$response->json([
|
||||
'error' => 'Quota Exceeded',
|
||||
'message' => 'You have exceeded the monthly request quota for your plan (' . $activeSub['max_requests'] . ' requests). Please upgrade your subscription.'
|
||||
], 403);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* Nabeh Application Bootstrap Loader
|
||||
* Handles PSR-4 Autoloading, security settings, and error handling.
|
||||
*/
|
||||
|
||||
// Define absolute path to application root
|
||||
define('APP_ROOT', dirname(__DIR__));
|
||||
|
||||
// 1. PSR-4 Autoloader
|
||||
spl_autoload_register(function ($class) {
|
||||
// Namespace prefix
|
||||
$prefix = 'App\\';
|
||||
// Directory mapping for the prefix
|
||||
$base_dir = APP_ROOT . '/app/';
|
||||
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return; // Move to next registered autoloader
|
||||
}
|
||||
|
||||
$relative_class = substr($class, $len);
|
||||
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Load Environment Variables
|
||||
try {
|
||||
// Find the closest .env file path (supporting local development and CloudPanel server directories)
|
||||
$env_file = APP_ROOT . '/.env';
|
||||
if (!file_exists($env_file)) {
|
||||
if (file_exists(APP_ROOT . '/../../../.env')) {
|
||||
$env_file = APP_ROOT . '/../../../.env';
|
||||
} elseif (file_exists(APP_ROOT . '/../.env')) {
|
||||
$env_file = APP_ROOT . '/../.env';
|
||||
}
|
||||
}
|
||||
\App\Core\Env::load($env_file);
|
||||
} catch (\Exception $e) {
|
||||
// In production, log error; in development, print it
|
||||
error_log('Env Load Error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 3. Configure Error Reporting based on environment
|
||||
$isDebug = filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if ($isDebug) {
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
} else {
|
||||
ini_set('display_errors', '0');
|
||||
error_reporting(0);
|
||||
}
|
||||
|
||||
// 4. Global Uncaught Exception Handler
|
||||
// Catches any unhandled exception anywhere in the app and returns a clean JSON error
|
||||
// instead of leaking PHP stack traces to the browser.
|
||||
set_exception_handler(function (\Throwable $e) {
|
||||
$isDebug = filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
error_log('[EXCEPTION] ' . get_class($e) . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code(500);
|
||||
}
|
||||
|
||||
$body = ['error' => 'Internal Server Error'];
|
||||
|
||||
// In debug mode, expose details to the developer only
|
||||
if ($isDebug) {
|
||||
$body['debug'] = [
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit(1);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* Saqel API Front Controller
|
||||
* Single entry point handling routing and application bootstrap.
|
||||
*/
|
||||
|
||||
// 1. Boot the application (autoloader, env, errors)
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Router;
|
||||
|
||||
// 2. Initialize request and response objects
|
||||
$request = new Request();
|
||||
$response = new Response();
|
||||
$router = new Router();
|
||||
|
||||
// 3. Define Global Middleware
|
||||
$router->use(\App\Middlewares\SecurityMiddleware::class);
|
||||
|
||||
// 4. Define API Routes
|
||||
|
||||
// Health Check
|
||||
$router->get('/api/health', function ($request, $response) {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Saqel API is healthy and fast (Pure PHP)',
|
||||
'app_name' => getenv('APP_NAME') ?: 'Saqel',
|
||||
'time' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
});
|
||||
|
||||
// Authentication Routes (Rate-limited: 5 attempts per 60 seconds per IP)
|
||||
// $router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register'], [\App\Middlewares\RateLimitMiddleware::class]);
|
||||
// $router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login'], [\App\Middlewares\RateLimitMiddleware::class]);
|
||||
// $router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
// 5. Dispatch the request
|
||||
$router->dispatch($request, $response);
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
return [
|
||||
'db_host' => '127.0.0.1',
|
||||
'db_name' => 'saqel',
|
||||
'db_user' => 'saqel',
|
||||
'db_pass' => 'secret',
|
||||
'db_charset' => 'utf8mb4',
|
||||
];
|
||||
@@ -1,92 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>فحص الـ API - منصة صقل</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; margin: 0; padding: 20px; color: #333; }
|
||||
h1, h2 { text-align: center; }
|
||||
.card { background: white; padding: 20px; margin: 20px auto; max-width: 600px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||
input, select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
|
||||
button { background-color: #27ae60; color: white; border: none; padding: 10px 15px; cursor: pointer; border-radius: 4px; font-weight: bold; margin-bottom: 5px; }
|
||||
button:hover { background-color: #2ecc71; }
|
||||
pre { background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 4px; overflow-x: auto; direction: ltr; text-align: left; max-height: 300px; }
|
||||
.btn-info { background-color: #3498db; }
|
||||
.btn-info:hover { background-color: #2980b9; }
|
||||
.nav { text-align: center; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="index.html">العودة للرئيسية</a>
|
||||
</div>
|
||||
<h1>أداة فحص الواجهات البرمجية (Pure PHP API Tester)</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>فحص الاتصال والسرعة (Ping)</h2>
|
||||
<button onclick="testPing()" class="btn-info">فحص /api/ping</button>
|
||||
|
||||
<h3>النتيجة:</h3>
|
||||
<pre id="ping-result">في انتظار التنفيذ...</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>تسجيل الدخول (Login)</h2>
|
||||
<div class="form-group">
|
||||
<label>البريد الإلكتروني:</label>
|
||||
<input type="email" id="login-email" value="test@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>كلمة المرور:</label>
|
||||
<input type="password" id="login-password" value="secret">
|
||||
</div>
|
||||
<button onclick="testLogin()">تسجيل الدخول</button>
|
||||
|
||||
<h3>النتيجة:</h3>
|
||||
<pre id="login-result">في انتظار التنفيذ...</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testPing() {
|
||||
const resultBox = document.getElementById('ping-result');
|
||||
resultBox.innerHTML = 'جاري الاتصال بـ /index.php/api/ping...';
|
||||
|
||||
try {
|
||||
// Notice we route through index.php since we haven't set up htaccess rewrite rules yet
|
||||
const response = await fetch('/index.php/api/ping');
|
||||
const data = await response.json();
|
||||
resultBox.innerHTML = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
resultBox.innerHTML = 'خطأ في الاتصال: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function testLogin() {
|
||||
const email = document.getElementById('login-email').value;
|
||||
const password = document.getElementById('login-password').value;
|
||||
const resultBox = document.getElementById('login-result');
|
||||
|
||||
resultBox.innerHTML = 'جاري الاتصال بـ /index.php/api/auth/login...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/index.php/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
resultBox.innerHTML = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
resultBox.innerHTML = 'خطأ في الاتصال: ' + error.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>منصة صقل - الرئيسية</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; text-align: center; margin: 0; padding: 0; background-color: #f4f7f6; color: #333; }
|
||||
header { background-color: #2c3e50; color: white; padding: 2rem 0; }
|
||||
h1 { margin: 0; font-size: 2.5rem; }
|
||||
p { font-size: 1.2rem; }
|
||||
.container { padding: 3rem 1rem; }
|
||||
.btn { display: inline-block; background-color: #3498db; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold; margin-top: 20px; transition: 0.3s; }
|
||||
.btn:hover { background-color: #2980b9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>منصة صقل</h1>
|
||||
<p>المنصة التعليمية الأولى لتطوير المهارات (Pure PHP)</p>
|
||||
</header>
|
||||
<div class="container">
|
||||
<h2>مرحباً بك في منصة صقل</h2>
|
||||
<p>النظام الأساسي (Backend) يعمل بنجاح تام وبسرعة فائقة!</p>
|
||||
<a href="api-test.html" class="btn">الانتقال لصفحة فحص الـ API</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
header("Content-Type: application/json; charset=UTF-8");
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Auto-loader for classes (simple version)
|
||||
spl_autoload_register(function ($class) {
|
||||
$prefix = 'Saqel\\';
|
||||
$base_dir = __DIR__ . '/../src/';
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return;
|
||||
}
|
||||
$relative_class = substr($class, $len);
|
||||
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
|
||||
use Saqel\Router;
|
||||
use Saqel\Database;
|
||||
|
||||
$router = new Router();
|
||||
|
||||
$router->add('GET', '/', function() {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Saqel API is running in Pure PHP mode!'
|
||||
]);
|
||||
});
|
||||
|
||||
$router->add('GET', '/api/ping', function() {
|
||||
$time_start = microtime(true);
|
||||
|
||||
// Test DB connection
|
||||
$db = Database::getInstance()->getConnection();
|
||||
$stmt = $db->query("SELECT 1");
|
||||
$dbStatus = $stmt ? 'connected' : 'failed';
|
||||
|
||||
$time_end = microtime(true);
|
||||
$execution_time = ($time_end - $time_start) * 1000;
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Saqel Pure PHP Backend is running incredibly fast!',
|
||||
'database' => $dbStatus,
|
||||
'execution_time_ms' => round($execution_time, 2)
|
||||
]);
|
||||
});
|
||||
|
||||
// Example route for user login (Mock)
|
||||
$router->add('POST', '/api/auth/login', function() {
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
// In a real app, query Database here and verify password
|
||||
if (isset($data['email']) && isset($data['password'])) {
|
||||
echo json_encode([
|
||||
'access_token' => bin2hex(random_bytes(16)),
|
||||
'token_type' => 'Bearer',
|
||||
'user' => [
|
||||
'email' => $data['email'],
|
||||
'role' => 'student'
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
header("HTTP/1.0 400 Bad Request");
|
||||
echo json_encode(['error' => 'Missing email or password']);
|
||||
}
|
||||
});
|
||||
|
||||
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
namespace Saqel;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class Database {
|
||||
private static $instance = null;
|
||||
private $pdo;
|
||||
|
||||
private function __construct() {
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
$dsn = "mysql:host={$config['db_host']};dbname={$config['db_name']};charset={$config['db_charset']}";
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->pdo = new PDO($dsn, $config['db_user'], $config['db_pass'], $options);
|
||||
} catch (PDOException $e) {
|
||||
die(json_encode(['error' => 'Database Connection failed: ' . $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance() {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function getConnection() {
|
||||
return $this->pdo;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
namespace Saqel;
|
||||
|
||||
class Router {
|
||||
private $routes = [];
|
||||
|
||||
public function add($method, $path, $handler) {
|
||||
$this->routes[] = compact('method', 'path', 'handler');
|
||||
}
|
||||
|
||||
public function dispatch($method, $uri) {
|
||||
$uri = parse_url($uri, PHP_URL_PATH);
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] === $method && $route['path'] === $uri) {
|
||||
return call_user_func($route['handler']);
|
||||
}
|
||||
}
|
||||
|
||||
header("HTTP/1.0 404 Not Found");
|
||||
echo json_encode(['error' => 'Endpoint Not Found']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user