feat: Real-time Workerman gateway + Multi-level exam hierarchy (lesson, unit, final, AI adaptive) and student mastery tracking
This commit is contained in:
@@ -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
@@ -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 (سجل متابعة ونبض المشاهدة)
|
||||
-- ------------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user