diff --git a/apps/student_app/lib/data/repositories/curriculum_repository.dart b/apps/student_app/lib/data/repositories/curriculum_repository.dart index 730673e..211cc5d 100644 --- a/apps/student_app/lib/data/repositories/curriculum_repository.dart +++ b/apps/student_app/lib/data/repositories/curriculum_repository.dart @@ -127,10 +127,10 @@ class CurriculumRepository { } /// Fetch lesson playback details (streams, checkpoints, and versions) from Live API - Future getLessonPlayback(String lessonId, {String? subjectId, String? unitId}) async { - AppLogger.log('Fetching live playback data for lesson $lessonId from /api/lessons/$lessonId/playback...', tag: 'CURRICULUM_REPO'); + Future getLessonPlayback(String curriculumKey, {String? subjectId, String? unitId}) async { + AppLogger.log('Fetching live playback data for curriculum key $curriculumKey...', tag: 'CURRICULUM_REPO'); - final res = await _api.get('/api/lessons/$lessonId/playback'); + final res = await _api.get('/api/lessons/playback', queryParams: {'curriculum_key': curriculumKey}); 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 4f29d71..f86e6c4 100644 --- a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart +++ b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart @@ -78,7 +78,7 @@ class VideoPlaybackCubit extends Cubit { AppLogger.log('Loading Socratic playback for lesson: ${lesson.title}', tag: 'VIDEO_CUBIT'); emit(VideoPlaybackLoading()); try { - final playback = await _repo.getLessonPlayback(lesson.id, subjectId: subject?.id); + final playback = await _repo.getLessonPlayback(lesson.markdownFilePath ?? lesson.id, subjectId: subject?.id); emit(VideoPlaybackReady( playbackData: playback, lessonItem: lesson, diff --git a/backend/app/Controllers/CurriculumController.php b/backend/app/Controllers/CurriculumController.php index d48c67d..04fb1aa 100644 --- a/backend/app/Controllers/CurriculumController.php +++ b/backend/app/Controllers/CurriculumController.php @@ -267,8 +267,8 @@ class CurriculumController $manifestLesson = CurriculumService::findLessonByFile($file); $title = $manifestLesson['title'] ?? basename($file, '.md'); $existingLesson = \App\Core\Database::selectOne( - "SELECT id, course_id FROM lessons WHERE title = ? OR markdown_content LIKE ? LIMIT 1", - [$title, '%' . $file . '%'] + "SELECT id, course_id FROM lessons WHERE curriculum_key = ? OR title = ? OR markdown_content LIKE ? LIMIT 1", + [$file, $title, '%' . $file . '%'] ); $courseId = $existingLesson ? (int)$existingLesson['course_id'] @@ -283,14 +283,14 @@ class CurriculumController if ($existingLesson) { $lessonId = (int)$existingLesson['id']; \App\Core\Database::query( - "UPDATE lessons SET storage_type = 'api_upload', video_uuid = ?, bunny_video_id = '', local_path = ?, hls_url = ?, thumbnail_url = ?, duration_seconds = ?, encoding_status = 'ready', ai_video_url = ? WHERE id = ?", - [$uploadResult['video_uuid'], $uploadResult['local_path'], $uploadResult['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0, $videoUrl, $lessonId] + "UPDATE lessons SET curriculum_key = ?, storage_type = 'api_upload', video_uuid = ?, bunny_video_id = '', local_path = ?, hls_url = ?, thumbnail_url = ?, duration_seconds = ?, encoding_status = 'ready', ai_video_url = ? WHERE id = ?", + [$file, $uploadResult['video_uuid'], $uploadResult['local_path'], $uploadResult['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0, $videoUrl, $lessonId] ); } else { $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', ?)", - [$courseId, $title, $uploadResult['video_uuid'], $uploadResult['local_path'], $uploadResult['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0, $videoUrl] + "INSERT INTO lessons (course_id, title, curriculum_key, 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', ?)", + [$courseId, $title, $file, $uploadResult['video_uuid'], $uploadResult['local_path'], $uploadResult['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0, $videoUrl] ); } diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index 4e0e474..d726a86 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -385,13 +385,16 @@ class VideoController VideoService::ensureSchema(); CurriculumService::ensureSchema(); - $rawId = $request->getParam('id') ?? ''; + $curriculumKey = trim((string)($request->getQuery('curriculum_key') ?? '')); + $rawId = $curriculumKey !== '' ? $curriculumKey : ($request->getParam('id') ?? ''); $lesson = null; - if (is_numeric($rawId) && (int)$rawId > 0) { + if ($curriculumKey !== '') { + $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? LIMIT 1", [$curriculumKey]); + } elseif (is_numeric($rawId) && (int)$rawId > 0) { $lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [(int)$rawId]); } elseif (!empty($rawId)) { - $lesson = Database::selectOne("SELECT * FROM lessons WHERE title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", ["%{$rawId}%", "%{$rawId}%", "%{$rawId}%"]); + $lesson = Database::selectOne("SELECT * FROM lessons WHERE curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? LIMIT 1", [$rawId, "%{$rawId}%", "%{$rawId}%", "%{$rawId}%"]); // Flutter curriculum lessons use the manifest slug (e.g. u1_l1_*), // while the database stores the canonical lesson title. if (!$lesson) { diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index f81effb..8792955 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -37,6 +37,12 @@ class VideoService Database::query("ALTER TABLE lessons ADD COLUMN video_uuid CHAR(36) NULL AFTER storage_type"); } + $colsCurriculumKey = Database::select("SHOW COLUMNS FROM lessons LIKE 'curriculum_key'"); + if (empty($colsCurriculumKey)) { + Database::query("ALTER TABLE lessons ADD COLUMN curriculum_key VARCHAR(500) NULL AFTER title"); + Database::query("ALTER TABLE lessons ADD INDEX idx_lessons_curriculum_key (curriculum_key)"); + } + $colsPath = Database::select("SHOW COLUMNS FROM lessons LIKE 'local_path'"); if (empty($colsPath)) { Database::query("ALTER TABLE lessons ADD COLUMN local_path VARCHAR(500) NULL AFTER bunny_video_id"); diff --git a/backend/database_schema.sql b/backend/database_schema.sql index c840574..0103ff9 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -222,6 +222,7 @@ CREATE TABLE IF NOT EXISTS `lessons` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `course_id` BIGINT UNSIGNED NOT NULL, `title` VARCHAR(255) NOT NULL, + `curriculum_key` VARCHAR(500) DEFAULT NULL COMMENT 'المسار الفريد لدرس الـManifest داخل storage/curriculum', `sequence_order` INT UNSIGNED NOT NULL DEFAULT 1, -- المعلم الحقيقي (Teacher Video) @@ -249,6 +250,7 @@ CREATE TABLE IF NOT EXISTS `lessons` ( `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_lessons_course` (`course_id`), + KEY `idx_lessons_curriculum_key` (`curriculum_key`), KEY `idx_lessons_video_uuid` (`video_uuid`), CONSTRAINT `fk_lessons_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/public/index.php b/backend/public/index.php index 8a973a2..9331647 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -114,6 +114,7 @@ $router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoCon $router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons']); $router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo']); $router->get('/api/videos/hls/{uuid}/{file}', [\App\Controllers\VideoController::class, 'streamHls']); +$router->get('/api/lessons/playback', [\App\Controllers\VideoController::class, 'getPlaybackData']); $router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'getPlaybackData']); // Student & Teacher Chat Routes (API-Driven, Authenticated) diff --git a/backend/scripts/sync_curriculum_to_database.php b/backend/scripts/sync_curriculum_to_database.php index f0c8be3..e5bd2d8 100644 --- a/backend/scripts/sync_curriculum_to_database.php +++ b/backend/scripts/sync_curriculum_to_database.php @@ -9,6 +9,10 @@ use App\Services\CurriculumService; echo "=== SAQEL ENTERPRISE CURRICULUM DATABASE SYNC ===\n"; +// Keep an existing production database compatible with the canonical +// curriculum-key linkage before querying or inserting synchronized lessons. +\App\Services\VideoService::ensureSchema(); + $manifestFile = __DIR__ . '/../storage/curriculum/manifest.json'; if (!file_exists($manifestFile)) { die("Error: manifest.json not found at $manifestFile\n"); @@ -99,24 +103,24 @@ foreach ($manifest as $gradeKey => $grade) { $cheatSheet = $les['cheat_sheet'] ?? null; $socraticQuiz = isset($les['socratic_quiz']) ? json_encode($les['socratic_quiz'], JSON_UNESCAPED_UNICODE) : null; - // Match existing lesson by course_id and title or file path + // The manifest file path is the canonical, globally unique lesson key. $dbLesson = Database::selectOne( - "SELECT id FROM lessons WHERE course_id = ? AND title = ? LIMIT 1", - [$courseId, $lessonTitle] + "SELECT id FROM lessons WHERE curriculum_key = ? OR (course_id = ? AND title = ?) LIMIT 1", + [$filePath, $courseId, $lessonTitle] ); if (!$dbLesson) { $lesId = Database::insert( - "INSERT INTO lessons (course_id, title, sequence_order, ai_video_url, markdown_content, cheat_sheet_markdown, socratic_quiz_json, is_free_preview) - VALUES (?, ?, ?, ?, ?, ?, ?, 1)", - [$courseId, $lessonTitle, $seqOrder, $aiVideoUrl, $mdContent, $cheatSheet, $socraticQuiz] + "INSERT INTO lessons (course_id, title, curriculum_key, sequence_order, ai_video_url, markdown_content, cheat_sheet_markdown, socratic_quiz_json, is_free_preview) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)", + [$courseId, $lessonTitle, $filePath, $seqOrder, $aiVideoUrl, $mdContent, $cheatSheet, $socraticQuiz] ); echo " 📄 [NEW] Lesson inserted: {$lessonTitle} (ID: {$lesId})\n"; } else { $lesId = (int)$dbLesson['id']; Database::query( - "UPDATE lessons SET sequence_order = ?, markdown_content = ?, ai_video_url = COALESCE(?, ai_video_url), cheat_sheet_markdown = COALESCE(?, cheat_sheet_markdown), socratic_quiz_json = COALESCE(?, socratic_quiz_json) WHERE id = ?", - [$seqOrder, $mdContent, $aiVideoUrl, $cheatSheet, $socraticQuiz, $lesId] + "UPDATE lessons SET curriculum_key = ?, sequence_order = ?, markdown_content = ?, ai_video_url = COALESCE(?, ai_video_url), cheat_sheet_markdown = COALESCE(?, cheat_sheet_markdown), socratic_quiz_json = COALESCE(?, socratic_quiz_json) WHERE id = ?", + [$filePath, $seqOrder, $mdContent, $aiVideoUrl, $cheatSheet, $socraticQuiz, $lesId] ); echo " 🔄 [UPDATED] Lesson: {$lessonTitle} (ID: {$lesId})\n"; }