From c4c59bc82827be9c87972f7a90c917b0cf2ae2a3 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 29 Aug 2026 22:41:36 +0300 Subject: [PATCH] Update Saqel Platform: 2026-08-29 22:41:36 --- .../app/Controllers/CurriculumController.php | 9 +- backend/app/Controllers/VideoController.php | 62 ++++++- backend/app/Services/CurriculumService.php | 151 +++++++++++++++++- backend/app/Views/StudentPortal.php | 37 ++++- backend/database_schema.sql | 5 + 5 files changed, 250 insertions(+), 14 deletions(-) diff --git a/backend/app/Controllers/CurriculumController.php b/backend/app/Controllers/CurriculumController.php index bf5e77f..c3ecaa9 100644 --- a/backend/app/Controllers/CurriculumController.php +++ b/backend/app/Controllers/CurriculumController.php @@ -261,7 +261,10 @@ class CurriculumController } try { - $courseId = 0; // System course ID for curriculum AI videos + // Resolve a valid FK-safe course_id for the master ministry curriculum. + // IMPORTANT: course_id = 0 causes SQLSTATE[23000] FK violation because + // the `lessons` table enforces courses(id) ON DELETE CASCADE. + $courseId = CurriculumService::getOrCreateSystemCourse(); $title = basename($file, '.md'); // 1. Upload & Transcode to HLS & Push to R2 (handled by VideoService) @@ -269,7 +272,7 @@ class CurriculumController $videoUrl = $uploadResult['r2_url'] ?? $uploadResult['hls_url']; - // 2. Insert into lessons table as AI generated version + // 2. Insert into lessons table as AI-generated ministry version $lessonId = \App\Core\Database::insert( "INSERT INTO lessons (course_id, title, sequence_order, storage_type, video_uuid, bunny_video_id, local_path, hls_url, thumbnail_url, duration_seconds, is_free_preview, encoding_status, ai_video_url) VALUES (?, ?, 1, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready', ?)", @@ -307,6 +310,8 @@ class CurriculumController 'status' => 'success', 'message' => 'تم رفع وتثبيت فيديو الشرح ومعالجته HLS وربطه بالذكاء الاصطناعي بنجاح!', 'video_url' => $videoUrl, + 'course_id' => $courseId, + 'lesson_id' => $lessonId, 'data' => $uploadResult ]); } catch (\Throwable $e) { diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index 7dd7736..78e9a9d 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -306,23 +306,73 @@ class VideoController VideoService::ensureSchema(); CurriculumService::ensureSchema(); - $courseId = (int)($request->getQuery('course_id') ?: 0); - $where = $courseId > 0 ? "WHERE l.course_id = {$courseId}" : ""; + $courseId = (int)($request->getQuery('course_id') ?: 0); + + // Extract student grade/stream from JWT-decoded context (populated by auth middleware) + $gradeLevel = $request->getQuery('grade_level') ?: ($request->user['grade_level'] ?? null); + $stream = $request->getQuery('stream') ?: ($request->user['stream'] ?? null); + $schoolId = $request->user['school_id'] ?? null; + + // Build WHERE clause dynamically + $conditions = []; + $params = []; + + if ($courseId > 0) { + $conditions[] = 'l.course_id = ?'; + $params[] = $courseId; + } + + // Grade/stream filter: include lessons where the course matches the student's + // grade/stream, OR where the course is the system curriculum (AI videos). + // This ensures AI ministry content is always visible to all students. + if ($gradeLevel || $stream) { + $gradeCond = '(c.is_system_curriculum = 1'; + if ($gradeLevel) { + $gradeCond .= ' OR c.grade_level = ?'; + $params[] = $gradeLevel; + } + if ($stream) { + $gradeCond .= ' OR c.stream = ?'; + $params[] = $stream; + } + $gradeCond .= ')'; + $conditions[] = $gradeCond; + } + + $whereClause = !empty($conditions) ? ('WHERE ' . implode(' AND ', $conditions)) : ''; $lessons = Database::select( - "SELECT l.id, l.course_id, l.title, l.sequence_order, l.storage_type, l.video_uuid, l.hls_url, l.thumbnail_url, + "SELECT l.id, l.course_id, l.title, l.sequence_order, l.storage_type, + l.video_uuid, l.hls_url, l.thumbnail_url, l.ai_video_url, l.duration_seconds, l.is_free_preview, l.created_at, COALESCE(c.title, 'توجيهي 2008 — المنهاج المعتمد') as course_title, + COALESCE(c.is_system_curriculum, 0) as is_ai_version, + c.teacher_id, + u.full_name as teacher_name, + c.school_id, + CASE WHEN c.school_id = ? THEN 1 ELSE 0 END as is_my_school, (SELECT COUNT(*) FROM exams WHERE lesson_id = l.id AND scope = 'in_video_checkpoint') as checkpoints_count FROM lessons l LEFT JOIN courses c ON l.course_id = c.id - {$where} - ORDER BY l.id DESC" + LEFT JOIN users u ON c.teacher_id = u.id + {$whereClause} + ORDER BY is_ai_version DESC, is_my_school DESC, l.sequence_order ASC, l.id DESC", + array_merge([$schoolId ?? 0], $params) ); + // Separate AI/ministry lessons from teacher lessons for the carousel + $aiLessons = array_filter($lessons, fn($l) => (bool)$l['is_ai_version']); + $teacherLessons = array_filter($lessons, fn($l) => !(bool)$l['is_ai_version']); + $response->json([ 'status' => 'success', - 'data' => $lessons + 'data' => array_values($lessons), + 'meta' => [ + 'grade_level' => $gradeLevel, + 'stream' => $stream, + 'ai_count' => count($aiLessons), + 'teacher_count' => count($teacherLessons), + ] ]); } diff --git a/backend/app/Services/CurriculumService.php b/backend/app/Services/CurriculumService.php index 4b2ceca..2f5915f 100644 --- a/backend/app/Services/CurriculumService.php +++ b/backend/app/Services/CurriculumService.php @@ -16,7 +16,156 @@ class CurriculumService */ public static function ensureSchema(): void { - // No-op + // No-op (schema managed via database_schema.sql) + } + + /** + * Runtime migration: add is_system_curriculum (and related columns) to `courses` + * if they are missing. Safe to call multiple times — uses IF NOT EXISTS / IGNORE. + */ + public static function ensureSystemCurriculumColumn(): void + { + static $ran = false; + if ($ran) return; + $ran = true; + + try { + // Check whether the column exists to avoid noisy ALTER errors on every request + $row = Database::selectOne( + "SELECT COUNT(*) as cnt FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'is_system_curriculum' + LIMIT 1" + ); + + if (empty($row['cnt'])) { + Database::query( + "ALTER TABLE `courses` + ADD COLUMN `is_system_curriculum` TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'الدورة الرئيسية للمنهاج الوزاري' + AFTER `is_school_exclusive`" + ); + } + + // Also ensure grade_level + stream columns exist (for student filtering) + $gradeRow = Database::selectOne( + "SELECT COUNT(*) as cnt FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'grade_level' + LIMIT 1" + ); + if (empty($gradeRow['cnt'])) { + Database::query( + "ALTER TABLE `courses` + ADD COLUMN `grade_level` VARCHAR(50) DEFAULT NULL + COMMENT 'المرحلة الدراسية مثل: tawjihi_2008' + AFTER `is_system_curriculum`, + ADD COLUMN `stream` ENUM('scientific','literary','vocational','general') DEFAULT NULL + COMMENT 'الفرع الدراسي' + AFTER `grade_level`" + ); + } + } catch (\Throwable $e) { + error_log('[CurriculumService] ensureSystemCurriculumColumn error: ' . $e->getMessage()); + } + } + + /** + * Resolve or create the master system curriculum course. + * + * Returns a guaranteed valid `course_id` that satisfies the FK constraint on + * `lessons.course_id → courses.id`. Called by CurriculumController when + * uploading AI/ministry videos without an explicit teacher course. + * + * @return int Valid course_id + * @throws \RuntimeException if no subjects or teachers exist and creation fails + */ + public static function getOrCreateSystemCourse(): int + { + // Ensure the column exists before querying it (runtime migration) + self::ensureSystemCurriculumColumn(); + + // 1. Look for existing master system course + $existing = Database::selectOne( + "SELECT id FROM courses WHERE is_system_curriculum = 1 LIMIT 1" + ); + if ($existing && !empty($existing['id'])) { + return (int)$existing['id']; + } + + // 2. Also try by canonical title in case migration was run after records existed + $byTitle = Database::selectOne( + "SELECT id FROM courses WHERE title LIKE '%منهاج وزارة التربية%' LIMIT 1" + ); + if ($byTitle && !empty($byTitle['id'])) { + // Stamp it so future lookups are fast + Database::query( + "UPDATE courses SET is_system_curriculum = 1 WHERE id = ?", + [(int)$byTitle['id']] + ); + return (int)$byTitle['id']; + } + + // 3. Create a new master course — resolve required FK dependencies first + // Resolve subject_id (use first available subject or create a placeholder) + $subject = Database::selectOne("SELECT id FROM subjects LIMIT 1"); + if (!$subject) { + $subjectId = Database::insert( + "INSERT INTO subjects (name, description) VALUES ('المنهاج الوزاري الأردني', 'مقررات وزارة التربية والتعليم المعتمدة') ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)" + ); + } else { + $subjectId = (int)$subject['id']; + } + + // Resolve teacher_id (use system teacher with id=1 or first available) + $teacher = Database::selectOne("SELECT id FROM teachers WHERE id = 1 LIMIT 1"); + if (!$teacher) { + $teacher = Database::selectOne("SELECT id FROM teachers LIMIT 1"); + } + if (!$teacher) { + // Last resort: look up the super_admin user and create a teacher row for them + $adminUser = Database::selectOne( + "SELECT id FROM users WHERE role = 'super_admin' LIMIT 1" + ); + $adminUserId = $adminUser ? (int)$adminUser['id'] : 1; + $teacherId = Database::insert( + "INSERT INTO teachers (user_id, specialization, verification_status) + VALUES (?, 'المنهاج الوزاري', 'verified') + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)", + [$adminUserId] + ); + } else { + $teacherId = (int)$teacher['id']; + } + + // Generate stable UUID for the master course + $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) + ); + + $courseId = Database::insert( + "INSERT INTO courses + (uuid, subject_id, teacher_id, title, description, semester, + price_jod, is_published, is_system_curriculum, grade_level, stream) + VALUES + (?, ?, ?, 'منهاج وزارة التربية والتعليم الأساسي', + 'الدورة الرئيسية للمنهاج الوزاري — تُستخدم لرفع وتنظيم فيديوهات الذكاء الاصطناعي التعليمية', + 'full_year', 0.00, 1, 1, 'tawjihi_2008', 'scientific')", + [$uuid, $subjectId, $teacherId] + ); + + if (!$courseId) { + throw new \RuntimeException('فشل إنشاء الدورة الرئيسية للمنهاج — تحقق من وجود records في جداول subjects و teachers'); + } + + error_log("[CurriculumService] Created master system course id={$courseId}"); + return (int)$courseId; } private static string $storagePath = __DIR__ . '/../../storage/curriculum'; diff --git a/backend/app/Views/StudentPortal.php b/backend/app/Views/StudentPortal.php index 23c3ac2..c7bf62b 100644 --- a/backend/app/Views/StudentPortal.php +++ b/backend/app/Views/StudentPortal.php @@ -1156,17 +1156,42 @@ class StudentPortal let userSelectedAnswers = []; document.addEventListener('DOMContentLoaded', async () => { - const token = localStorage.getItem('saqel_student_jwt'); - - if (token) { - // Always verify session with server — don't render from cache directly + const token = localStorage.getItem('saqel_student_jwt'); + const cachedUser = localStorage.getItem('saqel_student_user'); + + if (token && cachedUser) { + // ── FAST PATH ────────────────────────────────────────────────────── + // Render dashboard immediately from the cached user object so the + // student never sees the OTP form on a page refresh. + // The silent background revalidation below will logout if the token + // has actually expired on the server side. + try { + const user = JSON.parse(cachedUser); + if (user && user.is_completed !== false) { + renderDashboard(user); + initWebSocket(token); + } else { + switchToStudentOnboarding(); + } + } catch (_) { + // Corrupt cache — fall through to full server check + localStorage.removeItem('saqel_student_user'); + } + + // Silent background revalidation — does NOT block render + checkStudentSession(token).catch(() => {}); + + } else if (token) { + // Token exists but no cached user — wait for server validation initWebSocket(token); await checkStudentSession(token); + } else { + // No token at all — show auth form document.getElementById('auth_box_view').style.display = 'block'; } - // Initialize Player with Active Real Lesson + // Initialize Player with Active Real Lesson (runs regardless of auth state) initRealVideoPlayer(); // Video player time listener for Socratic checkpoint @@ -2103,6 +2128,7 @@ class StudentPortal } else { const token = data.data.token; localStorage.setItem('saqel_student_jwt', token); + localStorage.setItem('saqel_student_user', JSON.stringify(data.data.user)); renderDashboard(data.data.user); } } else { @@ -2256,6 +2282,7 @@ class StudentPortal if (!user.is_completed) { switchToStudentOnboarding(); } else { + // Refresh the localStorage cache so fast-path stays fresh localStorage.setItem('saqel_student_user', JSON.stringify(user)); renderDashboard(user); } diff --git a/backend/database_schema.sql b/backend/database_schema.sql index 32e98c3..df5089d 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -198,6 +198,9 @@ CREATE TABLE IF NOT EXISTS `courses` ( `price_jod` DECIMAL(8, 2) NOT NULL DEFAULT 35.00, `thumbnail_url` VARCHAR(500) DEFAULT NULL, `is_school_exclusive` TINYINT(1) NOT NULL DEFAULT 0, + `is_system_curriculum` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'الدورة الرئيسية للمنهاج الوزاري — تُستخدم لرفع فيديوهات الذكاء الاصطناعي', + `grade_level` VARCHAR(50) DEFAULT NULL COMMENT 'المرحلة الدراسية مثل: tawjihi_2008', + `stream` ENUM('scientific','literary','vocational','general') DEFAULT NULL COMMENT 'الفرع الدراسي', `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, @@ -205,6 +208,8 @@ CREATE TABLE IF NOT EXISTS `courses` ( KEY `idx_courses_subject` (`subject_id`), KEY `idx_courses_teacher` (`teacher_id`), KEY `idx_courses_school` (`school_id`), + KEY `idx_courses_grade_stream` (`grade_level`, `stream`), + KEY `idx_courses_system` (`is_system_curriculum`), CONSTRAINT `fk_courses_subject` FOREIGN KEY (`subject_id`) REFERENCES `subjects` (`id`) ON DELETE RESTRICT, CONSTRAINT `fk_courses_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_courses_school` FOREIGN KEY (`school_id`) REFERENCES `schools` (`id`) ON DELETE SET NULL