diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..a93fdbd --- /dev/null +++ b/backend/.env.example @@ -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 + + + diff --git a/backend/app/Core/Cache.php b/backend/app/Core/Cache.php new file mode 100644 index 0000000..595292d --- /dev/null +++ b/backend/app/Core/Cache.php @@ -0,0 +1,144 @@ +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; + } +} diff --git a/backend/app/Core/Database.php b/backend/app/Core/Database.php new file mode 100644 index 0000000..06d24ff --- /dev/null +++ b/backend/app/Core/Database.php @@ -0,0 +1,99 @@ + 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(); + } +} diff --git a/backend/app/Core/Env.php b/backend/app/Core/Env.php new file mode 100644 index 0000000..0ca3daf --- /dev/null +++ b/backend/app/Core/Env.php @@ -0,0 +1,86 @@ + $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); + } + } +} diff --git a/backend/app/Core/Flows/ConversationFlowEngine.php b/backend/app/Core/Flows/ConversationFlowEngine.php new file mode 100644 index 0000000..42df5af --- /dev/null +++ b/backend/app/Core/Flows/ConversationFlowEngine.php @@ -0,0 +1,322 @@ + 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'; + } +} diff --git a/backend/app/Core/Flows/DriverRegistrationFlow.php b/backend/app/Core/Flows/DriverRegistrationFlow.php new file mode 100644 index 0000000..377dd78 --- /dev/null +++ b/backend/app/Core/Flows/DriverRegistrationFlow.php @@ -0,0 +1,510 @@ + '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 = << isset($data['hours_delay']) ? (int)$data['hours_delay'] : 12 + ]; + } + + return null; + } +} diff --git a/backend/app/Core/Flows/FlowResult.php b/backend/app/Core/Flows/FlowResult.php new file mode 100644 index 0000000..c7fd61c --- /dev/null +++ b/backend/app/Core/Flows/FlowResult.php @@ -0,0 +1,55 @@ +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; + } +} diff --git a/backend/app/Core/Flows/PaymentFlow.php b/backend/app/Core/Flows/PaymentFlow.php new file mode 100644 index 0000000..fd88633 --- /dev/null +++ b/backend/app/Core/Flows/PaymentFlow.php @@ -0,0 +1,194 @@ +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; + } +} diff --git a/backend/app/Core/Flows/TestFlow.php b/backend/app/Core/Flows/TestFlow.php new file mode 100644 index 0000000..5eab433 --- /dev/null +++ b/backend/app/Core/Flows/TestFlow.php @@ -0,0 +1,38 @@ +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; + } +} diff --git a/backend/app/Core/Response.php b/backend/app/Core/Response.php new file mode 100644 index 0000000..58c2af7 --- /dev/null +++ b/backend/app/Core/Response.php @@ -0,0 +1,119 @@ +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}"); + } + } +} diff --git a/backend/app/Core/Router.php b/backend/app/Core/Router.php new file mode 100644 index 0000000..62e1188 --- /dev/null +++ b/backend/app/Core/Router.php @@ -0,0 +1,131 @@ +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); + } +} diff --git a/backend/app/Core/Security.php b/backend/app/Core/Security.php new file mode 100644 index 0000000..0cffaa2 --- /dev/null +++ b/backend/app/Core/Security.php @@ -0,0 +1,209 @@ + 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)); + } +} diff --git a/backend/app/Core/Validator.php b/backend/app/Core/Validator.php new file mode 100644 index 0000000..d6e4e26 --- /dev/null +++ b/backend/app/Core/Validator.php @@ -0,0 +1,102 @@ + '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; + } +} diff --git a/backend/app/Middlewares/AuthMiddleware.php b/backend/app/Middlewares/AuthMiddleware.php new file mode 100644 index 0000000..6187169 --- /dev/null +++ b/backend/app/Middlewares/AuthMiddleware.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/backend/app/Middlewares/RateLimitMiddleware.php b/backend/app/Middlewares/RateLimitMiddleware.php new file mode 100644 index 0000000..521cdb4 --- /dev/null +++ b/backend/app/Middlewares/RateLimitMiddleware.php @@ -0,0 +1,94 @@ +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'; + } +} diff --git a/backend/app/Middlewares/SecurityMiddleware.php b/backend/app/Middlewares/SecurityMiddleware.php new file mode 100644 index 0000000..abbae10 --- /dev/null +++ b/backend/app/Middlewares/SecurityMiddleware.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/backend/app/Middlewares/SubscriptionMiddleware.php b/backend/app/Middlewares/SubscriptionMiddleware.php new file mode 100644 index 0000000..cf42437 --- /dev/null +++ b/backend/app/Middlewares/SubscriptionMiddleware.php @@ -0,0 +1,51 @@ +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; + } + } +} diff --git a/backend/app/bootstrap.php b/backend/app/bootstrap.php new file mode 100644 index 0000000..d47d669 --- /dev/null +++ b/backend/app/bootstrap.php @@ -0,0 +1,86 @@ +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); +}); diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..72c5e34 --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,40 @@ +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); diff --git a/config.php b/config.php deleted file mode 100644 index 0d5f706..0000000 --- a/config.php +++ /dev/null @@ -1,8 +0,0 @@ - '127.0.0.1', - 'db_name' => 'saqel', - 'db_user' => 'saqel', - 'db_pass' => 'secret', - 'db_charset' => 'utf8mb4', -]; diff --git a/public/api-test.html b/public/api-test.html deleted file mode 100644 index ab6a04b..0000000 --- a/public/api-test.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - فحص الـ API - منصة صقل - - - - -

أداة فحص الواجهات البرمجية (Pure PHP API Tester)

- -
-

فحص الاتصال والسرعة (Ping)

- - -

النتيجة:

-
في انتظار التنفيذ...
-
- -
-

تسجيل الدخول (Login)

-
- - -
-
- - -
- - -

النتيجة:

-
في انتظار التنفيذ...
-
- - - - diff --git a/public/index.html b/public/index.html deleted file mode 100644 index c4bbbf6..0000000 --- a/public/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - منصة صقل - الرئيسية - - - -
-

منصة صقل

-

المنصة التعليمية الأولى لتطوير المهارات (Pure PHP)

-
-
-

مرحباً بك في منصة صقل

-

النظام الأساسي (Backend) يعمل بنجاح تام وبسرعة فائقة!

- الانتقال لصفحة فحص الـ API -
- - diff --git a/public/index.php b/public/index.php deleted file mode 100644 index 1842d02..0000000 --- a/public/index.php +++ /dev/null @@ -1,78 +0,0 @@ -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']); diff --git a/src/Database.php b/src/Database.php deleted file mode 100644 index 886e975..0000000 --- a/src/Database.php +++ /dev/null @@ -1,38 +0,0 @@ - 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; - } -} diff --git a/src/Router.php b/src/Router.php deleted file mode 100644 index 1c8ae2a..0000000 --- a/src/Router.php +++ /dev/null @@ -1,23 +0,0 @@ -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']); - } -}