feat: Real-time Workerman gateway + Multi-level exam hierarchy (lesson, unit, final, AI adaptive) and student mastery tracking

This commit is contained in:
Hamza-Ayed
2026-08-27 00:12:39 +03:00
parent 12f00a05b6
commit b00a16f32d
4 changed files with 657 additions and 12 deletions
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Database;
use App\Core\Validator;
class ExamController
{
/**
* List exams for a specific course/lesson/scope
* GET /api/exams?course_id=1&lesson_id=2&scope=in_video_checkpoint
*/
public function getExams(Request $request, Response $response): void
{
$queryParams = $request->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
]
]);
}
}
+111 -12
View File
@@ -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 (سجل متابعة ونبض المشاهدة)
-- ------------------------------------------------------------------------------
+6
View File
@@ -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);
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* Saqel Platform - High Performance Real-Time WebSocket Server (Workerman)
* =========================================================================
* Ports:
* - 4040 : Public WebSocket for Students, Teachers & Guardians
* - 4041 : Internal HTTP Event Dispatcher for PHP Backend
*/
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
require_once __DIR__ . '/../app/bootstrap.php';
require_once __DIR__ . '/../app/Core/Security.php';
require_once __DIR__ . '/../app/Core/Database.php';
// 1. Initialize Public WebSocket Server
$wsPort = (int)(getenv('WS_PORT') ?: 4040);
$internalPort = (int)(getenv('WS_INTERNAL_PORT') ?: 4041);
$wsWorker = new Worker("websocket://0.0.0.0:{$wsPort}");
$wsWorker->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();