Deploy: 2026-05-22 21:52:51

This commit is contained in:
Hamza-Ayed
2026-05-22 21:52:51 +03:00
parent 5269789b51
commit 8acca92bba
12 changed files with 1938 additions and 285 deletions

245
ai_document.php Normal file
View File

@@ -0,0 +1,245 @@
<?php
require_once __DIR__ . '/../../connect.php';
$driverId = filterRequest("driver_id");
$type = filterRequest("type");
// 🔒 Validate image
if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
error_log("Upload error: Image not provided or upload failed.");
jsonError("Image upload failed");
exit;
}
$file = $_FILES['image'];
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png'];
if (!in_array($extension, $allowed)) {
error_log("Unsupported file type: $extension");
jsonError("Unsupported file type");
exit;
}
$uniqueName = "driver_" . $type . "_" . $driverId . ".$extension";
$uploadDir = "../uploads/documents/";
$uploadPath = $uploadDir . $uniqueName;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
if (!move_uploaded_file($file['tmp_name'], $uploadPath)) {
error_log("Failed to move uploaded file.");
jsonError("Failed to move uploaded image");
exit;
}
$imageUrl = "https://intaleq.xyz/intaleq/auth/uploads/documents/" . $uniqueName ;
$imageData = file_get_contents($uploadPath);
$imageBase64 = base64_encode($imageData);
$mimeType = match ($extension) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
default => 'application/octet-stream',
};
$prompts = [
"id_front_sy" => <<<EOT
You are an OCR expert for Syrian national ID cards (green card).
### TASK
Analyse the **front side** of the ID and return **raw JSON only** with exactly these keys:
{
"full_name": "", // الاسم الثلاثي أو الرباعي
"national_number": "", // الرقم الوطني (LATIN digits only)
"dob": "YYYY-MM-DD", // تاريخ الميلاد
"address": "" // العنوان
}
### RULES
* Read the red number on the bottom of the card.
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western-Arabic digits (0-9).
* `national_number` must contain **Latin digits only, no spaces or other characters**.
* If a field is missing, set it to **null**.
* Convert the birth date to ISO `YYYY-MM-DD`.
* Return valid JSON only — no extra keys, no markdown.
EOT,
"id_back_sy" => <<<EOT
أنت خبير OCR مختص ببطاقات الهوية السورية (الوجه الخلفي).
### المطلوب
حلّل صورة الوجه الخلفي للهوية السورية وأعد **JSON صِرف** يحتوي المفاتيح التالية فقط:
{
"governorate": "", // المحافظة (مثال: دمشق)
"address": "", // العنوان التفصيلي (حيّ، بلدة …)
"gender": "", //Male or Female
"issue_date": "YYYY-MM-DD"// تاريخ الإصدار بصيغة ISO
}
### القواعد
1. حوّل أي أرقام عربية شرقية (٠١٢٣٤٥٦٧٨٩) إلى أرقام لاتينية (0-9).
2. أعدّ تاريخ الإصدار بالتقويم الميلادي بصيغة `YYYY-MM-DD`.
3. استخدم أحرف لاتينية كبيرة لزمرة الدم مع رمز `+` أو `-` فقط.
4. إذا كان أحد الحقول غير موجود مطلقًا، أعد قيمته **null**.
5. لا تُرجع أي مفاتيح إضافية أو شروح أو Markdown — JSON صالح فقط.
EOT,
"driving_license_sy_front" => <<<EOT
You are an OCR expert for Syrian documents.
### TASK
Analyse the **front side of a Syrian driving licence** and return **clean JSON only** with the following keys (no extra keys, no markdown):
{
"name_arabic": "", // الاسم الثلاثي أو الرباعي بالعربية
"birth_place": "", // المحافظة أو المنطقة المكتوبة بعد كلمة الولادة
"birth_year": "", // سنة الميلاد فقط (أربعة أرقام)
"national_number": ""
"civil_registry": "", // سطر "القيد" (مثال: سهوة 3)
"blood_type": "" // زمرة الدم بالشكل: A+ , A- , B+ , B- , AB+ , AB- , O+ , O-
}
### RULES
* إذا كانت القيمة مفقودة تمامًا اكتب **null**.
* لا تُغيّر ترتيب المفاتيح.
* لا تُرسل أى شرح أو أسطر إضافية JSON خالص فقط.
EOT,
"driving_license_sy_back" => <<<EOT
You are an OCR expert for Syrian driving licences.
### TASK
Analyse the **back side** of a Syrian driving licence and return **raw JSON only** with exactly these keys:
{
"issue_date": "YYYY-MM-DD", // تاريخ المنح
"expiry_date": "YYYY-MM-DD", // صالحة لغاية
"license_number": "", // رقم الإجازة
"license_category": "" // D1, D2, D3 … (as printed after "UNIVERSAL DRIVING LICENCE")
}
### RULES
* If a value is totally absent, set it to **null**.
* Convert all dates to ISO `YYYY-MM-DD` (Gregorian).
* Do **NOT** add extra keys, comments, or markdown — return valid JSON only.
EOT,
"vehicle_license_sy_front" => <<<EOT
You are an OCR expert specialized in analyzing Syrian vehicle registration cards (الرخصة البرتقالية).
Your task is to extract structured data from the **front side** of the Syrian orange vehicle card and return **raw JSON only** with the following exact fields:
{
"car_plate": "", // رقم المركبة الكامل مع اسم المحافظة، مأخوذ من الجهة اليسرى في السطر الأول (مثال: "155186 درعا")
"owner": "", // اسم المالك الكامل
"vin": "", // رقم الهيكل
"color": "", // اللون بالعربية أو الإنجليزية (مثال: "أبيض" أو "White")
"color_hex": "", // كود اللون بصيغة Hex (مثال: "#FFFFFF") أو #27332F إن تعذّر
"issue_date": "YYYY-MM-DD", // تاريخ المنح بصيغة ISO
"inspection_date": "YYYY-MM-DD" // تاريخ الفحص القادم بصيغة ISO
}
### Instructions & Rules:
1. Do **not** extract the "رمز المركبة" (on the right side of the first line) — use only the **left side** of the first line for `car_plate`.
2. Convert any Arabic dates (like `2024/05/13`) into ISO format `YYYY-MM-DD`.
3. If any value is missing or unreadable, return `null` for it.
4. Maintain Arabic encoding (e.g., owner name, city name, color).
5. Never guess — extract only what's visually found on the card.
6. Never include any explanation or extra output — return the JSON only.
Example of valid `car_plate`:
- "155186 درعا"
- "45291 دمشق"
- "122334 حمص"
EOT,
"vehicle_license_sy_back" => <<<EOT
You are an OCR expert for Syrian vehicle registration cards (orange card).
### TASK
Analyse the **back side** of the card and return **raw JSON only** with exactly these keys (no more, no less):
{
"make": "", // الصانع (Hyundai …)
"model": "", // الطراز (H1 …)
"year": "", // سنة الصنع بالأرقام اللاتينية (e.g. "2019")
"fuel": "", // نوع الوقود (بنزين، ديزل …) أو بالإنجليزية (Petrol, Diesel,electric)
"chassis": "" // رقم الهيكل (VIN)
}
### RULES
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western digits (0-9).
* Normalise color names to standard English if possible, then map to a common Hex code
• "أبيض / White" → **#FFFFFF**
• "أسود / Black" → **#000000**
• "أحمر / Red" → **#FF0000**
• "أزرق / Blue" → **#0000FF**
• … (use the closest basic colour); if no match, set **color_hex = null**.
* If any field is unreadable or absent, set its value to **null**.
* Do **NOT** include extra keys, comments, or markdown — output valid JSON only.
EOT
];
$prompt = $prompts[$type] ?? $prompts["id_front_sy"];
$apiKey = getenv("GEMINI_API_KEY");
$apiURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=$apiKey";
$headers = ["Content-Type: application/json"];
$payload = [
"contents" => [
["role" => "user", "parts" => [["text" => $prompt]]],
["role" => "user", "parts" => [["inlineData" => ["mimeType" => $mimeType, "data" => $imageBase64]]]]
]
];
$ch = curl_init($apiURL);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
$response = curl_exec($ch);
if (curl_errno($ch)) {
$error_msg = curl_error($ch);
error_log("CURL error: $error_msg");
jsonError("AI Error: $error_msg");
curl_close($ch);
exit;
}
curl_close($ch);
error_log("AI raw response: $response");
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("JSON decode error: " . json_last_error_msg());
jsonError("Failed to parse AI response");
exit;
}
$textRaw = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
$textRaw = trim(preg_replace('/```json|```/', '', $textRaw));
$json = json_decode($textRaw, true);
$requiredKey = match ($type) {
'id_front_sy' => 'national_number',
'id_back_sy' => 'gender',
'driving_license_sy' => 'license_type',
'vehicle_license_sy' => 'chassis',
default => null,
};
if (!$json || ($requiredKey && !isset($json[$requiredKey]))) {
error_log("AI response missing required key '$requiredKey': $textRaw");
jsonError("AI failed to extract required information");
exit;
}
printSuccess([
"image_url" => $imageUrl,
"data" => $json
]);

View File

