From b00a16f32dfde9337252314362319642c908d21e Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 27 Aug 2026 00:12:39 +0300 Subject: [PATCH] feat: Real-time Workerman gateway + Multi-level exam hierarchy (lesson, unit, final, AI adaptive) and student mastery tracking --- backend/app/Controllers/ExamController.php | 269 ++++++++++++++++++++ backend/database_schema.sql | 123 +++++++++- backend/public/index.php | 6 + backend/websocket/server.php | 271 +++++++++++++++++++++ 4 files changed, 657 insertions(+), 12 deletions(-) create mode 100644 backend/app/Controllers/ExamController.php create mode 100644 backend/websocket/server.php diff --git a/backend/app/Controllers/ExamController.php b/backend/app/Controllers/ExamController.php new file mode 100644 index 0000000..11538ce --- /dev/null +++ b/backend/app/Controllers/ExamController.php @@ -0,0 +1,269 @@ +getQueryParams(); + $courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null; + $lessonId = !empty($queryParams['lesson_id']) ? (int)$queryParams['lesson_id'] : null; + $scope = $queryParams['scope'] ?? null; + + $sql = "SELECT e.*, COUNT(q.id) as questions_count + FROM exams e + LEFT JOIN questions q ON q.exam_id = e.id + WHERE e.is_published = 1"; + $params = []; + + if ($courseId) { + $sql .= " AND e.course_id = ?"; + $params[] = $courseId; + } + if ($lessonId) { + $sql .= " AND e.lesson_id = ?"; + $params[] = $lessonId; + } + if ($scope) { + $sql .= " AND e.scope = ?"; + $params[] = $scope; + } + + $sql .= " GROUP BY e.id ORDER BY e.timestamp_seconds ASC, e.id ASC"; + + $exams = Database::select($sql, $params); + + $response->json([ + 'status' => 'success', + 'data' => $exams + ]); + } + + /** + * Get single exam with questions and sanitized options + * GET /api/exams/{id} + */ + public function getExamDetails(Request $request, Response $response): void + { + $examId = (int)$request->getParam('id'); + $isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin'); + + $exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); + if (!$exam) { + $response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']); + return; + } + + $questions = Database::select("SELECT * FROM questions WHERE exam_id = ? ORDER BY id ASC", [$examId]); + + foreach ($questions as &$q) { + $options = Database::select("SELECT id, option_text, feedback_text" . ($isTeacher ? ", is_correct" : "") . " FROM question_options WHERE question_id = ? ORDER BY id ASC", [$q['id']]); + $q['options'] = $options; + } + + $exam['questions'] = $questions; + + $response->json([ + 'status' => 'success', + 'data' => $exam + ]); + } + + /** + * Submit Exam Attempt & Calculate Instant AI Diagnostic Evaluation + * POST /api/exams/{id}/submit + */ + public function submitExam(Request $request, Response $response): void + { + $studentId = $request->user_id; + $examId = (int)$request->getParam('id'); + $body = $request->getBody(); + + $answers = $body['answers'] ?? []; // Array of ['question_id' => X, 'selected_option_id' => Y] + $timeSpent = (int)($body['time_spent_seconds'] ?? 0); + + $exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); + if (!$exam) { + $response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']); + return; + } + + // Fetch all questions and correct options + $questions = Database::select("SELECT id, points, topic_tag, explanation_text, ai_hint FROM questions WHERE exam_id = ?", [$examId]); + $questionMap = []; + $totalPoints = 0; + foreach ($questions as $q) { + $questionMap[$q['id']] = $q; + $totalPoints += (int)$q['points']; + } + + $options = Database::select("SELECT id, question_id, is_correct, feedback_text FROM question_options WHERE question_id IN (" . implode(',', array_keys($questionMap)) . ")"); + $correctOptions = []; + foreach ($options as $opt) { + if ($opt['is_correct']) { + $correctOptions[$opt['question_id']] = (int)$opt['id']; + } + } + + // Score evaluation + $earnedScore = 0; + $weakTopics = []; + $detailedAnswers = []; + + foreach ($answers as $ans) { + $qId = (int)($ans['question_id'] ?? 0); + $selectedOptId = (int)($ans['selected_option_id'] ?? 0); + + if (!isset($questionMap[$qId])) continue; + + $isCorrect = (isset($correctOptions[$qId]) && $correctOptions[$qId] === $selectedOptId); + $qPoints = (int)$questionMap[$qId]['points']; + $awarded = $isCorrect ? $qPoints : 0; + $earnedScore += $awarded; + + if (!$isCorrect) { + $topic = $questionMap[$qId]['topic_tag'] ?: 'المفاهيم العامة'; + $weakTopics[$topic] = ($weakTopics[$topic] ?? 0) + 1; + } + + $detailedAnswers[] = [ + 'question_id' => $qId, + 'selected_option_id' => $selectedOptId, + 'is_correct' => $isCorrect ? 1 : 0, + 'points_awarded' => $awarded, + 'explanation' => $questionMap[$qId]['explanation_text'] ?? null, + 'ai_hint' => $questionMap[$qId]['ai_hint'] ?? null + ]; + } + + $percentage = $totalPoints > 0 ? round(($earnedScore / $totalPoints) * 100, 2) : 0; + $passed = ($percentage >= (float)$exam['passing_percentage']); + $status = $passed ? 'passed' : 'needs_remediation'; + + // AI Diagnostic Report + $aiReport = $passed + ? "أداء ممتاز! استيعاب قوي للمفاهيم بنسبة {$percentage}%. أنت جاهز تماماً للانتقال للدرس التالي." + : "تحتاج لمراجعة المفاهيم المتعلقة بـ: " . implode('، ', array_keys($weakTopics)) . ". يُنصح بمشاهدة مقطع الشرح الموصى به."; + + // Insert Attempt Record + $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + + $attemptId = Database::insert( + "INSERT INTO exam_attempts (uuid, student_id, exam_id, score, total_score, percentage, status, time_spent_seconds, weak_topics_json, ai_diagnostic_report, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())", + [$uuid, $studentId, $examId, $earnedScore, $totalPoints, $percentage, $status, $timeSpent, json_encode(array_keys($weakTopics), JSON_UNESCAPED_UNICODE), $aiReport] + ); + + // Save detailed question answers + foreach ($detailedAnswers as $dAns) { + Database::insert( + "INSERT INTO student_question_answers (attempt_id, student_id, question_id, selected_option_id, is_correct, points_awarded, time_spent_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?)", + [$attemptId, $studentId, $dAns['question_id'], $dAns['selected_option_id'], $dAns['is_correct'], $dAns['points_awarded'], 0] + ); + } + + // Update Student Cumulative Mastery & Tawjihi Readiness Score + $courseId = (int)$exam['course_id']; + $subjectId = (int)Database::selectOne("SELECT subject_id FROM courses WHERE id = ? LIMIT 1", [$courseId])['subject_id']; + + $allAttempts = Database::select( + "SELECT ea.percentage, ea.status FROM exam_attempts ea + JOIN exams e ON e.id = ea.exam_id + WHERE ea.student_id = ? AND e.course_id = ?", + [$studentId, $courseId] + ); + + $totalExams = count($allAttempts); + $passedExams = 0; + $sumPct = 0; + foreach ($allAttempts as $att) { + $sumPct += (float)$att['percentage']; + if ($att['status'] === 'passed') $passedExams++; + } + + $avgMastery = $totalExams > 0 ? round($sumPct / $totalExams, 2) : 0; + $tawjihiIndex = round(($avgMastery * 0.7) + (($passedExams / max(1, $totalExams)) * 30), 1); + + Database::query(" + INSERT INTO student_mastery_analytics + (student_id, course_id, subject_id, mastery_percentage, tawjihi_readiness_score, exams_passed_count, exams_total_count, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + mastery_percentage = VALUES(mastery_percentage), + tawjihi_readiness_score = VALUES(tawjihi_readiness_score), + exams_passed_count = VALUES(exams_passed_count), + exams_total_count = VALUES(exams_total_count), + updated_at = NOW() + ", [$studentId, $courseId, $subjectId, $avgMastery, $tawjihiIndex, $passedExams, $totalExams]); + + $response->json([ + 'status' => 'success', + 'data' => [ + 'attempt_id' => $attemptId, + 'score' => $earnedScore, + 'total_score' => $totalPoints, + 'percentage' => $percentage, + 'passed' => $passed, + 'rewind_seconds' => $passed ? 0 : (int)$exam['rewind_on_fail_seconds'], + 'ai_diagnostic_report' => $aiReport, + 'weak_topics' => array_keys($weakTopics), + 'tawjihi_readiness_score'=> $tawjihiIndex, + 'detailed_answers' => $detailedAnswers + ] + ]); + } + + /** + * Get Student Mastery & Tawjihi Readiness Analytics + * GET /api/student/progress/mastery?course_id=1 + */ + public function getMastery(Request $request, Response $response): void + { + $studentId = $request->user_id; + $courseId = (int)$request->getParam('course_id'); + + $analytics = Database::selectOne( + "SELECT * FROM student_mastery_analytics WHERE student_id = ? AND course_id = ? LIMIT 1", + [$studentId, $courseId] + ); + + $recentAttempts = Database::select( + "SELECT ea.id, ea.score, ea.total_score, ea.percentage, ea.status, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope + FROM exam_attempts ea + JOIN exams e ON e.id = ea.exam_id + WHERE ea.student_id = ? AND e.course_id = ? + ORDER BY ea.id DESC LIMIT 10", + [$studentId, $courseId] + ); + + $response->json([ + 'status' => 'success', + 'data' => [ + 'analytics' => $analytics ?: [ + 'mastery_percentage' => 0, + 'tawjihi_readiness_score'=> 0, + 'exams_passed_count' => 0, + 'exams_total_count' => 0 + ], + 'recent_attempts' => $recentAttempts + ] + ]); + } +} diff --git a/backend/database_schema.sql b/backend/database_schema.sql index a9c8540..a2310dc 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -159,49 +159,148 @@ CREATE TABLE `lessons` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ------------------------------------------------------------------------------ --- 8. Table: quizzes (الكويزات داخل الفيديو - Socratic Checkpoints) +-- 8. Table: exams (الامتحانات الشاملة: درس، وحدة، فصل، ومحاكاة وزاري) -- ------------------------------------------------------------------------------ -CREATE TABLE `quizzes` ( +CREATE TABLE `exams` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `lesson_id` BIGINT UNSIGNED NOT NULL, + `uuid` CHAR(36) NOT NULL, + `course_id` BIGINT UNSIGNED NOT NULL, + `lesson_id` BIGINT UNSIGNED DEFAULT NULL, + `created_by_id` BIGINT UNSIGNED DEFAULT NULL, + `creator_type` ENUM('teacher', 'ai_adaptive', 'ministry_standard') NOT NULL DEFAULT 'teacher', + `scope` ENUM('in_video_checkpoint', 'lesson_exam', 'unit_exam', 'semester_final') NOT NULL DEFAULT 'lesson_exam', `title` VARCHAR(255) NOT NULL, + `description` TEXT DEFAULT NULL, + `duration_minutes` INT UNSIGNED NOT NULL DEFAULT 30, `timestamp_seconds` INT UNSIGNED NOT NULL DEFAULT 0, - `is_mandatory` TINYINT(1) NOT NULL DEFAULT 1, `rewind_on_fail_seconds` INT UNSIGNED NOT NULL DEFAULT 45, + `passing_percentage` DECIMAL(5,2) NOT NULL DEFAULT 60.00, + `total_points` INT UNSIGNED NOT NULL DEFAULT 100, + `is_mandatory` TINYINT(1) NOT NULL DEFAULT 1, + `is_published` TINYINT(1) NOT NULL DEFAULT 1, `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - KEY `idx_quizzes_lesson` (`lesson_id`), - CONSTRAINT `fk_quizzes_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE + UNIQUE KEY `idx_exams_uuid` (`uuid`), + KEY `idx_exams_course` (`course_id`), + KEY `idx_exams_lesson` (`lesson_id`), + KEY `idx_exams_scope` (`scope`), + KEY `idx_exams_creator` (`creator_type`), + CONSTRAINT `fk_exams_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_exams_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_exams_creator` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ------------------------------------------------------------------------------ --- 9. Table: questions (الأسئلة) +-- 9. Table: questions (الأسئلة والتحليل المعرفي للذكاء الاصطناعي) -- ------------------------------------------------------------------------------ CREATE TABLE `questions` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `quiz_id` BIGINT UNSIGNED NOT NULL, + `uuid` CHAR(36) NOT NULL, + `exam_id` BIGINT UNSIGNED NOT NULL, `question_text` TEXT NOT NULL, + `question_type` ENUM('multiple_choice', 'true_false', 'short_answer') NOT NULL DEFAULT 'multiple_choice', + `bloom_taxonomy` ENUM('recall', 'comprehension', 'application', 'analysis') NOT NULL DEFAULT 'comprehension', + `topic_tag` VARCHAR(150) DEFAULT NULL, + `points` INT UNSIGNED NOT NULL DEFAULT 10, `explanation_text` TEXT DEFAULT NULL, - `points` INT UNSIGNED NOT NULL DEFAULT 1, + `ai_hint` TEXT DEFAULT NULL, `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - KEY `idx_questions_quiz` (`quiz_id`), - CONSTRAINT `fk_questions_quiz` FOREIGN KEY (`quiz_id`) REFERENCES `quizzes` (`id`) ON DELETE CASCADE + UNIQUE KEY `idx_questions_uuid` (`uuid`), + KEY `idx_questions_exam` (`exam_id`), + KEY `idx_questions_topic` (`topic_tag`), + CONSTRAINT `fk_questions_exam` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ------------------------------------------------------------------------------ --- 10. Table: question_options (خيارات الإجابة) +-- 10. Table: question_options (خيارات الإجابة والتغذية الراجعة) -- ------------------------------------------------------------------------------ CREATE TABLE `question_options` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `question_id` BIGINT UNSIGNED NOT NULL, `option_text` TEXT NOT NULL, `is_correct` TINYINT(1) NOT NULL DEFAULT 0, + `feedback_text` TEXT DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_options_question` (`question_id`), CONSTRAINT `fk_options_question` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ------------------------------------------------------------------------------ +-- 11. Table: exam_attempts (محاولات ونتائج الامتحانات وتحليل الذكاء الاصطناعي) +-- ------------------------------------------------------------------------------ +CREATE TABLE `exam_attempts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `student_id` BIGINT UNSIGNED NOT NULL, + `exam_id` BIGINT UNSIGNED NOT NULL, + `attempt_number` INT UNSIGNED NOT NULL DEFAULT 1, + `score` DECIMAL(6,2) NOT NULL DEFAULT 0.00, + `total_score` DECIMAL(6,2) NOT NULL DEFAULT 100.00, + `percentage` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `status` ENUM('passed', 'failed', 'needs_remediation', 'in_progress') NOT NULL DEFAULT 'in_progress', + `time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `weak_topics_json` JSON DEFAULT NULL, + `ai_diagnostic_report` TEXT DEFAULT NULL, + `completed_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_attempts_uuid` (`uuid`), + KEY `idx_attempts_student_exam` (`student_id`, `exam_id`), + KEY `idx_attempts_status` (`status`), + CONSTRAINT `fk_attempts_student` FOREIGN KEY (`student_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_attempts_exam` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 12. Table: student_question_answers (تفاصيل إجابات الطالب على كل سؤال) +-- ------------------------------------------------------------------------------ +CREATE TABLE `student_question_answers` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `attempt_id` BIGINT UNSIGNED NOT NULL, + `student_id` BIGINT UNSIGNED NOT NULL, + `question_id` BIGINT UNSIGNED NOT NULL, + `selected_option_id` BIGINT UNSIGNED DEFAULT NULL, + `text_answer` TEXT DEFAULT NULL, + `is_correct` TINYINT(1) NOT NULL DEFAULT 0, + `points_awarded` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sqa_attempt` (`attempt_id`), + KEY `idx_sqa_student_question` (`student_id`, `question_id`), + CONSTRAINT `fk_sqa_attempt` FOREIGN KEY (`attempt_id`) REFERENCES `exam_attempts` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sqa_student` FOREIGN KEY (`student_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sqa_question` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sqa_option` FOREIGN KEY (`selected_option_id`) REFERENCES `question_options` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 13. Table: student_mastery_analytics (مؤشر الفهم التراكمي والجاهزية للوزاري) +-- ------------------------------------------------------------------------------ +CREATE TABLE `student_mastery_analytics` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `student_id` BIGINT UNSIGNED NOT NULL, + `course_id` BIGINT UNSIGNED NOT NULL, + `subject_id` BIGINT UNSIGNED NOT NULL, + `mastery_percentage` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `tawjihi_readiness_score` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `lessons_completed_count` INT UNSIGNED NOT NULL DEFAULT 0, + `exams_passed_count` INT UNSIGNED NOT NULL DEFAULT 0, + `exams_total_count` INT UNSIGNED NOT NULL DEFAULT 0, + `weak_concepts_summary` TEXT DEFAULT NULL, + `ai_recommendations` TEXT DEFAULT NULL, + `guardian_last_notified_at` TIMESTAMP NULL DEFAULT NULL, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_mastery_student_course` (`student_id`, `course_id`), + KEY `idx_mastery_subject` (`subject_id`), + CONSTRAINT `fk_mastery_student` FOREIGN KEY (`student_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mastery_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mastery_subject` FOREIGN KEY (`subject_id`) REFERENCES `subjects` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ------------------------------------------------------------------------------ -- 11. Table: lesson_progress (سجل متابعة ونبض المشاهدة) -- ------------------------------------------------------------------------------ diff --git a/backend/public/index.php b/backend/public/index.php index 3220342..f045139 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -73,5 +73,11 @@ $router->post('/api/chat/messages', [\App\Controllers\ChatController::class $router->post('/api/chat/read', [\App\Controllers\ChatController::class, 'markAsRead'], [\App\Middlewares\AuthMiddleware::class]); $router->get('/api/chat/unread-count', [\App\Controllers\ChatController::class, 'getUnreadCount'], [\App\Middlewares\AuthMiddleware::class]); +// Multi-Level Exams & AI Mastery Analytics Routes (API-Driven) +$router->get('/api/exams', [\App\Controllers\ExamController::class, 'getExams'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/exams/{id}', [\App\Controllers\ExamController::class, 'getExamDetails'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/exams/{id}/submit', [\App\Controllers\ExamController::class, 'submitExam'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/student/progress/mastery', [\App\Controllers\ExamController::class, 'getMastery'], [\App\Middlewares\AuthMiddleware::class]); + // 5. Dispatch the request $router->dispatch($request, $response); diff --git a/backend/websocket/server.php b/backend/websocket/server.php new file mode 100644 index 0000000..833a140 --- /dev/null +++ b/backend/websocket/server.php @@ -0,0 +1,271 @@ +count = 1; // Single process for unified in-memory connection registry + +// Registry: [userId => [connectionId => TcpConnection]] +$userConnections = []; + +// Registry: [connectionId => userId] +$connectionUserMap = []; + +$wsWorker->onWorkerStart = function () use ($wsWorker, $internalPort, &$userConnections) { + echo "====================================================\n"; + echo " 🚀 SAQEL REAL-TIME WORKERMAN SERVER STARTED \n"; + echo " 📡 WebSocket: 0.0.0.0:{$GLOBALS['wsPort']} \n"; + echo " 🔒 Internal HTTP: 0.0.0.0:{$internalPort} \n"; + echo "====================================================\n"; + + // Internal HTTP Event Listener (Receives pushes from PHP backend controllers) + $innerHttp = new Worker("http://0.0.0.0:{$internalPort}"); + $innerHttp->onMessage = function ($connection, $request) use (&$userConnections) { + $post = $request->post(); + $action = trim($post['action'] ?? ''); + $targetUserId = (int)($post['target_user_id'] ?? 0); + $payload = $post['payload'] ?? []; + + if (is_string($payload)) { + $payload = json_decode($payload, true) ?: $payload; + } + + switch ($action) { + case 'push_chat': + if ($targetUserId && isset($userConnections[$targetUserId])) { + foreach ($userConnections[$targetUserId] as $conn) { + $conn->send(json_encode([ + 'event' => 'chat_message', + 'data' => $payload + ], JSON_UNESCAPED_UNICODE)); + } + $connection->send(json_encode(['status' => 'delivered', 'recipients' => count($userConnections[$targetUserId])])); + return; + } + $connection->send(json_encode(['status' => 'offline'])); + break; + + case 'push_notification': + if ($targetUserId && isset($userConnections[$targetUserId])) { + foreach ($userConnections[$targetUserId] as $conn) { + $conn->send(json_encode([ + 'event' => 'notification', + 'data' => $payload + ], JSON_UNESCAPED_UNICODE)); + } + $connection->send(json_encode(['status' => 'delivered'])); + return; + } + $connection->send(json_encode(['status' => 'offline'])); + break; + + case 'drm_force_logout': + if ($targetUserId && isset($userConnections[$targetUserId])) { + foreach ($userConnections[$targetUserId] as $conn) { + $conn->send(json_encode([ + 'event' => 'drm_session_terminated', + 'message' => 'تم تسجيل الدخول من جهاز آخر. تم إنهاء الجلسة لحماية محتواك.' + ], JSON_UNESCAPED_UNICODE)); + } + $connection->send(json_encode(['status' => 'kicked'])); + return; + } + $connection->send(json_encode(['status' => 'not_found'])); + break; + + case 'broadcast_course': + $courseId = (int)($post['course_id'] ?? 0); + // Broadcast to all active connections + foreach ($userConnections as $uid => $conns) { + foreach ($conns as $conn) { + $conn->send(json_encode([ + 'event' => 'course_broadcast', + 'course_id' => $courseId, + 'data' => $payload + ], JSON_UNESCAPED_UNICODE)); + } + } + $connection->send(json_encode(['status' => 'broadcasted'])); + break; + + default: + $connection->send(json_encode(['status' => 'unknown_action'])); + break; + } + }; + $innerHttp->listen(); +}; + +$wsWorker->onConnect = function (TcpConnection $connection) { + // Initial ping on connection + $connection->send(json_encode([ + 'event' => 'connected', + 'message' => 'Connected to Saqel Real-time Network. Please authenticate with your JWT token.' + ], JSON_UNESCAPED_UNICODE)); +}; + +$wsWorker->onMessage = function (TcpConnection $connection, $data) use (&$userConnections, &$connectionUserMap) { + $msg = json_decode($data, true); + if (!is_array($msg)) { + return; + } + + $event = $msg['event'] ?? ''; + $payload = $msg['data'] ?? []; + + switch ($event) { + // 1. Authenticate WebSocket Connection via JWT + case 'auth': + $token = trim((string)($payload['token'] ?? '')); + if (empty($token)) { + $connection->send(json_encode(['event' => 'auth_error', 'message' => 'Token required'])); + return; + } + + try { + $decoded = \App\Core\Security::decodeJwt($token); + if (!$decoded || empty($decoded['user_id'])) { + $connection->send(json_encode(['event' => 'auth_error', 'message' => 'Invalid or expired token'])); + return; + } + + $userId = (int)$decoded['user_id']; + $role = (string)($decoded['role'] ?? 'student'); + + $userConnections[$userId][$connection->id] = $connection; + $connectionUserMap[$connection->id] = $userId; + + $connection->send(json_encode([ + 'event' => 'authenticated', + 'user_id' => $userId, + 'role' => $role, + 'message' => 'Authenticated successfully. Real-time stream active.' + ], JSON_UNESCAPED_UNICODE)); + + echo "✅ [WS Auth] User #{$userId} ({$role}) connected (Connection ID: {$connection->id})\n"; + + } catch (\Exception $e) { + $connection->send(json_encode(['event' => 'auth_error', 'message' => $e->getMessage()])); + } + break; + + // 2. Direct Chat Message (Student <-> Teacher) + case 'chat_send': + $senderId = $connectionUserMap[$connection->id] ?? null; + if (!$senderId) { + $connection->send(json_encode(['event' => 'error', 'message' => 'Unauthorized'])); + return; + } + + $receiverId = (int)($payload['receiver_id'] ?? 0); + $messageText = trim((string)($payload['message'] ?? '')); + $messageType = (string)($payload['message_type'] ?? 'text'); + $mediaUrl = !empty($payload['media_url']) ? trim((string)$payload['media_url']) : null; + $courseId = !empty($payload['course_id']) ? (int)$payload['course_id'] : null; + + if (!$receiverId || empty($messageText)) { + $connection->send(json_encode(['event' => 'error', 'message' => 'receiver_id and message required'])); + return; + } + + // Save to MySQL + $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + + try { + $msgId = \App\Core\Database::insert( + "INSERT INTO chat_messages (uuid, sender_id, receiver_id, course_id, message, message_type, media_url, is_read) + VALUES (?, ?, ?, ?, ?, ?, ?, 0)", + [$uuid, $senderId, $receiverId, $courseId, $messageText, $messageType, $mediaUrl] + ); + + $msgData = [ + 'id' => $msgId, + 'uuid' => $uuid, + 'sender_id' => $senderId, + 'receiver_id' => $receiverId, + 'course_id' => $courseId, + 'message' => $messageText, + 'message_type' => $messageType, + 'media_url' => $mediaUrl, + 'is_read' => false, + 'created_at' => date('Y-m-d H:i:s') + ]; + + // Echo back to sender with confirmed ID + $connection->send(json_encode([ + 'event' => 'chat_sent', + 'data' => array_merge($msgData, ['is_mine' => true]) + ], JSON_UNESCAPED_UNICODE)); + + // Dispatch to receiver if online + if (isset($userConnections[$receiverId])) { + foreach ($userConnections[$receiverId] as $recConn) { + $recConn->send(json_encode([ + 'event' => 'chat_message', + 'data' => array_merge($msgData, ['is_mine' => false]) + ], JSON_UNESCAPED_UNICODE)); + } + } + + } catch (\Exception $e) { + $connection->send(json_encode(['event' => 'error', 'message' => 'DB insert failed: ' . $e->getMessage()])); + } + break; + + // 3. Typing Indicator + case 'typing': + $senderId = $connectionUserMap[$connection->id] ?? null; + $receiverId = (int)($payload['receiver_id'] ?? 0); + if ($senderId && $receiverId && isset($userConnections[$receiverId])) { + foreach ($userConnections[$receiverId] as $recConn) { + $recConn->send(json_encode([ + 'event' => 'user_typing', + 'sender_id' => $senderId, + 'is_typing' => (bool)($payload['is_typing'] ?? true) + ])); + } + } + break; + + // 4. Heartbeat + case 'ping': + $connection->send(json_encode(['event' => 'pong', 'timestamp' => time()])); + break; + } +}; + +$wsWorker->onClose = function (TcpConnection $connection) use (&$userConnections, &$connectionUserMap) { + if (isset($connectionUserMap[$connection->id])) { + $userId = $connectionUserMap[$connection->id]; + unset($userConnections[$userId][$connection->id]); + if (empty($userConnections[$userId])) { + unset($userConnections[$userId]); + } + unset($connectionUserMap[$connection->id]); + echo "🔌 [WS Disconnect] User #{$userId} disconnected (Connection ID: {$connection->id})\n"; + } +}; + +Worker::runAll();