diff --git a/apps/student_app/lib/data/models/subject_model.dart b/apps/student_app/lib/data/models/subject_model.dart index e0d1c3e..d6bb8bd 100644 --- a/apps/student_app/lib/data/models/subject_model.dart +++ b/apps/student_app/lib/data/models/subject_model.dart @@ -234,24 +234,19 @@ class CurriculumLessonItemModel { : 'lesson_${(json['title']?.toString() ?? 'default').hashCode.abs()}'); // Video availability: - // 1. Explicitly false for introductory/project overviews - // 2. Physics & other subjects currently have no video -> false - // 3. Math 10 Lesson 1 ("حل معادلات خاصة") has the authentic lesson video -> true - final bool isIntroOrProject = (jsonId == 'intro_and_project') || - (filePath?.contains('intro_and_project') ?? false) || + // 1. Live server enriched: json['has_video'] == true or json['video_url'] is present + // 2. Unit 1 Math 10 lessons (Intro, Lesson 1, Lesson 2) uploaded by teacher are fully available + final bool hasVideoExplicit = (json['has_video'] == true) || + (json['video_url'] != null && json['video_url'].toString().isNotEmpty) || + (filePath != null && ( + filePath.contains('math_10/semester_1/unit_01') || + filePath.contains('intro_and_project') || + filePath.contains('lesson_01') || + filePath.contains('lesson_02') + )) || + ((json['title']?.toString() ?? '').contains('معادلات')) || ((json['title']?.toString() ?? '').contains('مقدمة ومشروع')); - final bool isMath10Lesson1 = !isIntroOrProject && ( - (filePath != null && filePath.contains('math_10/semester_1/unit_01/lesson_01')) || - ((json['title']?.toString() ?? '').contains('معادلاتٍ خاصّةٍ')) - ); - - final bool hasVideoExplicit = !isIntroOrProject && ( - (json['has_video'] == true) || - (json['video_url'] != null && json['video_url'].toString().isNotEmpty) || - isMath10Lesson1 - ); - return CurriculumLessonItemModel( id: stableId, title: json['title']?.toString() ?? 'درس بدون عنوان', diff --git a/apps/student_app/lib/data/repositories/curriculum_repository.dart b/apps/student_app/lib/data/repositories/curriculum_repository.dart index bc764ac..199f2b7 100644 --- a/apps/student_app/lib/data/repositories/curriculum_repository.dart +++ b/apps/student_app/lib/data/repositories/curriculum_repository.dart @@ -121,10 +121,14 @@ class CurriculumRepository { } /// Fetch lesson playback details (streams, checkpoints, and versions) from Live API - Future getLessonPlayback(String curriculumKey, {String? subjectId, String? unitId}) async { - AppLogger.log('Fetching live playback data for curriculum key $curriculumKey...', tag: 'CURRICULUM_REPO'); + Future getLessonPlayback(String curriculumKey, {String? subjectId, String? unitId, String? title}) async { + AppLogger.log('Fetching live playback data for curriculum key $curriculumKey (title: $title)...', tag: 'CURRICULUM_REPO'); - final res = await _api.get('/api/lessons/playback', queryParams: {'curriculum_key': curriculumKey}); + final Map queryParams = {'curriculum_key': curriculumKey}; + if (title != null && title.isNotEmpty) { + queryParams['title'] = title; + } + final res = await _api.get('/api/lessons/playback', queryParams: queryParams); if (res is Map && res['data'] != null) { return LessonPlaybackData.fromJson(Map.from(res['data'])); } diff --git a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart index ab36062..2da20e9 100644 --- a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart +++ b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart @@ -94,7 +94,7 @@ class VideoPlaybackCubit extends Cubit { try { final cleanKey = (lesson.markdownFilePath ?? lesson.id).replaceAll(RegExp(r'\.md$'), ''); - var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id); + var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id, title: lesson.title); if (playback.videoUrl.isEmpty) { throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.'); } diff --git a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart index 0b84901..1f8f078 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart @@ -814,13 +814,8 @@ class _SubjectHubScreenState extends State with SingleTickerPr ); } - /// Direct Launch of Socratic Video Player or Transparent No-Video Sheet + /// Direct Launch of Socratic Video Player void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) { - if (!lesson.hasVideo) { - _showNoVideoAvailableSheet(context, lesson); - return; - } - // Direct authentic launch: zero mockups, zero fake teacher names Navigator.of(context).push( CupertinoPageRoute( diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index ee33ca1..9d6564e 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -77,17 +77,21 @@ class VideoController } } else { $subjectName = trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? '')); - $gradeLevel = trim((string)($request->getBody()['grade_level'] ?? $_POST['grade_level'] ?? '')); + $rawGrade = trim((string)($request->getBody()['grade_level'] ?? $_POST['grade_level'] ?? '')); + $gradeLevel = !empty($rawGrade) ? \App\Services\StudentAccessControlService::normalizeGrade($rawGrade) : 'grade_10'; // The production schema uses subjects.name (not name_ar/name_en). // Match the teacher's grade first; the course subject is retained // as the canonical database relation. $existingCourse = Database::selectOne( - "SELECT id, teacher_id FROM courses WHERE teacher_id = ? AND grade_level = ? LIMIT 1", - [$request->user_id, $gradeLevel] + "SELECT id, teacher_id, grade_level FROM courses WHERE teacher_id = ? AND (grade_level = ? OR grade_level = ?) LIMIT 1", + [$request->user_id, $gradeLevel, $rawGrade] ); if ($existingCourse) { $courseId = (int)$existingCourse['id']; $course = $existingCourse; + if (!empty($gradeLevel) && ($existingCourse['grade_level'] ?? '') !== $gradeLevel) { + Database::query("UPDATE courses SET grade_level = ? WHERE id = ?", [$gradeLevel, $courseId]); + } } else { $subject = Database::selectOne( "SELECT id FROM subjects WHERE name = ? ORDER BY id LIMIT 1", @@ -521,35 +525,97 @@ class VideoController $curriculumKey = trim((string)($request->getQuery('curriculum_key') ?? '')); $curriculumKeyNoExt = preg_replace('/\.md$/i', '', $curriculumKey); + $title = trim((string)($request->getQuery('title') ?? '')); $rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? ''); $lesson = null; + $candidates = []; + + // 1. Match by curriculum_key if provided if ($curriculumKey !== '') { - $lesson = Database::selectOne( - "SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key = ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", - [$curriculumKey, $curriculumKeyNoExt, '%' . $curriculumKeyNoExt . '%', '%' . $curriculumKeyNoExt . '%'] + $baseKey = basename($curriculumKeyNoExt); + $matched = Database::select( + "SELECT * FROM lessons + WHERE curriculum_key = ? OR curriculum_key = ? + OR curriculum_key LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$curriculumKey, $curriculumKeyNoExt, '%' . $baseKey . '%', '%' . $curriculumKeyNoExt . '%', '%' . $curriculumKeyNoExt . '%'] ); - } elseif (is_numeric($rawId) && (int)$rawId > 0) { - $lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]); - } elseif (!empty($rawId)) { + if (!empty($matched)) { + $candidates = array_merge($candidates, $matched); + } + } + + // 2. Match by title if provided + if (!empty($title)) { + $cleanTitle = trim(preg_replace('/^(الدرس\s*\d+:\s*|معملُ\s*[^:]+:\s*|مقدمة\s*[^:]+:\s*)/u', '', $title)); + $matchedTitle = Database::select( + "SELECT * FROM lessons + WHERE title = ? OR title LIKE ? OR title LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$title, "%{$title}%", "%{$cleanTitle}%"] + ); + if (!empty($matchedTitle)) { + $candidates = array_merge($candidates, $matchedTitle); + } + } + + // 3. Match by numeric ID + if (is_numeric($rawId) && (int)$rawId > 0) { + $matchedId = Database::select("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]); + if (!empty($matchedId)) { + $candidates = array_merge($candidates, $matchedId); + } + } + + // 4. Manifest slug lookup + if (empty($candidates) && !empty($rawId)) { $rawIdNoExt = preg_replace('/\.md$/i', '', $rawId); - $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", [$rawId, $rawIdNoExt, "%{$rawId}%", "%{$rawIdNoExt}%", "%{$rawIdNoExt}%"]); - // Flutter curriculum lessons use the manifest slug (e.g. u1_l1_*), - // while the database stores the canonical lesson title. - if (!$lesson) { + $matchedRaw = Database::select( + "SELECT * FROM lessons + WHERE curriculum_key = ? OR curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$rawId, $rawIdNoExt, "%{$rawId}%", "%{$rawIdNoExt}%", "%{$rawIdNoExt}%"] + ); + if (!empty($matchedRaw)) { + $candidates = array_merge($candidates, $matchedRaw); + } + if (empty($candidates)) { $manifestLesson = CurriculumService::findLessonById($rawId); if ($manifestLesson && !empty($manifestLesson['title'])) { - $lesson = Database::selectOne( - "SELECT * FROM lessons WHERE title = ? OR markdown_content LIKE ? LIMIT 1", - [$manifestLesson['title'], '%' . ($manifestLesson['file'] ?? '') . '%'] + $matchedManifest = Database::select( + "SELECT * FROM lessons + WHERE title = ? OR title LIKE ? OR markdown_content LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$manifestLesson['title'], '%' . $manifestLesson['title'] . '%', '%' . ($manifestLesson['file'] ?? '') . '%'] ); + if (!empty($matchedManifest)) { + $candidates = array_merge($candidates, $matchedManifest); + } } } } + // Pick best candidate: prioritize one with valid hls_url + if (!empty($candidates)) { + foreach ($candidates as $cand) { + if (!empty($cand['hls_url'])) { + $lesson = $cand; + break; + } + } + if (!$lesson) { + $lesson = $candidates[0]; + } + } + if (!$lesson) { - // Fallback to latest available lesson in DB - $lesson = Database::selectOne("SELECT * FROM lessons ORDER BY id DESC LIMIT 1"); + // Fallback to latest available lesson in DB with a video + $lesson = Database::selectOne( + "SELECT * FROM lessons + WHERE hls_url IS NOT NULL AND hls_url != '' + ORDER BY id DESC LIMIT 1" + ); } if (!$lesson) { @@ -564,7 +630,7 @@ class VideoController // Strict Academic Access Control & Institutional Free / CliQ Paid Validation $course = Database::selectOne("SELECT * FROM courses WHERE id = ? LIMIT 1", [$lesson['course_id']]); - $targetGrade = $course['grade_level'] ?? 'grade_10'; + $targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10'); $studentId = $request->user_id ? (int)$request->user_id : null; $nationalId = $request->getHeader('x-national-id') ?: ($request->getQuery('national_id') ?? null); diff --git a/backend/app/Services/CurriculumService.php b/backend/app/Services/CurriculumService.php index 4e4893c..b24360e 100644 --- a/backend/app/Services/CurriculumService.php +++ b/backend/app/Services/CurriculumService.php @@ -190,14 +190,73 @@ class CurriculumService public static function getCurriculumTree(): array { self::ensureStorage(); - if (file_exists(self::$manifestFile)) { - $json = file_get_contents(self::$manifestFile); - $tree = json_decode($json, true); - if (is_array($tree)) { - return $tree; - } + if (!file_exists(self::$manifestFile)) { + return []; } - return []; + $json = file_get_contents(self::$manifestFile); + $tree = json_decode($json, true); + if (!is_array($tree)) { + return []; + } + + // Dynamically enrich manifest nodes with live uploaded videos from MySQL + try { + $dbLessons = \App\Core\Database::select( + "SELECT id, title, curriculum_key, hls_url, duration_seconds + FROM lessons + WHERE hls_url IS NOT NULL AND hls_url != ''" + ); + + if (!empty($dbLessons)) { + $attachVideos = function (&$node) use (&$attachVideos, $dbLessons) { + if (!is_array($node)) return; + if (isset($node['lessons']) && is_array($node['lessons'])) { + foreach ($node['lessons'] as &$lesson) { + if (!is_array($lesson)) continue; + $lessonId = (string)($lesson['id'] ?? ''); + $lessonFile = (string)($lesson['file'] ?? ''); + $lessonFileNoExt = preg_replace('/\.md$/i', '', $lessonFile); + $lessonTitle = (string)($lesson['title'] ?? ''); + + foreach ($dbLessons as $dbl) { + $currKey = (string)($dbl['curriculum_key'] ?? ''); + $currKeyNoExt = preg_replace('/\.md$/i', '', $currKey); + $dbTitle = (string)($dbl['title'] ?? ''); + + $matched = false; + if ($currKey !== '' && ($currKey === $lessonFile || $currKeyNoExt === $lessonFileNoExt)) { + $matched = true; + } elseif ($currKeyNoExt !== '' && str_ends_with($currKeyNoExt, $lessonId)) { + $matched = true; + } elseif ($dbTitle !== '' && ($dbTitle === $lessonTitle || str_contains($lessonTitle, $dbTitle) || str_contains($dbTitle, $lessonTitle))) { + $matched = true; + } + + if ($matched) { + $lesson['has_video'] = true; + $lesson['video_url'] = $dbl['hls_url']; + if (!empty($dbl['duration_seconds'])) { + $lesson['duration_seconds'] = (int)$dbl['duration_seconds']; + } + break; + } + } + } + } + foreach ($node as &$child) { + if (is_array($child)) { + $attachVideos($child); + } + } + }; + + $attachVideos($tree); + } + } catch (\Throwable $e) { + error_log("Enrich curriculum tree notice: " . $e->getMessage()); + } + + return $tree; } /** Resolve a manifest lesson slug to its canonical title/file. */ diff --git a/backend/app/Services/StudentAccessControlService.php b/backend/app/Services/StudentAccessControlService.php index 79b4c7f..490cea5 100644 --- a/backend/app/Services/StudentAccessControlService.php +++ b/backend/app/Services/StudentAccessControlService.php @@ -306,14 +306,14 @@ class StudentAccessControlService public static function normalizeGrade(string $grade): string { $g = strtolower(trim($grade)); - if ($g === '10' || $g === 'grade_10' || $g === 'grade10' || $g === 'عاشر' || $g === 'الصف العاشر') return 'grade_10'; - if ($g === '9' || $g === 'grade_9' || $g === 'grade9' || $g === 'تاسع' || $g === 'الصف التاسع') return 'grade_9'; - if ($g === '8' || $g === 'grade_8' || $g === 'grade8' || $g === 'ثامن' || $g === 'الصف الثامن') return 'grade_8'; - if ($g === '7' || $g === 'grade_7' || $g === 'grade7' || $g === 'سابع' || $g === 'الصف السابع') return 'grade_7'; - if ($g === '6' || $g === 'grade_6' || $g === 'grade6' || $g === 'سادس' || $g === 'الصف السادس') return 'grade_6'; - if ($g === '5' || $g === 'grade_5' || $g === 'grade5' || $g === 'خامس' || $g === 'الصف الخامس') return 'grade_5'; - if ($g === '11' || $g === 'grade_11' || $g === 'حادي عشر' || $g === 'أول ثانوي') return 'grade_11'; - if ($g === '12' || $g === 'grade_12' || $g === 'tawjihi' || $g === 'tawjihi_2008' || $g === 'توجيهي') return 'grade_12'; + if (str_contains($g, 'عاشر') || str_contains($g, 'grade_10') || str_contains($g, 'grade10') || str_contains($g, 'grade 10') || $g === '10') return 'grade_10'; + if (str_contains($g, 'تاسع') || str_contains($g, 'grade_9') || str_contains($g, 'grade9') || str_contains($g, 'grade 9') || $g === '9') return 'grade_9'; + if (str_contains($g, 'ثامن') || str_contains($g, 'grade_8') || str_contains($g, 'grade8') || str_contains($g, 'grade 8') || $g === '8') return 'grade_8'; + if (str_contains($g, 'سابع') || str_contains($g, 'grade_7') || str_contains($g, 'grade7') || str_contains($g, 'grade 7') || $g === '7') return 'grade_7'; + if (str_contains($g, 'سادس') || str_contains($g, 'grade_6') || str_contains($g, 'grade6') || str_contains($g, 'grade 6') || $g === '6') return 'grade_6'; + if (str_contains($g, 'خامس') || str_contains($g, 'grade_5') || str_contains($g, 'grade5') || str_contains($g, 'grade 5') || $g === '5') return 'grade_5'; + if (str_contains($g, 'حادي') || str_contains($g, 'أول ثانوي') || str_contains($g, 'grade_11') || str_contains($g, 'grade 11') || $g === '11') return 'grade_11'; + if (str_contains($g, 'ثاني عشر') || str_contains($g, 'توجيهي') || str_contains($g, 'tawjihi') || str_contains($g, 'grade_12') || str_contains($g, 'grade 12') || $g === '12') return 'grade_12'; return $g; }