@@ -16,6 +16,7 @@ class ConversationFlowEngine
*/
private static array $flows = [
'test_flow' => TestFlow::class,
'driver_registration_flow' => DriverRegistrationFlow::class,
];
/**
@@ -24,6 +25,9 @@ class ConversationFlowEngine
private static array $startTriggers = [
'test' => 'test_flow',
'اختبار' => 'test_flow',
'سجل' => 'driver_registration_flow',
'تسجيل' => 'driver_registration_flow',
'register' => 'driver_registration_flow',
];
/**
@@ -39,6 +43,20 @@ class ConversationFlowEngine
$companyId = $session['company_id'];
$text = isset($msgData['body']) ? trim($msgData['body']) : '';
// If incoming message is audio, transcribe it via Gemini
$isAudio = !empty($msgData['audio']) && !empty($msgData['mimeType']);
if ($isAudio) {
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
if (!empty($apiKey)) {
$transcription = \App\Services\GeminiService::transcribeAudio($apiKey, $msgData['audio'], $msgData['mimeType']);
if ($transcription) {
$text = $transcription;
$msgData['body'] = $transcription;
}
}
}
// 2. Lookup existing active flow
$state = ConversationState::findActive($companyId, $phone);
@@ -84,6 +102,7 @@ class ConversationFlowEngine
$flowInstance = new $flowClass();
try {
$context['company_id'] = $companyId;
$result = $flowInstance->handleStep($currentStep, $msgData, $context);
if ($result->isFinished()) {
@@ -104,8 +123,8 @@ class ConversationFlowEngine
}
// 5. Send reply if one is provided
if ($result->getReplyText() !== '') {
self::sendReply($session, $phone, $result->getReplyText());
if ($result->getReplyText() !== '' || $result->getMediaUrl() !== null) {
self::sendReply($session, $phone, $result->getReplyText(), $result->getMediaUrl());
}
return true;
@@ -118,8 +137,14 @@ class ConversationFlowEngine
/**
* Send outbound message reply via the Baileys Gateway
*/
private static function sendReply(array $session, string $phone, string $message): void
{
public static function sendReply(
array $session,
string $phone,
string $message,
?string $mediaUrl = null,
?string $audioBase64 = null,
?string $mimetype = null
): void {
$gatewayUrl = rtrim(getenv('WHATSAPP_GATEWAY_URL') ?: 'http://localhost:3722', '/');
if (substr($gatewayUrl, -4) === '/api') {
$sendUrl = $gatewayUrl . '/messages/send';
@@ -127,11 +152,25 @@ class ConversationFlowEngine
$sendUrl = $gatewayUrl . '/api/messages/send';
}
$payload = json_encode([
$payloadData = [
'session_key' => $session['session_key'],
'phone' => $phone,
'message' => $message
]);
'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;
}
$payload = json_encode($payloadData);
$ch = curl_init($sendUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -161,14 +200,17 @@ class ConversationFlowEngine
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' => 'text',
'message_type' => $msgType,
'message_body' => $message,
'media_url' => $mediaUrl,
'whatsapp_message_id' => $waMsgId,
'status' => $status,
'error_message' => $errorMsg

View File

@@ -0,0 +1,483 @@
<?php
namespace App\Core\Flows;
use App\Services\GeminiService;
use App\Models\DriverOcrData;
use App\Models\ChatbotRule;
use App\Models\DriverReminder;
/**
* DriverRegistrationFlow
* Handles step-by-step driver and vehicle registration using Gemini OCR.
*/
class DriverRegistrationFlow extends BaseFlow
{
private array $prompts = [
"id_front" => <<<EOT
You are an OCR expert for Syrian national ID cards (green card).
### TASK
Analyse the **front side** of the ID and return **raw JSON only** with exactly these keys:
{
"full_name": "", // الاسم الثلاثي أو الرباعي
"national_number": "", // الرقم الوطني (LATIN digits only)
"dob": "YYYY-MM-DD", // تاريخ الميلاد
"address": "" // العنوان
}
### RULES
* Read the red number on the bottom of the card.
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western-Arabic digits (0-9).
* `national_number` must contain **Latin digits only, no spaces or other characters**.
* If a field is missing, set it to **null**.
* Convert the birth date to ISO `YYYY-MM-DD`.
* Return valid JSON only — no extra keys, no markdown.
EOT,
"id_back" => <<<EOT
أنت خبير OCR مختص ببطاقات الهوية السورية (الوجه الخلفي).
### المطلوب
حلّل صورة الوجه الخلفي للهوية السورية وأعد **JSON صِرف** يحتوي المفاتيح التالية فقط:
{
"governorate": "", // المحافظة (مثال: دمشق)
"address": "", // العنوان التفصيلي (حيّ، بلدة …)
"gender": "", //Male or Female
"issue_date": "YYYY-MM-DD"// تاريخ الإصدار بصيغة ISO
}
### القواعد
1. حوّل أي أرقام عربية شرقية (٠١٢٣٤٥٦٧٨٩) إلى أرقام لاتينية (0-9).
2. أعدّ تاريخ الإصدار بالتقويم الميلادي بصيغة `YYYY-MM-DD`.
3. استخدم أحرف لاتينية كبيرة لزمرة الدم مع رمز `+` أو `-` فقط.
4. إذا كان أحد الحقول غير موجود مطلقًا، أعد قيمته **null**.
5. لا تُرجع أي مفاتيح إضافية أو شروح أو Markdown — JSON صالح فقط.
EOT,
"driving_license_front" => <<<EOT
You are an OCR expert for Syrian documents.
### TASK
Analyse the **front side of a Syrian driving licence** and return **clean JSON only** with the following keys (no extra keys, no markdown):
{
"name_arabic": "", // الاسم الثلاثي أو الرباعي بالعربية
"birth_place": "", // المحافظة أو المنطقة المكتوبة بعد كلمة الولادة
"birth_year": "", // سنة الميلاد فقط (أربعة أرقام)
"national_number": "",
"civil_registry": "", // سطر "القيد" (مثال: سهوة 3)
"blood_type": "" // زمرة الدم بالشكل: A+ , A- , B+ , B- , AB+ , AB- , O+ , O-
}
### RULES
* إذا كانت القيمة مفقودة تمامًا اكتب **null**.
* لا تُغيّر ترتيب المفاتيح.
* لا تُرسل أى شرح أو أسطر إضافية JSON خالص فقط.
EOT,
"driving_license_back" => <<<EOT
You are an OCR expert for Syrian driving licences.
### TASK
Analyse the **back side** of a Syrian driving licence and return **raw JSON only** with exactly these keys:
{
"issue_date": "YYYY-MM-DD", // تاريخ المنح
"expiry_date": "YYYY-MM-DD", // صالحة لغاية
"license_number": "", // رقم الإجازة
"license_category": "" // D1, D2, D3 … (as printed after "UNIVERSAL DRIVING LICENCE")
}
### RULES
* If a value is totally absent, set it to **null**.
* Convert all dates to ISO `YYYY-MM-DD` (Gregorian).
* Do **NOT** add extra keys, comments, or markdown — return valid JSON only.
EOT,
"vehicle_license_front" => <<<EOT
You are an OCR expert specialized in analyzing Syrian vehicle registration cards (الرخصة البرتقالية).
Your task is to extract structured data from the **front side** of the Syrian orange vehicle card and return **raw JSON only** with the following exact fields:
{
"car_plate": "", // رقم المركبة الكامل مع اسم المحافظة، مأخوذ من الجهة اليسرى في السطر الأول (مثال: "155186 درعا")
"owner": "", // اسم المالك الكامل
"vin": "", // رقم الهيكل
"color": "", // اللون بالعربية أو الإنجليزية (مثال: "أبيض" أو "White")
"color_hex": "", // كود اللون بصيغة Hex (مثال: "#FFFFFF") أو #27332F إن تعذّر
"issue_date": "YYYY-MM-DD", // تاريخ المنح بصيغة ISO
"inspection_date": "YYYY-MM-DD" // تاريخ الفحص القادم بصيغة ISO
}
### Instructions & Rules:
1. Do **not** extract the "رمز المركبة" (on the right side of the first line) — use only the **left side** of the first line for `car_plate`.
2. Convert any Arabic dates (like `2024/05/13`) into ISO format `YYYY-MM-DD`.
3. If any value is missing or unreadable, return `null` for it.
4. Maintain Arabic encoding (e.g., owner name, city name, color).
5. Never guess — extract only what's visually found on the card.
6. Never include any explanation or extra output — return the JSON only.
Example of valid `car_plate`:
- "155186 درعا"
- "45291 دمشق"
- "122334 حمص"
EOT,
"vehicle_license_back" => <<<EOT
You are an OCR expert for Syrian vehicle registration cards (orange card).
### TASK
Analyse the **back side** of the card and return **raw JSON only** with exactly these keys (no more, no less):
{
"make": "", // الصانع (Hyundai …)
"model": "", // الطراز (H1 …)
"year": "", // سنة الصنع بالأرقام اللاتينية (e.g. "2019")
"fuel": "", // نوع الوقود (بنزين، ديزل …) أو بالإنجليزية (Petrol, Diesel,electric)
"chassis": "" // رقم الهيكل (VIN)
}
### RULES
* Convert any Eastern-Arabic digits (٠١٢٣٤٥٦٧٨٩) to Western digits (0-9).
* Normalise color names to standard English if possible, then map to a common Hex code
• "أبيض / White" → **#FFFFFF**
• "أسود / Black" → **#000000**
• "أحمر / Red" → **#FF0000**
• "أزرق / Blue" → **#0000FF**
• … (use the closest basic colour); if no match, set **color_hex = null**.
* If any field is unreadable or absent, set its value to **null**.
* Do **NOT** include extra keys, comments, or markdown — output valid JSON only.
EOT
];
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;
// If currently postponed and user sends a message, resume the flow
if ($step === 'postponed') {
// Cancel active reminder
$activeReminder = DriverReminder::findActive($companyId, $phone);
if ($activeReminder) {
DriverReminder::update($activeReminder['id'], ['status' => 'cancelled']);
}
// Restore previous step
$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);
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
if (!empty($apiKey)) {
$postponeData = $this->detectPostponement($text, $apiKey);
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;
// Schedule reminder
$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(
"أهلاً بك كابتن في خدمة تسجيل كباتن تطبيق انطلق 🚖.\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");
}
// 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");
}
return new FlowResult(
"شكراً لك كابتن، لقد تم استلام كافة المستندات والتحقق منها بنجاح. سيقوم فريق خدمة عملاء انطلق بمراجعة طلبك وتفعيل حسابك بأسرع وقت ممكن. يومك سعيد! 🚖",
"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);
}
$companyId = $context['company_id'] ?? 1;
$rule = ChatbotRule::findActiveForRule($companyId);
$apiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
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]) && !array_key_exists($requiredJsonKey, $ocrData))) {
error_log("[DriverRegistrationFlow] Missing required key '$requiredJsonKey' in OCR response: " . $rawOcr);
return new FlowResult($failMessage, $step);
}
// 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): ?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;
}
// 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;
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Models;
use App\Core\Database;
use App\Core\Security;
/**
* DriverOcrData Model
* Manages collected driver documents and OCR results with binary field encryption.
*/
class DriverOcrData extends BaseModel
{
protected static string $table = 'driver_ocr_data';
/**
* Find decrypted record for a company and contact phone
*/
public static function findByPhone(int $companyId, string $phone): ?array
{
self::ensureTableExists();
$hash = Security::blindIndex($phone);
$record = Database::selectOne(
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND (phone_hash = ? OR phone = ?) LIMIT 1",
[$companyId, $hash, $phone]
);
return self::decryptRecord($record);
}
/**
* Save or update driver registration OCR state securely
*/
public static function saveSecure(array $data): string
{
self::ensureTableExists();
$data = self::encryptRecord($data);
$existing = Database::selectOne(
"SELECT id FROM " . static::$table . " WHERE company_id = ? AND phone_hash = ? LIMIT 1",
[$data['company_id'], $data['phone_hash']]
);
if ($existing) {
self::update($existing['id'], $data);
return $existing['id'];
} else {
return self::create($data);
}
}
/**
* Helper to encrypt sensitive fields in a record (simplified to plain text)
*/
public static function encryptRecord(array $data): array
{
if (!empty($data['phone'])) {
$data['phone_hash'] = Security::blindIndex($data['phone']);
}
$ocrFields = [
'id_front_ocr', 'id_back_ocr',
'driving_license_front_ocr', 'driving_license_back_ocr',
'vehicle_license_front_ocr', 'vehicle_license_back_ocr'
];
foreach ($ocrFields as $field) {
if (isset($data[$field])) {
if (is_array($data[$field])) {
$jsonStr = json_encode($data[$field], JSON_UNESCAPED_UNICODE);
} else {
$jsonStr = $data[$field];
}
$data[$field] = $jsonStr ?: null;
}
}
return $data;
}
/**
* Helper to decrypt sensitive fields in a record (simplified to plain text)
*/
public static function decryptRecord(?array $record): ?array
{
if (!$record) return null;
$ocrFields = [
'id_front_ocr', 'id_back_ocr',
'driving_license_front_ocr', 'driving_license_back_ocr',
'vehicle_license_front_ocr', 'vehicle_license_back_ocr'
];
foreach ($ocrFields as $field) {
if (!empty($record[$field])) {
$record[$field] = json_decode($record[$field], true);
}
}
return $record;
}
/**
* Ensure the driver_ocr_data table exists dynamically
*/
public static function ensureTableExists(): void
{
static $checked = false;
if ($checked) return;
try {
Database::execute("
CREATE TABLE IF NOT EXISTS `driver_ocr_data` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`phone` VARCHAR(512) NOT NULL,
`phone_hash` VARCHAR(64) NOT NULL,
`name` VARCHAR(512) NOT NULL,
`id_front_url` VARCHAR(512) DEFAULT NULL,
`id_front_ocr` TEXT DEFAULT NULL,
`id_back_url` VARCHAR(512) DEFAULT NULL,
`id_back_ocr` TEXT DEFAULT NULL,
`driving_license_front_url` VARCHAR(512) DEFAULT NULL,
`driving_license_front_ocr` TEXT DEFAULT NULL,
`driving_license_back_url` VARCHAR(512) DEFAULT NULL,
`driving_license_back_ocr` TEXT DEFAULT NULL,
`vehicle_license_front_url` VARCHAR(512) DEFAULT NULL,
`vehicle_license_front_ocr` TEXT DEFAULT NULL,
`vehicle_license_back_url` VARCHAR(512) DEFAULT NULL,
`vehicle_license_back_ocr` TEXT DEFAULT NULL,
`criminal_record_url` VARCHAR(512) DEFAULT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `unique_company_driver_phone` (`company_id`, `phone_hash`),
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$checked = true;
} catch (\Exception $e) {
error_log("Failed to ensure driver_ocr_data table: " . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use App\Core\Database;
/**
* DriverReminder Model
* Manages driver registration reminders for follow-up notifications.
*/
class DriverReminder extends BaseModel
{
protected static string $table = 'driver_registration_reminders';
/**
* Find active reminder by company and contact phone
*/
public static function findActive(int $companyId, string $phone): ?array
{
self::ensureTableExists();
return Database::selectOne(
"SELECT * FROM " . static::$table . " WHERE company_id = ? AND phone = ? AND status = 'pending' LIMIT 1",
[$companyId, $phone]
);
}
/**
* Save or update driver reminder state
*/
public static function saveReminder(array $data): string
{
self::ensureTableExists();
$existing = Database::selectOne(
"SELECT id FROM " . static::$table . " WHERE company_id = ? AND phone = ? AND status = 'pending' LIMIT 1",
[$data['company_id'], $data['phone']]
);
if ($existing) {
self::update($existing['id'], $data);
return $existing['id'];
} else {
return self::create($data);
}
}
/**
* Ensure the driver_registration_reminders table exists dynamically
*/
public static function ensureTableExists(): void
{
static $checked = false;
if ($checked) return;
try {
Database::execute("
CREATE TABLE IF NOT EXISTS `driver_registration_reminders` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`phone` VARCHAR(50) NOT NULL,
`scheduled_at` DATETIME NOT NULL,
`postpone_count` INT DEFAULT 0,
`status` VARCHAR(20) DEFAULT 'pending',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$checked = true;
} catch (\Exception $e) {
error_log("Failed to ensure driver_registration_reminders table: " . $e->getMessage());
}
}
}

View File

@@ -155,6 +155,59 @@ class GeminiService
return $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
}
/**
* Transcribe incoming audio voice note to text using gemini-2.0-flash-lite
*/
public static function transcribeAudio(string $apiKey, string $audioBase64, string $mimeType): ?string
{
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=' . $apiKey;
// Clean mimeType if it contains codec info
if (strpos($mimeType, ';') !== false) {
$mimeType = trim(explode(';', $mimeType)[0]);
}
$payload = json_encode([
'contents' => [
[
'role' => 'user',
'parts' => [
[
'inlineData' => [
'mimeType' => $mimeType,
'data' => $audioBase64
]
],
[
'text' => "Transcribe the following audio message to Arabic text. Output only the transcription, no translation, no commentary, no markdown, and no code blocks."
]
]
]
]
]);
$ch = curl_init($url);
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'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[Gemini Audio Transcription Error] HTTP " . $httpCode . " | Response: " . $response);
return null;
}
$data = json_decode($response, true);
return trim($data['candidates'][0]['content']['parts'][0]['text'] ?? '');
}
/**
* Call Gemini API with image inline data and system instruction to generate a response text
*/
@@ -213,6 +266,64 @@ class GeminiService
return $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
}
/**
* Call Gemini API with image inline data and custom prompt to extract structured OCR data
*/
public static function generateOcrFromImage(string $apiKey, string $prompt, string $imageBase64, string $mimeType): ?string
{
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=' . $apiKey;
// Clean mimeType if it contains codec info
if (strpos($mimeType, ';') !== false) {
$mimeType = trim(explode(';', $mimeType)[0]);
}
$payload = json_encode([
'contents' => [
[
'role' => 'user',
'parts' => [
[
'text' => $prompt
],
[
'inlineData' => [
'mimeType' => $mimeType,
'data' => $imageBase64
]
]
]
]
]
]);
$ch = curl_init($url);
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'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 35); // 35 seconds timeout for image analysis
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("[Gemini OCR Image Response Error] HTTP " . $httpCode . " | Response: " . $response);
return null;
}
$data = json_decode($response, true);
$textRaw = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
if ($textRaw) {
// Clean markdown block if present
$textRaw = trim(preg_replace('/```json|```/', '', $textRaw));
}
return $textRaw;
}
/**
* Call ElevenLabs API to generate a native audio response from text
*/

View File

@@ -1073,6 +1073,7 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; gap: 0.5rem;">
<label class="form-label" x-text="chatbotSettings.trigger_type === 'gemini_ai' ? 'System Instruction Prompt' : 'Predefined Auto-Reply Message'" style="margin-bottom: 0;"></label>
<div x-show="chatbotSettings.trigger_type === 'gemini_ai'" style="display: flex; gap: 0.35rem; flex-wrap: wrap; align-items: center;">
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('intaleq')">قالب خدمة عملاء انطلق (سوري)</button>
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('nabeh')">قالب تطبيق نبيه (سوري)</button>
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('store')">قالب متجر إلكتروني</button>
<button type="button" class="btn btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem; width: auto;" @click="loadPromptTemplate('general')">قالب عام (إنجليزي)</button>
@@ -2006,7 +2007,22 @@
},
loadPromptTemplate(type) {
if (type === 'nabeh') {
if (type === 'intaleq') {
this.chatbotSettings.ai_prompt = `أنت روبوت خدمة العملاء الخاص بشركة "انطلق" (Intaleq). مهمتك هي مساعدة المستخدمين والإجابة على استفساراتهم حول خدماتنا بلهجة سورية ودودة ومهنية للغاية.
التزم بالقواعد والتفاصيل التالية بدقة عند الرد:
1. اسم الشركة: انطلق.
2. ساعات العمل: يومياً من الساعة 11 صباحاً حتى 5 مساءً.
3. طرق الدفع المتاحة: شام كاش، سيريتل، إم تي إن، وبطاقات البنك.
4. طريقة التسجيل: يمكن للمستخدمين تحميل التطبيق من متجر جوجل بلاي، آبل ستور، أو عبر رابط مباشر. عملية التسجيل سهلة وسريعة.
5. مميزات التطبيق:
- عمولة التطبيق هي 10% فقط (وهي الأقل في السوق).
- يوجد ذكاء اصطناعي متطور لتحليل المشاكل وحلها.
- ضمان حقوق السائق والركاب بشكل كامل.
- خدمة الطلب من أي مكان وبكل سهولة.
6. تحدث دائماً باللهجة السورية اللطيفة والترحيبية والودية عند التحدث باللغة العربية (مثال: "ياهلا بك"، "كيف بقدر أساعدك اليوم؟").
7. كوني مختصرة ومباشرة ومفيدة في إجاباتكِ وتجنبي الإطالة غير الضرورية.`;
} else if (type === 'nabeh') {
this.chatbotSettings.ai_prompt = `أنتِ "سارة"، موظفة خدمة العملاء الافتراضية الذكية والودودة لتطبيق "نبيه" (Nabeh).
مهمتكِ هي مساعدة المستخدمين والإجابة على استفساراتهم بلطف وأدب بالمسائل التقنية والتجارية المتعلقة بالتطبيق.

View File

@@ -148,5 +148,237 @@ $router->post('/api/external/verify-payment', function ($request, $response) {
});
// REST API for Entaleq Driver OCR Details (GET)
$router->get('/api/external/driver-ocr', function ($request, $response) {
$apiKey = getenv('ENTALEQ_API_KEY');
$incomingKey = $request->getHeader('x-api-key') ?? '';
if (empty($apiKey) || $incomingKey !== $apiKey) {
$response->status(401)->json([
'status' => 'error',
'message' => 'Unauthorized'
]);
return;
}
$phone = $request->get('phone') ?? '';
$companyId = $request->get('company_id') ?? '';
if (empty($phone)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Missing phone parameter'
]);
return;
}
\App\Models\DriverOcrData::ensureTableExists();
$hash = \App\Core\Security::blindIndex($phone);
if ($companyId) {
$records = \App\Core\Database::select(
"SELECT * FROM driver_ocr_data WHERE company_id = ? AND phone_hash = ?",
[$companyId, $hash]
);
} else {
$records = \App\Core\Database::select(
"SELECT * FROM driver_ocr_data WHERE phone_hash = ?",
[$hash]
);
}
if (empty($records)) {
$response->status(404)->json([
'status' => 'error',
'message' => 'No driver records found for this phone number'
]);
return;
}
$decryptedRecords = [];
$host = ($request->getHeader('x-forwarded-proto') ?: 'http') . '://' . ($request->getHeader('host') ?: 'localhost');
foreach ($records as $record) {
$decrypted = \App\Models\DriverOcrData::decryptRecord($record);
if ($decrypted) {
// Include absolute paths for all document URLs
$urlFields = [
'id_front_url', 'id_back_url',
'driving_license_front_url', 'driving_license_back_url',
'vehicle_license_front_url', 'vehicle_license_back_url',
'criminal_record_url'
];
foreach ($urlFields as $field) {
if (!empty($decrypted[$field])) {
$decrypted[$field . '_absolute'] = $host . $decrypted[$field];
} else {
$decrypted[$field . '_absolute'] = null;
}
}
$decryptedRecords[] = $decrypted;
}
}
$response->json([
'status' => 'success',
'data' => $decryptedRecords
]);
});
// REST API to Mark Driver Registered (POST)
$router->post('/api/external/register-driver', function ($request, $response) {
$apiKey = getenv('ENTALEQ_API_KEY');
$incomingKey = $request->getHeader('x-api-key') ?? '';
if (empty($apiKey) || $incomingKey !== $apiKey) {
$response->status(401)->json([
'status' => 'error',
'message' => 'Unauthorized'
]);
return;
}
$phone = $request->get('phone') ?? '';
$companyId = $request->get('company_id') ?? '';
if (empty($phone)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Missing phone parameter'
]);
return;
}
\App\Models\DriverOcrData::ensureTableExists();
$hash = \App\Core\Security::blindIndex($phone);
if ($companyId) {
$existing = \App\Core\Database::selectOne(
"SELECT id FROM driver_ocr_data WHERE company_id = ? AND phone_hash = ? LIMIT 1",
[$companyId, $hash]
);
} else {
$existing = \App\Core\Database::selectOne(
"SELECT id FROM driver_ocr_data WHERE phone_hash = ? LIMIT 1",
[$hash]
);
}
if (!$existing) {
$response->status(404)->json([
'status' => 'error',
'message' => 'Driver record not found'
]);
return;
}
\App\Core\Database::execute(
"UPDATE driver_ocr_data SET status = 'registered' WHERE id = ?",
[$existing['id']]
);
$response->json([
'status' => 'success',
'message' => 'Driver status successfully updated to registered'
]);
});
// Cron job endpoint to send pending driver registration reminders
$router->get('/api/external/cron/send-reminders', function ($request, $response) {
$apiKey = getenv('ENTALEQ_API_KEY');
$incomingKey = $request->getHeader('x-api-key') ?? '';
if (empty($apiKey) || $incomingKey !== $apiKey) {
$response->status(401)->json([
'status' => 'error',
'message' => 'Unauthorized'
]);
return;
}
\App\Models\DriverReminder::ensureTableExists();
$now = date('Y-m-d H:i:s');
$reminders = \App\Core\Database::select(
"SELECT * FROM driver_registration_reminders WHERE status = 'pending' AND scheduled_at <= ?",
[$now]
);
$processed = 0;
foreach ($reminders as $reminder) {
$companyId = $reminder['company_id'];
$phone = $reminder['phone'];
// Get company chatbot rule details
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$geminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
$elApiKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : getenv('ELEVENLABS_API_KEY');
$elVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : getenv('ELEVENLABS_VOICE_ID');
// Fetch company WhatsApp session
$session = \App\Models\WhatsAppSession::findByCompany($companyId);
if (!$session || $session['status'] !== 'connected') {
error_log("Cron Reminder: WhatsApp session not connected for company {$companyId}");
continue;
}
// Try to get driver name from active conversation flow state
$driverName = '';
$state = \App\Models\ConversationState::findActive($companyId, $phone);
if ($state) {
$ctx = json_decode($state['context_data'] ?: '{}', true);
$driverName = $ctx['name'] ?? '';
}
$nameStr = $driverName ? " كابتن " . $driverName : " كابتن";
$reminderMsg = "أهلاً بك{$nameStr}، حابين نذكرك تكمل خطوات تسجيلك لتنضم لعائلة انطلق 🚖. بقية الأوراق كتير مهمة لنفعل حسابك ونبدأ سوا. بانتظار إرسالها!";
// Generate Audio voice note if key is present
$audioData = null;
if (!empty($geminiKey)) {
$audioData = \App\Services\GeminiService::generateAudioResponse(
$geminiKey,
"أنت روبوت خدمة العملاء لشركة انطلق، تتحدث باللهجة السورية الودودة.",
$reminderMsg,
'Puck',
$elApiKey ?: null,
$elVoiceId ?: null
);
}
try {
// 1. Send the text reminder
\App\Core\Flows\ConversationFlowEngine::sendReply($session, $phone, $reminderMsg);
// 2. Send the voice note reminder if generated successfully
if ($audioData && !empty($audioData['audio'])) {
\App\Core\Flows\ConversationFlowEngine::sendReply(
$session,
$phone,
'',
null,
$audioData['audio'],
$audioData['mimeType'] ?? 'audio/mp4'
);
}
// 3. Mark the reminder as sent
\App\Models\DriverReminder::update($reminder['id'], [
'status' => 'sent'
]);
$processed++;
} catch (\Exception $ex) {
error_log("Failed to process reminder ID {$reminder['id']}: " . $ex->getMessage());
}
}
$response->json([
'status' => 'success',
'processed_count' => $processed,
'total_pending_due' => count($reminders)
]);
});
// 4. Dispatch the request
$router->dispatch($request, $response);

View File

@@ -0,0 +1,139 @@
<?php
/**
* Driver Registration & Postponement Flow Simulation Script
* Run this on the server: php backend/public/test_simulation.php
*/
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
use App\Models\ConversationState;
use App\Models\DriverReminder;
use App\Core\Flows\ConversationFlowEngine;
use App\Models\WhatsAppSession;
echo "=== Starting Driver Registration & Reminder Flow Simulation ===\n\n";
$companyId = 1;
$phone = "963999999999"; // Test Phone Number
// Setup mock session
$session = WhatsAppSession::findOrCreate($companyId);
// Ensure session is marked connected for simulation to go through sendReply
WhatsAppSession::updateState($session['id'], [
'phone' => $phone,
'status' => 'connected'
]);
// Refresh session data
$session = WhatsAppSession::findByCompany($companyId);
echo "1. Cleaning up existing test states/reminders...\n";
Database::execute("DELETE FROM conversation_states WHERE contact_phone = ?", [$phone]);
Database::execute("DELETE FROM driver_registration_reminders WHERE phone = ?", [$phone]);
Database::execute("DELETE FROM driver_ocr_data WHERE phone = ?", [$phone]);
echo "2. Initializing conversation to 'id_front' state...\n";
ConversationState::saveState([
'company_id' => $companyId,
'contact_phone' => $phone,
'flow_name' => 'driver_registration_flow',
'current_step' => 'id_front',
'context_data' => json_encode(['name' => 'جميل الشام'], JSON_UNESCAPED_UNICODE),
'expires_at' => date('Y-m-d H:i:s', strtotime('+1 hour'))
]);
echo "3. Simulating user postponement request: 'بكرة المسا ببعتلك الهوية الشخصية'...\n";
$msgData = [
'phone' => $phone,
'body' => 'بكرة المسا ببعتلك الهوية الشخصية'
];
$handled = ConversationFlowEngine::processMessage($session, $msgData);
echo " Message processed by flow engine: " . ($handled ? "YES" : "NO") . "\n";
// Fetch the new state and scheduled reminders
$state = ConversationState::findActive($companyId, $phone);
echo " New Flow Step: " . ($state ? $state['current_step'] : 'None (finished)') . "\n";
echo " Context: " . ($state ? $state['context_data'] : 'None') . "\n";
$reminders = Database::select("SELECT * FROM driver_registration_reminders WHERE phone = ?", [$phone]);
echo " Scheduled Reminders Count: " . count($reminders) . "\n";
if (!empty($reminders)) {
foreach ($reminders as $r) {
echo " - ID: {$r['id']}, Scheduled At: {$r['scheduled_at']}, Postpone Count: {$r['postpone_count']}, Status: {$r['status']}\n";
}
}
echo "\n4. Testing Cron Scheduler (Simulate reminder execution by setting scheduled_at to the past)...\n";
if (!empty($reminders)) {
Database::execute("UPDATE driver_registration_reminders SET scheduled_at = ? WHERE id = ?", [
date('Y-m-d H:i:s', strtotime('-1 minute')),
$reminders[0]['id']
]);
echo " Triggering cron process via internal endpoint request simulation...\n";
// We call the main cron logic directly by querying and executing the reminder
$dueReminders = Database::select(
"SELECT * FROM driver_registration_reminders WHERE status = 'pending' AND scheduled_at <= ?",
[date('Y-m-d H:i:s')]
);
echo " Due Reminders found: " . count($dueReminders) . "\n";
foreach ($dueReminders as $reminder) {
$rule = \App\Models\ChatbotRule::findActiveForRule($companyId);
$geminiKey = ($rule && !empty($rule['gemini_api_key'])) ? $rule['gemini_api_key'] : getenv('GEMINI_API_KEY');
$elApiKey = ($rule && !empty($rule['elevenlabs_api_key'])) ? $rule['elevenlabs_api_key'] : getenv('ELEVENLABS_API_KEY');
$elVoiceId = ($rule && !empty($rule['elevenlabs_voice_id'])) ? $rule['elevenlabs_voice_id'] : getenv('ELEVENLABS_VOICE_ID');
$driverName = 'جميل الشام';
$nameStr = $driverName ? " كابتن " . $driverName : " كابتن";
$reminderMsg = "أهلاً بك{$nameStr}، حابين نذكرك تكمل خطوات تسجيلك لتنضم لعائلة انطلق 🚖. بقية الأوراق كتير مهمة لنفعل حسابك ونبدأ سوا. بانتظار إرسالها!";
echo " Generating reminder Audio via TTS...\n";
$audioData = null;
if (!empty($geminiKey)) {
$audioData = \App\Services\GeminiService::generateAudioResponse(
$geminiKey,
"أنت روبوت خدمة العملاء لشركة انطلق، تتحدث باللهجة السورية الودودة.",
$reminderMsg,
'Puck',
$elApiKey ?: null,
$elVoiceId ?: null
);
}
echo " Sending outbound simulated message...\n";
ConversationFlowEngine::sendReply($session, $phone, $reminderMsg);
if ($audioData && !empty($audioData['audio'])) {
echo " Audio voice note generated successfully (Length: " . strlen($audioData['audio']) . " bytes). Sending...\n";
ConversationFlowEngine::sendReply(
$session,
$phone,
'',
null,
$audioData['audio'],
$audioData['mimeType'] ?? 'audio/mp4'
);
} else {
echo " Audio voice note generation skipped or failed (Gemini Key may be missing or empty).\n";
}
DriverReminder::update($reminder['id'], ['status' => 'sent']);
echo " Reminder ID {$reminder['id']} status set to 'sent'.\n";
}
}
echo "\n5. Simulating user resuming registration (sending message 'هلا يا كابتن')...\n";
$msgData = [
'phone' => $phone,
'body' => 'هلا يا كابتن'
];
$handled = ConversationFlowEngine::processMessage($session, $msgData);
echo " Resumed Message handled: " . ($handled ? "YES" : "NO") . "\n";
$state = ConversationState::findActive($companyId, $phone);
echo " Resumed Flow Step: " . ($state ? $state['current_step'] : 'None') . "\n";
echo "\n=== Simulation Finished ===\n";

View File

@@ -1,298 +1,78 @@
# خطة تنفيذ المعمارية الخلفية ونظام النشر المؤتمت — تطبيق نبيه (Nabeh)
# Implementation Plan - Intaleq Customer Service & Driver Registration Flow
<div dir="rtl" align="right">
This plan details the implementation of the Intaleq customer service prompt template, the multi-stage WhatsApp driver registration flow using Gemini OCR, and the authenticated REST API endpoints for Nabeh's backend.
توضح هذه الخطة التفاصيل الهيكلية والخطوات العملية لبناء النواة البرمجية للخلفية (Backend Base Architecture) باستخدام **Pure PHP OOP** وبناء نظام نشر تلقائي آمن عبر **Git/SSH** إلى السيرفر.
---
## أولاً: تقسيم المشروع إلى مراحل (Milestones)
من أجل ضمان الانتقال السلس وبناء أساسات متينة وقابلة للتطوير، سنقسم العمل على الخلفية والنشر إلى المراحل التالية:
### المرحلة 1: بناء نواة الـ MVC وتهيئة الهيكل البرمجي
- بناء هيكل المجلدات المنظم وفصل الملفات العامة (Public) عن الكود البرمجي الأساسي.
- إعداد نظام التحميل التلقائي للملفات (Autoloader) المتوافق مع معيار PSR-4.
- برمجة موجه الطلبات (Router) المتوافق مع خادم Nginx.
- برمجة قارئ ملفات البيئة المتطور والآمن (`.env` Reader) مع تخزينه خارج المسار العام لضمان أعلى مستويات الحماية.
### المرحلة 2: تصميم قاعدة البيانات ونواة الاتصال (Database & Core)
- تصميم ملف تهيئة الاتصال بقاعدة البيانات باستخدام PDO.
- بناء الموديل الرئيسي (Base Model) والمتحكم الرئيسي (Base Controller) لإدارة المدخلات والمخرجات (JSON Responses).
- بناء مخطط الجداول الأساسية لقاعدة البيانات (الهيكل متعدد المستأجرين - Multi-Tenant Prep).
### المرحلة 3: نظام النشر التلقائي للسيرفر (`deploy.sh`)
- كتابة سكربت النشر الآمن لرفع التحديثات لـ Git وعمل Pull تلقائي وإعادة بناء التهيئات على السيرفر.
- إعداد البنية التحتية للمجلدات على السيرفر وربط الـ Subdomain.
---
## ثانياً: معمارية مجلدات الخلفية المقترحة (Directory Structure)
سنقوم بتنظيم كود الخلفية داخل مجلد `backend/` لحماية كود التطبيق وملف البيانات الحساسة (`.env`) من الوصول المباشر من المتصفح، حيث سيكون مجلد `public/` هو المجلد الوحيد المتاح للعامة والمربوط بالـ Document Root في Nginx.
الهيكل المستهدف للمجلدات هو كالتالي:
</div>
```
nabeh/
├── backend/
│ ├── app/
│ │ ├── Controllers/ # متحكمات معالجة الطلبات
│ │ │ ├── BaseController.php
│ │ │ └── AuthController.php
│ │ ├── Models/ # موديلات قواعد البيانات
│ │ │ ├── BaseModel.php
│ │ │ └── Tenant.php
│ │ ├── Core/ # نواة التطبيق البرمجية
│ │ │ ├── Router.php
│ │ │ ├── Database.php
│ │ │ ├── Request.php
│ │ │ ├── Response.php
│ │ │ └── Env.php
│ │ └── Middlewares/ # فلاتر التحقق والوساطة
│ │ └── AuthMiddleware.php
│ ├── config/ # ملفات الإعدادات العامة
│ │ └── app.php
│ ├── public/ # المجلد الوحيد المتاح للمتصفح
│ │ └── index.php # نقطة الدخول الموحدة (Front Controller)
│ ├── .env # ملف البيئة الحساس (مخفي تماماً)
│ ├── .env.example
│ └── composer.json # لإدارة التحميل التلقائي PSR-4 وحزم التطبيق
```
<div dir="rtl" align="right">
---
## ثالثاً: تصميم النواة البرمجية (Core Architecture)
### 1. نقطة الدخول الموحدة `backend/public/index.php`
يقوم هذا الملف بتهيئة بيئة العمل، تحميل الملفات تلقائياً، قراءة متغيرات البيئة، ثم تمرير الطلب إلى الـ Router.
### 2. قارئ البيئة الآمن `backend/app/Core/Env.php`
يقرأ ملف `.env` المتواجد خارج المجلد العام ويقوم بتحميل المتغيرات داخل `$_ENV` و `getenv()` بأمان تام.
### 3. نظام توجيه الطلبات `backend/app/Core/Router.php`
نظام توجيه خفيف وقوي يدعم تعبيرات Regex، ويتيح تعريف مسارات من نوع GET و POST و PUT و DELETE وتمرير البارامترات للمتحكمات (Controllers) وتطبيق فلاتر الوساطة (Middlewares).
---
## رابعاً: نظام النشر المؤتمت (`deploy.sh`)
سنقوم بإنشاء سكربت النشر الذكي في الجذر الرئيسي للمشروع لتبسيط عملية الدفع والتحديث. سيقوم السكربت بالخطوات التالية بشكل تفاعلي وآمن:
1. التأكد من كتابة تعليق الالتزام (Commit Message).
2. إضافة كافة التغييرات محلياً والدفع إلى الفرع الرئيسي على GitHub/GitLab.
3. الاتصال بالسيرفر عبر بروتوكول SSH بشكل آمن.
4. التوجه إلى مجلد المشروع على السيرفر وسحب الكود البرمجي المحدث (`git pull origin main`).
5. تطبيق أي أوامر تهيئة مثل تثبيت حزم الملحقات (`composer install`) أو تنظيف الكاش.
محتوى سكربت `deploy.sh` المقترح:
</div>
```bash
#!/bin/bash
# ==========================================
# سكريبت النشر الآلي وتحديث السيرفر - تطبيق نبيه
# ==========================================
# 1. إعدادات السيرفر والمتغيرات الأساسية
SERVER_USER="hamza" # اسم مستخدم السيرفر
SERVER_IP="your-server-ip" # عنوان السيرفر
SERVER_PATH="/var/www/nabeh" # مسار المشروع على السيرفر
GIT_BRANCH="main" # الفرع الرئيسي للنشر
echo "=========================================="
echo "🚀 بدء عملية النشر والتحديث لتطبيق (نبيه)..."
echo "=========================================="
# 2. التأكد من حالة التغييرات المحلية
if [ -z "$(git status --porcelain)" ]; then
echo " لا توجد تغييرات برمجية غير محفوظة للنشر."
else
# طلب وصف للتحديثات (Commit Message)
echo "✍️ الرجاء إدخال رسالة الالتزام (Commit Message):"
read commit_msg
if [ -z "$commit_msg" ]; then
commit_msg="Update: Automatic deployment via deploy.sh"
fi
# الإضافة والالتزام البرمجي
git add .
git commit -m "$commit_msg"
fi
# 3. الدفع إلى Git Remote
echo "📤 دفع التحديثات إلى المستودع البعيد (Git Push)..."
git push origin $GIT_BRANCH
if [ $? -ne 0 ]; then
echo "❌ فشلت عملية الدفع البرمجي (Git Push). تم إلغاء النشر."
exit 1
fi
echo "✅ تم رفع الكود بنجاح إلى المستودع البرمجي."
# 4. الاتصال بالسيرفر وتحديث الكود (Git Pull)
echo "🌐 الاتصال بالسيرفر وسحب الكود المحدث (Git Pull)..."
ssh ${SERVER_USER}@${SERVER_IP} << EOF
cd ${SERVER_PATH}
echo "📁 الانتقال إلى مجلد المشروع: ${SERVER_PATH}"
echo "📥 سحب التحديثات من الفرع ${GIT_BRANCH}..."
git pull origin ${GIT_BRANCH}
if [ -f "backend/composer.json" ]; then
echo "📦 تحديث الحزم البرمجية والتحميل التلقائي (Composer)..."
cd backend
composer install --no-dev --optimize-autoloader
cd ..
fi
echo "🎉 تم تحديث السيرفر وتثبيت التحديثات بنجاح!"
EOF
echo "=========================================="
echo "✨ تمت عملية النشر والتحديث بالكامل بنجاح!"
echo "=========================================="
```
<div dir="rtl" align="right">
---
## خامساً: خطة إعداد الخادم والـ Subdomain
لتشغيل نظام الـ Front Controller والمسارات الديناميكية بشكل متوافق مع **Nginx** وبدون الحاجة لملفات `.htaccess` الخاصة بـ Apache، يجب إعداد ملف تهيئة الـ Subdomain (مثال: `api.nabeh.sa` أو `app.nabeh.sa`) ليوجه الطلبات إلى المجلد العام `backend/public/`:
إعداد Nginx المقترح للمخدم:
</div>
```nginx
server {
listen 80;
listen [::]:80;
server_name api.nabeh.sa; # النطاق الفرعي للتطبيق
# تحديد مسار المجلد العام فقط
root /var/www/nabeh/backend/public;
index index.php index.html;
charset utf-8;
# توجيه كافة الطلبات إلى index.php لدعم الـ Router
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
# تشغيل ملفات PHP وتكاملها مع PHP-FPM
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; # حسب إصدار PHP المثبت
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
# منع الوصول تماماً لأي ملفات حساسة أو مخفية
location ~ /\.(?!well-known).* {
deny all;
}
}
```
<div dir="rtl" align="right">
---
## سادساً: خطة التحقق والتدقيق (Verification Plan)
### التحقق الذاتي والمحلي:
1. **التحقق من المسارات (Router Testing)**: إنشاء طلبات محلية للتحقق من تشغيل المسارات المختلفة (GET/POST) وإرجاع بيانات JSON صحيحة.
2. **التحقق من حماية متغيرات البيئة (`.env` Security)**: محاولة استعراض ملف `.env` عبر المسار المباشر للتأكد من حظر الوصول العام إليه، وقراءته بنجاح من داخل تطبيق PHP.
3. **فحص التحميل التلقائي**: التحقق من استدعاء كلاسات الـ Controllers والموديلات دون الحاجة لكتابة `require_once` يدوياً لكل ملف.
---
## المرحلة الرابعة: نظام التوثيق والمصادقة (Authentication Phase)
بناءً على النواة الأمنية التي تم تجهيزها (JWT, AES-256-GCM, Bcrypt, HMAC Blind Index)، سنقوم ببناء نظام المصادقة ليكون جاهزاً لإدارة جلسات المستخدمين.
### Proposed Changes
#### [NEW] `backend/app/Models/User.php`
- كلاس يمتد من `BaseModel` لإدارة جدول `users`.
- يحتوي على دالة `findByEmail` التي تقوم بتوليد (Blind Index HMAC) للبريد الإلكتروني للبحث السريع في قاعدة البيانات دون فك التشفير.
- دالة `createSecure` لإنشاء مستخدم جديد مع تشفير بريده الإلكتروني وكلمة مروره.
#### [NEW] `backend/app/Controllers/AuthController.php`
- دالة `login`: تستقبل البريد الإلكتروني وكلمة المرور، تتحقق منها عبر الموديل، وتولد JWT Token.
- دالة `register` (اختيارية كأداة تطوير مبدئية أو للأدمن): إنشاء حساب موظف جديد.
- دالة `me`: جلب بيانات المستخدم الحالي باستخدام الـ `AuthMiddleware` لاختبار سلامة الجلسة.
#### [MODIFY] `backend/public/index.php`
- إضافة المسارات الخاصة بنظام التوثيق:
- `POST /api/auth/login`
- `GET /api/auth/me` (مرفق بـ `AuthMiddleware`)
### User Review Required
## User Review Required
> [!IMPORTANT]
> - هل ترغب بإنشاء مسار `POST /api/auth/register` مفتوح للجميع لإنشاء حسابات شركات جديدة؟ أم نكتفي حالياً بإنشاء مستخدم مدير (Admin) افتراضي يدوياً أو عبر سكربت ليكون النظام مغلقاً للشركات المعتمدة فقط؟
> - في عملية تسجيل الدخول، هل نحتاج لإرجاع بيانات الشركة المرتبطة بالمستخدم ضمن نفس الـ Response، أم نكتفي بإرجاع التوكن (Token) وبيانات المستخدم الأساسية فقط؟
> - **API Key Configuration**: We will authenticate the REST endpoints using a shared `ENTALEQ_API_KEY` defined in the `.env` file. Please ensure this environment variable is populated on your environment before calling these endpoints.
> - **Direct Upload Path**: Decoded WhatsApp images will be saved under the public directory `backend/public/uploads/documents/` to ensure they are accessible via public URL paths.
> - **Prompt Dialect**: The custom service prompt is written in a professional, warm Syrian dialect (اللهجة السورية) adhering to all business rules.
---
## المرحلة الخامسة: معالجة وإصلاح الثغرات الأمنية (Security Audit Remediation)
## Proposed Changes
بناءً على التقرير الأمني الصادر، سنقوم بتطبيق التعديلات البرمجية لرفع مستوى أمان الخادم وحماية البيانات.
### Database Schema Component
### Proposed Changes
#### [NEW] [DriverOcrData.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Models/DriverOcrData.php)
- Class extending `BaseModel` that manages the `driver_ocr_data` table.
- Dynamically creates the table via `ensureTableExists()`.
- Implements `saveSecure` to encrypt sensitive fields (`phone`, `name`, `password`, and all JSON OCR strings) using AES-256-GCM (`Security::encrypt()`) and generate search hashes (`Security::blindIndex()`).
- Implements `findByPhone` to retrieve and decrypt records.
#### [MODIFY] [Security.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Security.php)
- إرجاع خطأ (Exception) فوراً في حال عدم وجود متغيرات البيئة (`ENCRYPTION_KEY`, `HMAC_SALT`, `JWT_SECRET`) لمنع تشفير البيانات بمفاتيح فارغة.
---
#### [MODIFY] [BaseModel.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Models/BaseModel.php)
- تنظيف وفلترة أسماء الأعمدة ديناميكياً باستخدام مصفوفة بيضاء (Whitelist Regex) لمنع ثغرات الـ SQL Injection عبر أسماء الأعمدة في دالتي `create()` و `update()`.
### Gemini Service Component
#### [MODIFY] [SecurityMiddleware.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Middlewares/SecurityMiddleware.php)
- إلغاء تطبيق `htmlspecialchars` و `strip_tags` على المدخلات الخام القادمة للخلفية لمنع تلف كلمات المرور والرموز الخاصة، وتأجيل التنظيف ليكون حصراً عند الطباعة/العرض (Output Encoding).
#### [MODIFY] [GeminiService.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Services/GeminiService.php)
- Add the `generateOcrFromImage` static method to support custom structured document analysis. It forwards custom prompts (e.g. green card OCR instructions) and base64 inline images to the `gemini-2.0-flash-lite` model.
#### [MODIFY] [AuthController.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Controllers/AuthController.php)
- إزالة التنظيف المزدوج (Double Encoding) لأسماء المستخدمين والشركات.
---
#### [MODIFY] [Router.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Router.php)
- إخفاء مسار الملفات الحقيقي من رسائل الخطأ 404 و 500 وإظهار رسائل عامة لمنع تسريب بنية المجلدات (Information Disclosure).
### Dashboard Component
#### [MODIFY] [Response.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Response.php)
- جعل رابط الـ CORS ديناميكياً يعتمد على متغير بيئة `ALLOWED_ORIGIN` بدلاً من فتح النطاق للجميع عبر `*`.
#### [MODIFY] [index.html](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/public/index.html)
- Add the new prompt template button: `<button type="button" class="btn btn-secondary" @click="loadPromptTemplate('intaleq')">قالب خدمة عملاء انطلق (سوري)</button>`.
- Add `intaleq` template case inside `loadPromptTemplate(type)` to load the full customer service system instruction prompt.
#### [MODIFY] [bootstrap.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/bootstrap.php)
- تسجيل معالج أخطاء عام (`set_exception_handler` / `set_error_handler`) لتحويل الأخطاء الفادحة غير المعالجة إلى استجابات JSON نظيفة ومخفية في الإنتاج، مع تسجيل التفاصيل التقنية في الـ `error_log` فقط.
---
#### [MODIFY] [Request.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Request.php)
- تعريف الخصائص المعرفية (`$user_id`, `$company_id`, `$role`) بشكل صريح لتفادي مشاكل الخصائص الديناميكية (Dynamic Properties Deprecation) في إصدارات PHP 8.2+.
### Conversation Flow Component
#### [NEW] [RateLimitMiddleware.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Middlewares/RateLimitMiddleware.php)
- بناء فلتر مخصص للحد من معدل الطلبات (Rate Limiting) على مسارات تسجيل الدخول والتسجيل لحماية التطبيق من محاولات التخمين العنيف (Brute Force).
#### [NEW] [DriverRegistrationFlow.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Flows/DriverRegistrationFlow.php)
- Implement a step-by-step driver registration state machine.
- Steps:
1. `start`: Welcome message, request full name.
2. `ask_name`: Save name, request password.
3. `ask_password`: Validate password, request ID Front image.
4. `id_front`: Run `id_front_sy` Gemini OCR prompt. Request ID Back.
5. `id_back`: Run `id_back_sy` Gemini OCR prompt. Request Driving License Front.
6. `driving_license_front`: Run `driving_license_sy_front` Gemini OCR prompt. Request Driving License Back.
7. `driving_license_back`: Run `driving_license_sy_back` Gemini OCR prompt. Request Vehicle License Front.
8. `vehicle_license_front`: Run `vehicle_license_sy_front` Gemini OCR prompt. Request Vehicle License Back.
9. `vehicle_license_back`: Run `vehicle_license_sy_back` Gemini OCR prompt. Request Criminal Record (لا حكم عليه).
10. `criminal_record`: Save document, update status to `ocr_completed`, save to `driver_ocr_data` table, and conclude.
#### [MODIFY] [ConversationFlowEngine.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/app/Core/Flows/ConversationFlowEngine.php)
- Add trigger mappings: `'تسجيل' => 'driver_registration_flow'`, `'سجل' => 'driver_registration_flow'`, and `'register' => 'driver_registration_flow'`.
- Register the flow class: `'driver_registration_flow' => DriverRegistrationFlow::class`.
---
### REST API Component
#### [MODIFY] [index.php](file:///Users/hamzaaleghwairyeen/development/App/nabeh/backend/public/index.php)
- تفعيل فلتر الحد من معدل الطلبات على مسارات الحماية.
- Define 2 REST API endpoints:
- `GET /api/external/driver-ocr`: Accepts a query parameter `phone`. Authenticates against `ENTALEQ_API_KEY`. Retrieves and decrypts driver documents, OCR logs, and password details.
- `POST /api/external/register-driver`: Accepts a `phone` body parameter. Authenticates against `ENTALEQ_API_KEY`. Marks the local record status as `registered` (confirming transfer to primary DB).
### User Review Required
---
> [!IMPORTANT]
> هل نعتمد نظام تخزين المحاولات للحد من معدل الطلبات (Rate Limiting) في ملفات مؤقتة داخل مجلد `storage/` محلي (سهل الإعداد ومستقل)، أم نستخدم جدولاً مخصصاً في قاعدة البيانات؟
</div>
## Verification Plan
### Manual Verification
1. Open the dashboard, click **قالب خدمة عملاء انطلق (سوري)**, and verify the prompt loads.
2. Send a WhatsApp message to the bot saying `"تسجيل"` and follow the registration wizard. Send test images for each stage, verifying database storage and Gemini OCR parsing logs.
3. Query the `GET /api/external/driver-ocr` endpoint using `curl` with the header `X-API-Key: your_key` and confirm decrypted data is returned.
4. Execute `POST /api/external/register-driver` with the API key header and confirm the record status updates to `registered`.

View File

@@ -0,0 +1,237 @@
<?php
// --- 1. Dependencias y Conexión ---
require_once __DIR__ . '/../connect.php';
// دالة مساعدة لتسجيل الخطوات في ملف الـ LOG
function logStep($step, $message) {
error_log("[DriverReg] Step $step: $message");
}
try {
// --- بدء المعاملة ---
$con->beginTransaction();
logStep(1, "Transaction started via beginTransaction()");
// --- 2. Recolección de Datos (Conductor + Coche) ---
$phone = filterRequest("phone");
$password = filterRequest("password");
$firstName = filterRequest("first_name");
$lastName = filterRequest("last_name");
// تسجيل البيانات المبدئية (بدون كلمات المرور) للتأكد من وصولها
logStep(2, "Inputs received -> Phone: $phone, Name: $firstName $lastName");
// التحقق من الحقول الإجبارية
if (empty($phone) || empty($password) || empty($firstName) || empty($lastName)) {
throw new Exception("Required fields missing (phone, password, first_name, last_name).");
}
// --- 3. Generar ID de Conductor ---
$driverId = substr(md5($phone), 0, 20);
logStep(3, "Driver ID generated: $driverId");
// --- 4. Procesamiento de Datos del Conductor ---
$password_hashed = password_hash($password, PASSWORD_DEFAULT);
$email = filterRequest("email");
if (empty($email) || $email === 'Not specified') {
$email = $phone . '@intaleqapp.com';
}
$nameArabic = $firstName . ' ' . $lastName;
$site = filterRequest("site");
$address = $site;
// بيانات إضافية
$gender = filterRequest("gender");
$license_type = filterRequest("license_type");
$nationalNumber = filterRequest("national_number");
$issue_date = filterRequest("issue_date");
$expiry_date = filterRequest("expiry_date");
$licenseCategories = filterRequest("license_categories");
$licenseIssueDate = filterRequest("license_issue_date");
$birthdate = filterRequest("birthdate");
$maritalStatus = filterRequest("maritalStatus");
// --- 5. Recolección de Datos del Coche ---
$owner = filterRequest("owner");
$color = filterRequest("color");
$colorHex = filterRequest("color_hex");
$model = filterRequest("model");
$carPlate = filterRequest("car_plate");
$make = filterRequest("make");
$fuel = filterRequest("fuel");
$year = filterRequest("year");
$vin = filterRequest("vin");
if (empty($vin)) {
$vin = 'unknown';
}
$carExpirationDate = filterRequest("expiration_date");
logStep(4, "Data processing completed. Car Plate: $carPlate, VIN: $vin");
// --- 6. Cifrado de Datos ---
try {
$encryptedPhone = $encryptionHelper->encryptData($phone);
$encryptedEmail = $encryptionHelper->encryptData($email);
$encryptedFirstName = $encryptionHelper->encryptData($firstName);
$encryptedLastName = $encryptionHelper->encryptData($lastName);
$encryptedNameArabic = $encryptionHelper->encryptData($nameArabic);
$encryptedGender = $encryptionHelper->encryptData($gender);
$encryptedNationalNumber = $encryptionHelper->encryptData($nationalNumber);
$encryptedAddress = $encryptionHelper->encryptData($address);
$encryptedSite = $encryptionHelper->encryptData($site);
$encryptedBirthdate = $encryptionHelper->encryptData($birthdate);
$encryptedOwner = $encryptionHelper->encryptData($owner);
$encryptedCarPlate = $encryptionHelper->encryptData($carPlate);
logStep(5, "Encryption successful for sensitive fields.");
} catch (Exception $encEx) {
throw new Exception("Encryption Error: " . $encEx->getMessage());
}
// --- 7. Comprobación de Duplicados ---
// ملاحظة: إذا كان التشفير عشوائياً، فلن يجد التكرار هنا.
$dup = $con->prepare("SELECT id FROM driver WHERE phone = :phone OR email = :email OR national_number = :national_number");
$dup->execute([':phone' => $encryptedPhone, ':email' => $encryptedEmail, ':national_number' =>$encryptedNationalNumber]);
if ($dup->rowCount() > 0) {
logStep(6, "Duplicate found! Phone or Email or encryptedNationalNumber already exists.");
throw new Exception("Phone or email already registered.");
}
logStep(6, "No duplicates found. Proceeding.");
// --- 8. INSERCIÓN 1: Tabla 'driver' ---
$sqlDriver = "
INSERT INTO driver (
id, phone, email, password, gender, license_type, national_number,
name_arabic, issue_date, expiry_date, license_categories,
address, licenseIssueDate, status, birthdate, site,
first_name, last_name, accountBank, bankCode,
employmentType, maritalStatus, fullNameMaritial, expirationDate,
created_at, updated_at
) VALUES (
:id, :phone, :email, :pwd, :gender, :license_type, :national_number,
:name_arabic, :issue_date, :expiry_date, :license_categories,
:address, :licenseIssueDate, :status, :birthdate, :site,
:first_name, :last_name, :accountBank, :bankCode,
:employmentType, :maritalStatus, :fullNameMaritial, :expirationDate,
NOW(), NOW()
)
";
$stmtDriver = $con->prepare($sqlDriver);
// تم توحيد المفاتيح لتشمل النقطتين (:)
$driverData = [
':id' => $driverId,
':phone' => $encryptedPhone,
':email' => $encryptedEmail,
':pwd' => $password_hashed,
':gender' => $encryptedGender,
':license_type' => $license_type,
':national_number' => $encryptedNationalNumber,
':name_arabic' => $encryptedNameArabic,
':issue_date' => $issue_date,
':expiry_date' => $expiry_date,
':license_categories' => $licenseCategories ?? 'B',
':address' => $encryptedAddress,
':licenseIssueDate' => $licenseIssueDate,
':status' => 'actives',
':birthdate' => $encryptedBirthdate,
':site' => $encryptedSite,
':first_name' => $encryptedFirstName,
':last_name' => $encryptedLastName,
':accountBank' => 'yet',
':bankCode' => 'yet',
':employmentType' => $maritalStatus ?? 'yet',
':maritalStatus' => $maritalStatus ?? 'yet',
':fullNameMaritial' => 'yet',
':expirationDate' => 'yet',
];
if (!$stmtDriver->execute($driverData)) {
// تسجيل خطأ SQL بالتفصيل
$errInfo = $stmtDriver->errorInfo();
throw new Exception("Driver Insert Failed: " . $errInfo[2]);
}
logStep(7, "Driver table insert successful.");
// --- 9. INSERCIÓN 2: Tabla 'CarRegistration' ---
$sqlCar = "
INSERT INTO CarRegistration (
driverID, vin, owner, color, color_hex, model, car_plate,
make, fuel, `year`, expiration_date, created_at
) VALUES (
:driverId, :vin, :owner, :color, :color_hex, :model, :car_plate,
:make, :fuel, :year, :expiration_date, NOW()
)
";
$stmtCar = $con->prepare($sqlCar);
$carData = [
':driverId' => $driverId,
':vin' => $vin,
':owner' => $encryptedOwner,
':color' => $color,
':color_hex' => $colorHex,
':model' => $model,
':car_plate' => $encryptedCarPlate,
':make' => $make,
':fuel' => $fuel,
':year' => $year,
':expiration_date' => $carExpirationDate
];
if (!$stmtCar->execute($carData)) {
$errInfo = $stmtCar->errorInfo();
throw new Exception("Car Insert Failed: " . $errInfo[2]);
}
logStep(8, "CarRegistration insert successful.");
// --- 10. Confirmar Transacción ---
$con->commit();
logStep(9, "COMMIT successful. Sending Success Response.");
jsonSuccess(["driverId" => $driverId, "message" => "Driver and car registered successfully."]);
// --- 11. Enviar Notificación (خارج المعاملة يفضل، ولكن هنا كما في الكود الأصلي) ---
try {
$supportPhones = ['0952475740', '0952475742'];
$randomIndex = array_rand($supportPhones);
$phoneToUse = $supportPhones[$randomIndex];
$randomNumber = rand(1000, 999999);
$messageBody = "أهلاً وسهلاً كابتن $firstName 👋\n"
. "تم تفعيل حسابك على تطبيق *انطلق*.\n"
. "يمكنك الآن تسجيل الدخول والبدء بالعمل مباشرة.\n"
. "للمساعدة تواصل معنا على الرقم: $phoneToUse\n"
. "نتمنى لك عمل موفق 🚖\n\n"
. "معرف الرسالة: $randomNumber";
sendWhatsAppFromServer($phone, $messageBody);
logStep(10, "WhatsApp notification sent.");
} catch (Exception $waError) {
// لا نوقف العملية إذا فشل الواتساب، فقط نسجل الخطأ
logStep(10, "WhatsApp Warning: " . $waError->getMessage());
}
} catch (PDOException $e) {
$con->rollBack();
$errorMsg = "Database Error (PDO): " . $e->getMessage();
logStep("ERROR-PDO", $errorMsg);
// إظهار رسالة عامة للمستخدم، وتسجيل التفاصيل في السيرفر
jsonError("System error during registration. Please contact support.");
} catch (Exception $e) {
// إذا كانت المعاملة مفتوحة، قم بإلغائها
if ($con->inTransaction()) {
$con->rollBack();
}
$errorMsg = "General Error: " . $e->getMessage();
logStep("ERROR-GEN", $errorMsg);
jsonError($e->getMessage());
}
?>

150
updateDriverToActive.php Normal file
View File

@@ -0,0 +1,150 @@
<?php
// --- تضمين الملفات الأساسية ---
require_once __DIR__ . '/../connect.php'; // يفترض أن يحتوي على الاتصال ومساعد التشفير
// --- استقبال البيانات من التطبيق ---
$driverId = filterRequest("driverId");
$phone = filterRequest("phone"); // استقبال رقم الهاتف مباشرة
// --- بيانات جدول السائق (driver) ---
$firstName = filterRequest("first_name");
$lastName = filterRequest("last_name");
$site = filterRequest("site"); // مكان القيد/الولادة
$nationalNumber = filterRequest("national_number");
$licenseCategories = filterRequest("license_categories"); // فئة الرخصة
$expiryDate = filterRequest("expiry_date"); // تاريخ انتهاء رخصة السائق
$licenseIssueDate = filterRequest("license_issue_date");
$gender = filterRequest("gender"); // الحقل الجديد
$birthdate = filterRequest("birthdate"); // الحقل الجديد
$maritalStatus=filterRequest("maritalStatus");
// --- بيانات جدول السيارة (CarRegistration) ---
$owner = filterRequest("owner");
$color = filterRequest("color");
$colorHex = filterRequest("color_hex");
$model = filterRequest("model"); // الموديل
$carPlate = filterRequest("car_plate");
$make = filterRequest("make"); // الصانع
$fuel = filterRequest("fuel");
$year = filterRequest("year");
$carExpirationDate = filterRequest("expiration_date"); // تاريخ انتهاء رخصة السيارة
// --- بدء المعاملة لضمان سلامة البيانات ---
$con->beginTransaction();
try {
// --- 1. معالجة وتشفير البيانات ---
$nameArabic = $firstName . ' ' . $lastName;
$address = $site;
// تشفير الحقول الحساسة
$encryptedFirstName = $encryptionHelper->encryptData($firstName);
$encryptedLastName = $encryptionHelper->encryptData($lastName);
$encryptedSite = $encryptionHelper->encryptData($site);
$encryptedAddress = $encryptionHelper->encryptData($address);
$encryptedNameArabic = $encryptionHelper->encryptData($nameArabic);
$encryptedNationalNumber = $encryptionHelper->encryptData($nationalNumber);
$encryptedOwner = $encryptionHelper->encryptData($owner);
$encryptedCarPlate = $encryptionHelper->encryptData($carPlate);
$encryptedBirthdate = $encryptionHelper->encryptData($birthdate);
$encryptedGender = $encryptionHelper->encryptData($gender);
// --- 2. تحديث جدول السائق ---
$sqlDriver = "UPDATE `driver` SET
`first_name` = :first_name,
`last_name` = :last_name,
`site` = :site,
`address` = :address,
`national_number` = :national_number,
`license_categories` = :license_categories,
`expiry_date` = :expiry_date,
`issue_date` = :issue_date,
`gender` = :gender,
`birthdate` = :birthdate,
`name_arabic` = :name_arabic,
`maritalStatus` = :maritalStatus,
`status` = 'actives'
WHERE `id` = :driverId";
$stmtDriver = $con->prepare($sqlDriver);
$stmtDriver->execute([
':first_name' => $encryptedFirstName,
':last_name' => $encryptedLastName,
':site' => $encryptedSite,
':address' => $encryptedAddress,
':national_number' => $encryptedNationalNumber,
':license_categories' => $licenseCategories,
':expiry_date' => $expiryDate,
':issue_date' => $licenseIssueDate,
':gender' => $encryptedGender,
':birthdate' => $encryptedBirthdate,
':name_arabic' => $encryptedNameArabic,
':driverId' => $driverId,
':maritalStatus' =>$maritalStatus
]);
// --- 3. تحديث جدول السيارة ---
$sqlCar = "UPDATE `CarRegistration` SET
`owner` = :owner,
`color` = :color,
`color_hex` = :color_hex,
`model` = :model,
`car_plate` = :car_plate,
`make` = :make,
`fuel` = :fuel,
`year` = :year,
`expiration_date` = :expiration_date
WHERE `driverID` = :driverId";
$stmtCar = $con->prepare($sqlCar);
$stmtCar->execute([
':owner' => $encryptedOwner,
':color' => $color,
':color_hex' => $colorHex,
':model' => $model,
':car_plate' => $encryptedCarPlate,
':make' => $make,
':fuel' => $fuel,
':year' => $year,
':expiration_date' => $carExpirationDate,
':driverId' => $driverId
]);
// --- 4. تأكيد المعاملة ---
$con->commit();
jsonSuccess(["message" => "Driver and car data updated successfully."]);
// --- 5. إرسال رسالة واتساب مبسطة وآمنة (باختيار رقم عشوائي) ---
// 5.1. تعريف الأرقام
$supportPhones = ['0952475740', '0952475742']; // يمكنك إضافة المزيد من الأرقام هنا
// 5.2. اختيار رقم عشوائي من القائمة
$randomIndex = array_rand($supportPhones); // يختار "مفتاح" عشوائي (index)
$phoneToUse = $supportPhones[$randomIndex]; // يحصل على الرقم من المفتاح
// --- !!! التعديل: إضافة رقم عشوائي ---
// هذا يضيف رقم عشوائي (4-6 خانات) لجعل الرسالة فريدة
$randomNumber = rand(1000, 999999);
// 5.5. إعداد نص الرسالة بالرقم المتغير
$messageBody = "أهلاً وسهلاً كابتن $firstName 👋\n"
. "تم تفعيل حسابك على تطبيق *انطلق*.\n"
. "يمكنك الآن تسجيل الدخول والبدء بالعمل مباشرة.\n"
. "للمساعدة تواصل معنا على الرقم: $phoneToUse\n" // <-- تم استخدام المتغير العشوائي هنا
. "نتمنى لك عمل موفق 🚖\n\n"
. "معرف الرسالة: $randomNumber"; // <-- إضافة الرقم العشوائي
// 5.6. إرسال الرسالة
sendWhatsAppFromServer($phone, $messageBody);
} catch (Exception $e) {
// --- 6. التراجع في حال الخطأ ---
$con->rollBack();
jsonError("An error occurred: " . $e->getMessage());
}
?>