user_id, trim((string)$request->getParam('versionId','')), $request->getHeader('x-national-id')); $response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true])); } catch (\Throwable $e) { error_log('Watch session start failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر بدء جلسة المشاهدة.']); } } /** POST /api/watch-sessions/{sessionId}/events */ public function recordWatchEvent(Request $request, Response $response): void { $body=$request->getBody(); try { $result = \App\Services\WatchSessionService::event((int)$request->user_id,trim((string)$request->getParam('sessionId','')),(int)($body['sequence_no']??0),trim((string)($body['event_type']??'')),(int)($body['position_ms']??-1),is_array($body['payload']??null)?$body['payload']:[]); $response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true])); } catch (\Throwable $e) { error_log('Watch event failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ حدث المشاهدة.']); } } /** GET /api/video-versions/{versionId}/playback */ public function getVideoVersionPlayback(Request $request, Response $response): void { $versionUuid = trim((string)$request->getParam('versionId', '')); if (!preg_match('/^[0-9a-f-]{36}$/i', $versionUuid)) { $response->status(400)->json(['status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.']); return; } try { $row = Database::selectOne( "SELECT vv.uuid AS video_version_id, l.id AS lesson_id, l.course_id, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, l.duration_seconds, cl.uuid AS curriculum_lesson_id, cl.title, ts.uuid AS submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid] ); if (!$row) { $response->status(404)->json(['status'=>'error','message'=>'نسخة الفيديو المطلوبة غير منشورة أو سُحبت.']); return; } $course=Database::selectOne('SELECT grade_level FROM courses WHERE id=? LIMIT 1',[$row['course_id']]); $access=\App\Services\StudentAccessControlService::validateLessonAccess((int)$request->user_id, $request->getHeader('x-national-id'), \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10'), (int)$row['course_id'], (int)$row['lesson_id']); if (empty($access['allowed'])) { $response->status(403)->json(['status'=>'forbidden','message'=>$access['message'] ?? 'غير مصرح بمشاهدة هذه الحصة.']); return; } if ($row['storage_type']==='api_upload') $playback=['storage_type'=>'api_upload','video_url'=>$row['hls_url'] ?: '/api/videos/stream/'.$row['video_uuid'],'hls_url'=>$row['hls_url'] ?: '/api/videos/hls/'.$row['video_uuid'].'/index.m3u8']; elseif (!empty($row['bunny_video_id'])) $playback=array_merge(['storage_type'=>'bunny_stream'],VideoService::generateBunnySignedPlayback($row['bunny_video_id'],10800)); elseif (!empty($row['ai_video_url'])) $playback=['storage_type'=>'direct_url','video_url'=>$row['ai_video_url'],'hls_url'=>$row['ai_video_url']]; else { $response->status(409)->json(['status'=>'error','message'=>'تخزين نسخة الفيديو غير جاهز.']); return; } $response->json(['status'=>'success','data'=>['video_version_id'=>$row['video_version_id'],'submission_id'=>$row['submission_id'],'curriculum_lesson_id'=>$row['curriculum_lesson_id'],'lesson_id'=>(int)$row['lesson_id'],'title'=>$row['title'],'duration_seconds'=>(int)$row['duration_seconds'],'playback'=>$playback,'checkpoints'=>[]]]); } catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); } } /** GET /api/curriculum/lessons/{lessonId}/videos?cursor=&limit= */ public function listPublishedLessonVideos(Request $request, Response $response): void { $lessonUuid = trim((string)$request->getParam('lessonId', '')); $cursor = max(0, (int)$request->getQuery('cursor', 0)); $limit = min(50, max(1, (int)$request->getQuery('limit', 20))); if (!preg_match('/^[0-9a-f-]{36}$/i', $lessonUuid)) { $response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return; } try { $lesson = Database::selectOne("SELECT id, uuid, title FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]); if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; } $rows = Database::select( "SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id, c.grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating, COALESCE(pm.total_reviews_count, 0) AS rating_count FROM teacher_submissions ts JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published' JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready' JOIN courses c ON c.id = l.course_id JOIN teachers t ON t.id = ts.teacher_id LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published' ORDER BY (rating_count > 0) DESC, rating DESC, vv.id ASC LIMIT 101", [$lesson['id']] ); // A list is also protected content: do not disclose a teacher, count, // or version that the student cannot play. A curriculum lesson may // legitimately have versions attached to different courses. $accessible = []; foreach ($rows as $row) { $access = \App\Services\StudentAccessControlService::validateLessonAccess( (int)$request->user_id, $request->getHeader('x-national-id'), \App\Services\StudentAccessControlService::normalizeGrade($row['grade_level'] ?? 'grade_10'), (int)$row['course_id'], (int)$row['source_lesson_id'], false ); if (!empty($access['allowed'])) $accessible[] = $row; } // `cursor` is an offset in the stable rating order, rather than a // database id (an id seek would skip records after rating changes). $page = array_slice($accessible, $cursor, $limit); $hasMore = count($accessible) > ($cursor + count($page)); $items = array_map(static fn($r) => ['video_version_id'=>$r['video_version_id'],'submission_id'=>$r['submission_id'],'teacher_name'=>$r['teacher_name'],'rating'=>(float)$r['rating'],'rating_count'=>(int)$r['rating_count'],'is_new'=>(int)$r['rating_count']===0], $page); $response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>count($accessible),'items'=>$items,'next_cursor'=>$hasMore ? $cursor + count($page) : null]]); } catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); } } /** POST /api/teacher/submissions/preflight */ public function preflightSubmission(Request $request, Response $response): void { $body = $request->getBody(); $lessonId = trim((string)($body['curriculum_lesson_id'] ?? '')); $replacement = !empty($body['replacement']); $submissionId = isset($body['submission_id']) ? trim((string)$body['submission_id']) : null; $key = trim((string)$request->getHeader('idempotency-key', '')); try { $result = TeacherSubmissionService::preflight((int)$request->user_id, $lessonId, $key, $replacement, $submissionId); $response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true])); } catch (\Throwable $e) { error_log('Teacher submission preflight failed: ' . $e->getMessage()); $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر إنشاء نسخة الحصة حالياً.']); } } /** POST /api/admin/video-review/publish; super-admin only route. */ public function publishReviewedVersion(Request $request, Response $response): void { $body = $request->getBody(); $jobId = trim((string)($body['review_job_id'] ?? '')); $expected = isset($body['expected_current_version_id']) && $body['expected_current_version_id'] !== '' ? trim((string)$body['expected_current_version_id']) : null; try { $result = VideoReviewService::publishApproved($jobId, $expected); $response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true])); } catch (\Throwable $e) { error_log('Video publication failed: ' . $e->getMessage()); $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر نشر نسخة الفيديو حالياً.']); } } /** POST /api/admin/video-review/decision; super-admin only route. */ public function recordVideoReviewDecision(Request $request, Response $response): void { $body=$request->getBody(); try { $result=VideoReviewService::recordHumanReview(trim((string)($body['review_job_id']??'')),(int)$request->user_id,trim((string)($body['recommendation']??'')),trim((string)($body['decision']??'')),is_array($body['report']??null)?$body['report']:[]); $response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true])); } catch(\Throwable $e){error_log('Video review decision failed: '.$e->getMessage());$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ قرار المراجعة.']);} } /** POST /api/admin/video-review/evidence; super-admin only route. */ public function submitVideoReviewEvidence(Request $request, Response $response): void { $body = $request->getBody(); try { $result = VideoReviewService::submitEvidence( trim((string)($body['review_job_id'] ?? '')), (int)$request->user_id, trim((string)($body['transcript_asset_id'] ?? '')), trim((string)($body['visual_evidence_asset_id'] ?? '')), is_array($body['coverage'] ?? null) ? $body['coverage'] : [] ); $response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true])); } catch (\Throwable $e) { error_log('Video review evidence failed: ' . $e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ أدلة فحص الفيديو.']); } } /** * رفع فيديو الشرح مباشرة عبر واجهة البرمجة وتوليد HLS وبدء التحليل السقراطي بالذكاء الاصطناعي * POST /api/teacher/videos/upload-direct * * @param Request $request طلب الـ HTTP المحتوي على ملف الفيديو والبيانات الوصفية * @param Response $response كائن الاستجابة بحالة الرفع ومعرف الدرس ورابط المشاهدة */ public function uploadDirect(Request $request, Response $response): void { VideoService::ensureSchema(); CurriculumService::ensureSchema(); // PHP discards both multipart fields and files when post_max_size is // exceeded. Detect that case before reporting a misleading missing title. $contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); if ($contentLength > 0 && empty($_POST) && empty($_FILES)) { $response->status(413)->json([ 'status' => 'error', 'message' => 'حجم الفيديو تجاوز حد الرفع المسموح على الخادم (' . ini_get('post_max_size') . '). ارفع حدّي post_max_size وupload_max_filesize ثم أعد المحاولة.', ]); return; } $courseId = (int)($request->getBody()['course_id'] ?? $_POST['course_id'] ?? 0); $title = trim((string)($request->getBody()['title'] ?? $_POST['title'] ?? '')); $seqOrder = (int)($request->getBody()['sequence_order'] ?? $_POST['sequence_order'] ?? 1); $curriculumKey = trim((string)($request->getBody()['curriculum_key'] ?? $_POST['curriculum_key'] ?? '')); $videoVersionId = trim((string)($request->getBody()['video_version_id'] ?? $_POST['video_version_id'] ?? '')); if (empty($title)) { $response->status(400)->json([ 'status' => 'error', 'message' => 'عنوان الدرس مطلوب' ]); return; } if ($videoVersionId === '') { $response->status(409)->json([ 'status' => 'submission_preflight_required', 'message' => 'أنشئ نسخة حصة عبر preflight قبل رفع الفيديو.', ]); return; } try { if (!TeacherSubmissionService::reserveUpload((int)$request->user_id, $videoVersionId)) { $response->status(409)->json([ 'status' => 'video_version_unavailable', 'message' => 'نسخة الفيديو استُخدمت أو لم تعد متاحة للرفع. أنشئ نسخة مرشحة جديدة.', ]); return; } } catch (\Throwable $e) { error_log('Video version reservation failed: ' . $e->getMessage()); $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر حجز نسخة الفيديو للرفع.']); return; } // Resolve a teacher-owned course when the app did not explicitly select one. $course = null; if ($courseId > 0) { $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); if (!$course) { $response->status(404)->json(['status' => 'error', 'message' => 'الدورة المطلوبة غير موجودة']); return; } if ((int)$course['teacher_id'] !== (int)$request->user_id && $request->role !== 'super_admin') { $response->status(403)->json(['status' => 'error', 'message' => 'لا تملك صلاحية رفع محتوى لهذه الدورة']); return; } } else { $subjectName = trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? '')); $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, 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", [$subjectName] ); if (!$subject && $subjectName !== '') { // The curriculum tree is the source of truth. When its // subject has not yet been seeded into SQL, create the // minimal canonical subject record instead of attaching // the teacher's lesson to an unrelated first subject. $subjectCode = 'CURR-' . strtoupper(substr(hash('sha256', $subjectName), 0, 12)); try { $subjectId = (int)Database::insert( "INSERT INTO subjects (name, code, stream, is_active) VALUES (?, ?, 'common', 1)", [$subjectName, $subjectCode] ); $subject = ['id' => $subjectId]; } catch (\Throwable $e) { $subject = Database::selectOne("SELECT id FROM subjects WHERE name = ? LIMIT 1", [$subjectName]); } } if (!$subject) { $response->status(409)->json(['status' => 'error', 'message' => 'لا يوجد مبحث معرف لربط الفيديو به']); return; } $cUuid = 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)); $newCourseId = Database::insert( "INSERT INTO courses (uuid, teacher_id, subject_id, title, description, semester, price_jod, is_published, grade_level) VALUES (?, ?, ?, ?, '', 'first', 0.00, 0, ?)", [$cUuid, $request->user_id, (int)$subject['id'], $title, $gradeLevel] ); $courseId = $newCourseId; $course = ['id' => $newCourseId, 'teacher_id' => $request->user_id]; } } if (empty($_FILES['video'])) { $response->status(400)->json([ 'status' => 'error', 'message' => 'يرجى إرفاق ملف الفيديو في الطلب (key: video)' ]); return; } // No Cloudflare R2 write occurs before this fail-closed media gate. // The report is generated from the real uploaded file while it remains // in PHP's temporary storage. $preflight = AiVideoAnalyzerService::auditUploadBeforeStorage( $_FILES['video'], $title, trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? '')) ); $auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight); if (($preflight['decision'] ?? '') !== 'approved') { // The candidate contains no accepted media yet, so make it reusable. // Do not leave a failed local quality gate blocking a future upload. TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId); $response->status(422)->json([ 'status' => 'needs_review', 'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.', 'data' => ['audit_id' => $auditId, 'preflight_report' => $preflight], ]); return; } $reviewQueued = false; try { // Fast direct upload: save file locally and slice HLS instantly via stream copy (-c copy) $uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title, false); // Insert lesson record with HLS references and processing status $lessonId = (int)Database::insert( "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) VALUES (?, ?, ?, ?, 'api_upload', ?, '', ?, ?, ?, ?, 0, 'processing')", [ $courseId, $title, $curriculumKey !== '' ? $curriculumKey : null, $seqOrder, $uploadResult['video_uuid'], $uploadResult['local_path'], $uploadResult['hls_url'], $uploadResult['thumbnail_url'], $uploadResult['duration'] ?? 0 ] ); if ($auditId) { Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]); } TeacherSubmissionService::attachUploadedLesson( (int)$request->user_id, $videoVersionId, $lessonId, hash_file('sha256', (string)$_FILES['video']['tmp_name']) ); $reviewQueued = true; // Immediately send HTTP 201 response to Flutter client and close connection $responsePayload = [ 'status' => 'success', 'message' => 'تم رفع الفيديو إلى طابور المراجعة. لن يظهر للطلاب قبل اكتمال الأدلة وقرار المراجع البشري.', 'data' => array_merge($uploadResult, [ 'lesson_id' => $lessonId, 'title' => $title, 'course_id' => $courseId, 'preflight_report' => $preflight, 'audit_id' => $auditId, ]) ]; $response->jsonAndFinish($responsePayload, 201); // ------------------------------------------------------------------------- // ASYNCHRONOUS BACKGROUND WORKER (PHP-FPM continues running after client exit) // ------------------------------------------------------------------------- @ignore_user_abort(true); @set_time_limit(600); try { // 1. Sync MP4, HLS segments, and thumbnail to Cloudflare R2 VideoService::syncLessonToR2( $lessonId, $courseId, $uploadResult['target_path'], $uploadResult['target_file_name'], $uploadResult['video_uuid'], $uploadResult['mime_type'] ); // 2. Storage readiness is not publication. The evidence-bound // review job controls any later release of this version. Database::query("UPDATE lessons SET encoding_status = 'ready' WHERE id = ?", [$lessonId]); } catch (\Throwable $bgError) { error_log("Background sync/analysis error for lesson {$lessonId}: " . $bgError->getMessage()); } exit; } catch (\Throwable $e) { if (!$reviewQueued) { TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId); } $response->status(500)->json([ 'status' => 'error', 'message' => $e->getMessage() ]); } } private function recordUploadAudit(Request $request, int $courseId, array $file, array $report): ?int { try { $hex = bin2hex(random_bytes(16)); $uuid = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) . '-a' . substr($hex, 17, 3) . '-' . substr($hex, 20); return (int)Database::insert( 'INSERT INTO video_upload_audits (uuid, teacher_id, course_id, file_sha256, original_filename, decision, report_json) VALUES (?, ?, ?, ?, ?, ?, ?)', [$uuid, (int)$request->user_id, $courseId ?: null, hash_file('sha256', (string)($file['tmp_name'] ?? '')), (string)($file['name'] ?? 'video'), (string)($report['decision'] ?? 'needs_manual_review'), json_encode($report, JSON_UNESCAPED_UNICODE)] ); } catch (\Throwable $e) { error_log('Video upload audit persistence failed: ' . $e->getMessage()); return null; } } /** * Create video entity on Bunny Stream * POST /api/teacher/videos/bunny-create */ public function createBunnyVideo(Request $request, Response $response): void { VideoService::ensureSchema(); $body = $request->getBody(); $title = trim((string)($body['title'] ?? 'درس جديد')); $courseId = (int)($body['course_id'] ?? 0); if (!$courseId) { $response->status(400)->json(['status' => 'error', 'message' => 'معرف الدورة مطلوب']); return; } try { $result = VideoService::createBunnyVideo($title); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم إنشاء الفيديو في Bunny Stream بنجاح', 'data' => $result ]); } catch (\Throwable $e) { $response->status(500)->json([ 'status' => 'error', 'message' => $e->getMessage() ]); } } /** * Link Bunny Video ID to a Course Lesson with Autonomous AI Socratic Generation * POST /api/teacher/videos/bunny-link */ public function linkBunnyLesson(Request $request, Response $response): void { VideoService::ensureSchema(); CurriculumService::ensureSchema(); $body = $request->getBody(); $courseId = (int)($body['course_id'] ?? 0); $title = trim((string)($body['title'] ?? '')); $bunnyVideoId = trim((string)($body['bunny_video_id'] ?? '')); $duration = (int)($body['duration_seconds'] ?? 600); $sequenceOrder = (int)($body['sequence_order'] ?? 1); if (!$courseId || empty($title) || empty($bunnyVideoId)) { $response->status(400)->json([ 'status' => 'error', 'message' => 'معرف الدورة، عنوان الدرس، ومعرف فيديو Bunny Stream مطلوبين' ]); return; } $course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); if (!$course || ($course['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { $response->status(403)->json([ 'status' => 'error', 'message' => 'غير مصرح: لا تملك هذه الدورة' ]); return; } $lessonId = Database::insert( "INSERT INTO lessons (course_id, title, sequence_order, storage_type, bunny_video_id, duration_seconds, is_free_preview, encoding_status) VALUES (?, ?, ?, 'bunny_stream', ?, ?, 0, 'ready')", [$courseId, $title, $sequenceOrder, $bunnyVideoId, $duration] ); // Autonomous AI Analysis for Bunny Lessons $aiReport = AiVideoAnalyzerService::processLessonAutonomously($lessonId); $response->status(201)->json([ 'status' => 'success', 'message' => 'تم ربط درس Bunny Stream وتوليد نقاط الفحص السقراطي تلقائياً!', 'data' => [ 'lesson_id' => $lessonId, 'bunny_video_id' => $bunnyVideoId, 'storage_type' => 'bunny_stream', 'ai_analysis' => $aiReport ] ]); } /** * Stream Local Video via HTTP 206 Range Streaming * GET /api/videos/stream/{uuid} */ public function streamLocalVideo(Request $request, Response $response): void { $uuid = $request->getParam('uuid'); if (empty($uuid)) { $response->status(400)->json(['status' => 'error', 'message' => 'معرف الفيديو مطلوب']); return; } VideoService::streamLocalVideo($uuid); } /** * Stream HLS Playlist or Video Segments * GET /api/videos/hls/{uuid}/{file} */ public function streamHls(Request $request, Response $response): void { $uuid = $request->getParam('uuid'); $file = $request->getParam('file') ?: 'index.m3u8'; if (empty($uuid)) { $response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']); return; } VideoService::streamHlsFile($uuid, $file); } /** * Save Socratic Checkpoint Quiz inside a Video Lesson * POST /api/teacher/lessons/checkpoints */ public function saveCheckpoint(Request $request, Response $response): void { $body = $request->getBody(); $lessonId = (int)($body['lesson_id'] ?? 0); $timeSeconds = (int)($body['timestamp_seconds'] ?? 15); $rewindSecs = (int)($body['rewind_seconds'] ?? 45); $question = trim((string)($body['question_text'] ?? '')); $options = (array)($body['options'] ?? []); $correctIdx = (int)($body['correct_index'] ?? 0); if (!$lessonId || empty($question) || empty($options)) { $response->status(400)->json([ 'status' => 'error', 'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة' ]); return; } $lesson = Database::selectOne("SELECT l.id, l.course_id, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]); if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { $response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']); return; } $examUuid = 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) ); $examId = Database::insert( "INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published) VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'فحص سقراطي لحظي', ?, ?, 100.00, 10, 1, 1)", [$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs] ); $qUuid = 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) ); $qId = Database::insert( "INSERT INTO questions (uuid, exam_id, question_text, question_type, bloom_taxonomy, points) VALUES (?, ?, ?, 'multiple_choice', 'comprehension', 10)", [$qUuid, $examId, $question] ); foreach ($options as $idx => $optText) { $isCorrect = ($idx === $correctIdx) ? 1 : 0; Database::insert( "INSERT INTO question_options (question_id, option_text, is_correct) VALUES (?, ?, ?)", [$qId, $optText, $isCorrect] ); } $response->status(201)->json([ 'status' => 'success', 'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!', 'data' => [ 'exam_id' => $examId, 'timestamp_seconds' => $timeSeconds, 'question_id' => $qId ] ]); } /** * List all published lessons for student portal * GET /api/student/lessons */ public function getStudentLessons(Request $request, Response $response): void { VideoService::ensureSchema(); CurriculumService::ensureSchema(); $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, 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 LEFT JOIN teachers 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' => array_values($lessons), 'meta' => [ 'grade_level' => $gradeLevel, 'stream' => $stream, 'ai_count' => count($aiLessons), 'teacher_count' => count($teacherLessons), ] ]); } /** * Get Lesson Playback Data with Chapters Roadmap and Socratic Checkpoints * GET /api/lessons/{id}/playback */ public function getPlaybackData(Request $request, Response $response): void { VideoService::ensureSchema(); CurriculumService::ensureSchema(); $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 !== '') { if (str_contains($curriculumKeyNoExt, '/')) { // Structured hierarchical key: match exact or qualified path suffix $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, "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%"] ); } else { $matched = Database::select( "SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key LIKE ? ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", [$curriculumKey, "%/{$curriculumKeyNoExt}%"] ); } 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)); if (mb_strlen($cleanTitle) >= 6) { $matchedTitle = Database::select( "SELECT * FROM lessons WHERE title = ? OR title LIKE ? ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", [$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)) { $manifestLesson = CurriculumService::findLessonById($rawId); if ($manifestLesson && !empty($manifestLesson['file'])) { $fileNoExt = preg_replace('/\.md$/i', '', $manifestLesson['file']); $matchedManifest = Database::select( "SELECT * FROM lessons WHERE curriculum_key = ? OR curriculum_key = ? OR local_path LIKE ? ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", [$manifestLesson['file'], $fileNoExt, "%{$fileNoExt}%"] ); 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; } } } // Strict verification: if no matching lesson with an uploaded video is found, return 404 if (!$lesson || empty($lesson['hls_url'])) { $response->status(404)->json([ 'status' => 'error', 'message' => 'لم يتم رفع ونشر فيديو شرح لهذا الدرس بعد.' ]); return; } $lessonId = (int)$lesson['id']; // Strict Academic Access Control & Institutional Free / CliQ Paid Validation $course = Database::selectOne("SELECT * FROM courses WHERE id = ? LIMIT 1", [$lesson['course_id']]); $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); $access = \App\Services\StudentAccessControlService::validateLessonAccess( $studentId, $nationalId, $targetGrade, (int)$lesson['course_id'], $lessonId ); if (!$access['allowed']) { $response->status(403)->json([ 'status' => 'forbidden', 'access_denied' => true, 'reason' => $access['reason'], 'message' => $access['message'] ?? 'غير مصرح بمشاهدة هذا الدرس', 'student_grade' => $access['student_grade'] ?? null, 'target_grade' => $access['target_grade'] ?? null, 'payment_info' => (($access['reason'] ?? '') === 'payment_required') ? [ 'payment_method' => 'CliQ (نظام كليك الأردني للمدفوعات الفورية)', 'cliq_alias' => \App\Services\CliqPaymentService::DEFAULT_PLATFORM_CLIQ_ALIAS, 'price_jod' => (float)($course['price_jod'] ?? 35.0), 'initiate_url' => '/api/payment/cliq/initiate' ] : null ]); return; } // Self-Healing Curriculum Guard: Purge any obsolete/mismatched calculus questions // for non-calculus lessons (e.g. Grade 10 Systems of Equations) $isCalculusLesson = (str_contains($lesson['title'], 'اشتقاق') || str_contains($lesson['title'], 'تفاضل')); if (!$isCalculusLesson) { try { $mismatched = Database::selectOne( "SELECT q.id FROM questions q JOIN exams e ON q.exam_id = e.id WHERE e.lesson_id = ? AND (q.question_text LIKE '%مشتق%' OR q.question_text LIKE '%f\'(x)%') LIMIT 1", [$lessonId] ); if ($mismatched) { $badExams = Database::select("SELECT id FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]); foreach ($badExams as $be) { Database::query("DELETE FROM exams WHERE id = ?", [$be['id']]); } } } catch (\Throwable $e) { error_log("Curriculum self-healing notice: " . $e->getMessage()); } } // Playback is read-only. Checkpoints are published only by the review // workflow after it verifies the video transcript and lesson Markdown. // Fetch attached in-video Socratic Checkpoints with Questions and Options $exams = Database::select( "SELECT e.id as exam_id, e.uuid as exam_uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage FROM exams e WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1 ORDER BY e.timestamp_seconds ASC", [$lessonId] ); $checkpoints = []; foreach ($exams as $ex) { $q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]); if (!$q) { continue; } $opts = []; $opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]); if (count($opts) < 2) { continue; } $checkpoints[] = [ 'exam_id' => (int)$ex['exam_id'], 'question_id' => (int)$q['id'], 'timestamp_seconds' => (int)$ex['timestamp_seconds'], 'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'], 'question_text' => $q['question_text'], 'explanation' => $q['explanation_text'] ?? '', 'options' => array_map(function ($o) { return [ 'id' => (int)$o['id'], 'text' => $o['option_text'], 'is_correct' => (bool)$o['is_correct'] ]; }, $opts) ]; } $storageType = $lesson['storage_type'] ?? 'bunny_stream'; $playbackInfo = []; if ($storageType === 'api_upload' && !empty($lesson['video_uuid'])) { $playbackInfo = [ 'storage_type' => 'api_upload', 'stream_url' => '/api/videos/stream/' . $lesson['video_uuid'], 'hls_url' => $lesson['hls_url'] ?: ('/api/videos/hls/' . $lesson['video_uuid'] . '/index.m3u8'), 'video_url' => $lesson['hls_url'] ?: ('/api/videos/stream/' . $lesson['video_uuid']), 'video_uuid' => $lesson['video_uuid'], 'is_direct' => true, 'is_ready' => true, ]; } elseif (!empty($lesson['bunny_video_id'])) { // Bunny Stream Signed Playback $bunnyId = $lesson['bunny_video_id']; $signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token $playbackInfo = array_merge(['storage_type' => 'bunny_stream', 'is_ready' => true], $signedData); } elseif (!empty($lesson['ai_video_url'])) { $playbackInfo = [ 'storage_type' => 'direct_url', 'video_url' => $lesson['ai_video_url'], 'hls_url' => $lesson['ai_video_url'], 'stream_url' => $lesson['ai_video_url'], 'is_ready' => true, ]; } elseif (!empty($lesson['hls_url'])) { $playbackInfo = [ 'storage_type' => 'hls_stream', 'video_url' => $lesson['hls_url'], 'hls_url' => $lesson['hls_url'], 'stream_url' => $lesson['hls_url'], 'is_ready' => true, ]; } else { $response->status(409)->json(['status' => 'error', 'message' => 'لم يتم ربط فيديو R2 جاهز بهذا الدرس بعد']); return; } $chapters = !empty($lesson['timeline_chapters_json']) ? json_decode($lesson['timeline_chapters_json'], true) : []; // Find available versions (AI vs Teacher specific) $lessonTitle = $lesson['title']; $versionsRaw = Database::select( "SELECT l.id, l.course_id, l.title, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, c.teacher_id, u.full_name as teacher_name, s.name as school_name FROM lessons l LEFT JOIN courses c ON l.course_id = c.id LEFT JOIN teachers u ON c.teacher_id = u.id LEFT JOIN schools s ON c.school_id = s.id WHERE l.title = ? AND l.encoding_status = 'ready'", [$lessonTitle] ); $availableVersions = []; $studentSchool = $request->user['school_name'] ?? ''; // if student's school is in the token foreach ($versionsRaw as $ver) { $isAi = ($ver['course_id'] == 0 || $ver['course_id'] == null); $vPlayback = []; if ($ver['storage_type'] === 'api_upload') { $vPlayback = [ 'storage_type' => 'api_upload', 'stream_url' => '/api/videos/stream/' . $ver['video_uuid'], 'hls_url' => $ver['hls_url'] ?: ('/api/videos/hls/' . $ver['video_uuid'] . '/index.m3u8'), 'video_url' => $ver['ai_video_url'] ?: ($ver['hls_url'] ?: ('/api/videos/stream/' . $ver['video_uuid'])) ]; } else { $bId = $ver['bunny_video_id'] ?: ''; if ($bId === '') { continue; } else { $signed = VideoService::generateBunnySignedPlayback($bId, 10800); $vPlayback = array_merge(['storage_type' => 'bunny_stream', 'video_url' => $signed['hls_url']], $signed); } } $label = $isAi ? 'فيديو الذكاء الاصطناعي الأساسي 🤖' : 'شرح الأستاذ ' . $ver['teacher_name']; $isRecommended = (!$isAi && !empty($studentSchool) && $ver['school_name'] === $studentSchool); if ($isRecommended) { $label .= ' (مدرستك 🏫)'; } $availableVersions[] = [ 'lesson_id' => (int)$ver['id'], 'is_ai' => $isAi, 'teacher_name' => $ver['teacher_name'], 'school_name' => $ver['school_name'], 'label' => $label, 'is_recommended' => $isRecommended, 'playback' => $vPlayback ]; } if (empty($availableVersions)) { $availableVersions[] = [ 'lesson_id' => $lessonId, 'is_ai' => true, 'teacher_name' => 'منصة صَقِل الرقمية', 'school_name' => 'المركز التعليمي المعتمد', 'label' => 'الشرح الرقمي الرسمي المعتمد 🤖', 'is_recommended' => true, 'playback' => $playbackInfo ]; } // Sort: Recommended first, then AI, then others usort($availableVersions, function($a, $b) { if ($a['is_recommended'] && !$b['is_recommended']) return -1; if (!$a['is_recommended'] && $b['is_recommended']) return 1; if ($a['is_ai'] && !$b['is_ai']) return -1; if (!$a['is_ai'] && $b['is_ai']) return 1; return 0; }); $response->json([ 'status' => 'success', 'data' => [ 'lesson' => [ 'id' => (int)$lesson['id'], 'course_id' => (int)$lesson['course_id'], 'title' => $lesson['title'], 'duration_seconds' => (int)$lesson['duration_seconds'], 'is_free_preview' => (bool)$lesson['is_free_preview'], 'storage_type' => $storageType ], 'playback' => $playbackInfo, 'available_versions' => $availableVersions, 'chapters' => $chapters ?: [], 'checkpoints' => $checkpoints ?: [] ] ]); } /** Save real playback progress; the client never owns the readiness score. */ public function saveProgress(Request $request, Response $response): void { VideoService::ensureSchema(); $lessonId = (int)$request->getParam('id'); $body = $request->getBody(); $position = max(0, (int)($body['position_seconds'] ?? 0)); $watched = max(0, (int)($body['watched_seconds'] ?? $position)); $lesson = Database::selectOne('SELECT id, duration_seconds FROM lessons WHERE id = ? LIMIT 1', [$lessonId]); if (!$lesson) { $response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']); return; } $duration = max(1, (int)$lesson['duration_seconds']); $percentage = min(100, round(($position / $duration) * 100, 2)); $completed = $percentage >= 90 ? 1 : 0; Database::query( "INSERT INTO lesson_progress (student_id, lesson_id, position_seconds, watched_seconds, completion_percentage, is_completed, last_seen_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), CASE WHEN ? = 1 THEN NOW() ELSE NULL END) ON DUPLICATE KEY UPDATE position_seconds = VALUES(position_seconds), watched_seconds = GREATEST(watched_seconds, VALUES(watched_seconds)), completion_percentage = VALUES(completion_percentage), is_completed = GREATEST(is_completed, VALUES(is_completed)), last_seen_at = NOW(), completed_at = CASE WHEN is_completed = 1 OR VALUES(is_completed) = 1 THEN COALESCE(completed_at, NOW()) ELSE completed_at END", [$request->user_id, $lessonId, $position, $watched, $percentage, $completed, $completed] ); $response->json(['status' => 'success', 'data' => ['completion_percentage' => $percentage, 'is_completed' => (bool)$completed]]); } /** * Webhook listener for Bunny Stream encoding notifications * POST /api/webhooks/bunny */ public function handleBunnyWebhook(Request $request, Response $response): void { VideoService::ensureSchema(); $body = $request->getBody(); $videoId = trim((string)($body['VideoGuid'] ?? $body['videoId'] ?? '')); $status = (int)($body['Status'] ?? 0); if (!empty($videoId)) { $encodingStatus = ($status === 3) ? 'ready' : (($status === 4) ? 'failed' : 'processing'); Database::query( "UPDATE lessons SET encoding_status = ? WHERE bunny_video_id = ?", [$encodingStatus, $videoId] ); } $response->json(['status' => 'received']); } }