Update Saqel Platform: 2026-08-29 22:41:36
This commit is contained in:
@@ -261,7 +261,10 @@ class CurriculumController
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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');
|
$title = basename($file, '.md');
|
||||||
|
|
||||||
// 1. Upload & Transcode to HLS & Push to R2 (handled by VideoService)
|
// 1. Upload & Transcode to HLS & Push to R2 (handled by VideoService)
|
||||||
@@ -269,7 +272,7 @@ class CurriculumController
|
|||||||
|
|
||||||
$videoUrl = $uploadResult['r2_url'] ?? $uploadResult['hls_url'];
|
$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(
|
$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)
|
"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', ?)",
|
VALUES (?, ?, 1, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'ready', ?)",
|
||||||
@@ -307,6 +310,8 @@ class CurriculumController
|
|||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'تم رفع وتثبيت فيديو الشرح ومعالجته HLS وربطه بالذكاء الاصطناعي بنجاح!',
|
'message' => 'تم رفع وتثبيت فيديو الشرح ومعالجته HLS وربطه بالذكاء الاصطناعي بنجاح!',
|
||||||
'video_url' => $videoUrl,
|
'video_url' => $videoUrl,
|
||||||
|
'course_id' => $courseId,
|
||||||
|
'lesson_id' => $lessonId,
|
||||||
'data' => $uploadResult
|
'data' => $uploadResult
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -307,22 +307,72 @@ class VideoController
|
|||||||
CurriculumService::ensureSchema();
|
CurriculumService::ensureSchema();
|
||||||
|
|
||||||
$courseId = (int)($request->getQuery('course_id') ?: 0);
|
$courseId = (int)($request->getQuery('course_id') ?: 0);
|
||||||
$where = $courseId > 0 ? "WHERE l.course_id = {$courseId}" : "";
|
|
||||||
|
// 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(
|
$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,
|
l.duration_seconds, l.is_free_preview, l.created_at,
|
||||||
COALESCE(c.title, 'توجيهي 2008 — المنهاج المعتمد') as course_title,
|
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
|
(SELECT COUNT(*) FROM exams WHERE lesson_id = l.id AND scope = 'in_video_checkpoint') as checkpoints_count
|
||||||
FROM lessons l
|
FROM lessons l
|
||||||
LEFT JOIN courses c ON l.course_id = c.id
|
LEFT JOIN courses c ON l.course_id = c.id
|
||||||
{$where}
|
LEFT JOIN users u ON c.teacher_id = u.id
|
||||||
ORDER BY l.id DESC"
|
{$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([
|
$response->json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'data' => $lessons
|
'data' => array_values($lessons),
|
||||||
|
'meta' => [
|
||||||
|
'grade_level' => $gradeLevel,
|
||||||
|
'stream' => $stream,
|
||||||
|
'ai_count' => count($aiLessons),
|
||||||
|
'teacher_count' => count($teacherLessons),
|
||||||
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,156 @@ class CurriculumService
|
|||||||
*/
|
*/
|
||||||
public static function ensureSchema(): void
|
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';
|
private static string $storagePath = __DIR__ . '/../../storage/curriculum';
|
||||||
|
|||||||
@@ -1157,16 +1157,41 @@ class StudentPortal
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const token = localStorage.getItem('saqel_student_jwt');
|
const token = localStorage.getItem('saqel_student_jwt');
|
||||||
|
const cachedUser = localStorage.getItem('saqel_student_user');
|
||||||
|
|
||||||
if (token) {
|
if (token && cachedUser) {
|
||||||
// Always verify session with server — don't render from cache directly
|
// ── 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);
|
initWebSocket(token);
|
||||||
await checkStudentSession(token);
|
await checkStudentSession(token);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
// No token at all — show auth form
|
||||||
document.getElementById('auth_box_view').style.display = 'block';
|
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();
|
initRealVideoPlayer();
|
||||||
|
|
||||||
// Video player time listener for Socratic checkpoint
|
// Video player time listener for Socratic checkpoint
|
||||||
@@ -2103,6 +2128,7 @@ class StudentPortal
|
|||||||
} else {
|
} else {
|
||||||
const token = data.data.token;
|
const token = data.data.token;
|
||||||
localStorage.setItem('saqel_student_jwt', token);
|
localStorage.setItem('saqel_student_jwt', token);
|
||||||
|
localStorage.setItem('saqel_student_user', JSON.stringify(data.data.user));
|
||||||
renderDashboard(data.data.user);
|
renderDashboard(data.data.user);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2256,6 +2282,7 @@ class StudentPortal
|
|||||||
if (!user.is_completed) {
|
if (!user.is_completed) {
|
||||||
switchToStudentOnboarding();
|
switchToStudentOnboarding();
|
||||||
} else {
|
} else {
|
||||||
|
// Refresh the localStorage cache so fast-path stays fresh
|
||||||
localStorage.setItem('saqel_student_user', JSON.stringify(user));
|
localStorage.setItem('saqel_student_user', JSON.stringify(user));
|
||||||
renderDashboard(user);
|
renderDashboard(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ CREATE TABLE IF NOT EXISTS `courses` (
|
|||||||
`price_jod` DECIMAL(8, 2) NOT NULL DEFAULT 35.00,
|
`price_jod` DECIMAL(8, 2) NOT NULL DEFAULT 35.00,
|
||||||
`thumbnail_url` VARCHAR(500) DEFAULT NULL,
|
`thumbnail_url` VARCHAR(500) DEFAULT NULL,
|
||||||
`is_school_exclusive` TINYINT(1) NOT NULL DEFAULT 0,
|
`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,
|
`is_published` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_subject` (`subject_id`),
|
||||||
KEY `idx_courses_teacher` (`teacher_id`),
|
KEY `idx_courses_teacher` (`teacher_id`),
|
||||||
KEY `idx_courses_school` (`school_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_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_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
|
CONSTRAINT `fk_courses_school` FOREIGN KEY (`school_id`) REFERENCES `schools` (`id`) ON DELETE SET NULL
|
||||||
|
|||||||
Reference in New Issue
Block a user