Implement verified curriculum, video review, watch sessions, ledger safeguards, and remove demo data

This commit is contained in:
Hamza-Ayed
2026-09-09 17:04:08 +03:00
parent 0dc5df7b5e
commit 5af684a892
49 changed files with 1809 additions and 3005 deletions
+199 -19
View File
@@ -24,9 +24,169 @@ use App\Core\Security;
use App\Services\VideoService;
use App\Services\AiVideoAnalyzerService;
use App\Services\CurriculumService;
use App\Services\TeacherSubmissionService;
use App\Services\VideoReviewService;
class VideoController
{
/** POST /api/video-versions/{versionId}/watch-sessions */
public function startWatchSession(Request $request, Response $response): void
{
try {
$result = \App\Services\WatchSessionService::start((int)$request->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
@@ -54,6 +214,7 @@ class VideoController
$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([
@@ -62,6 +223,26 @@ class VideoController
]);
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;
@@ -177,6 +358,12 @@ class VideoController
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'])
);
// Immediately send HTTP 201 response to Flutter client and close connection
$responsePayload = [
@@ -210,10 +397,8 @@ class VideoController
$uploadResult['mime_type']
);
// 2. Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation
AiVideoAnalyzerService::processLessonAutonomously($lessonId);
// 3. Mark encoding as ready
// 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());
@@ -672,17 +857,8 @@ class VideoController
}
}
// If lesson has no checkpoints or missing questions, generate them autonomously
$existingCount = Database::selectOne("SELECT COUNT(*) as cnt FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
$existingQuestions = Database::selectOne("SELECT COUNT(*) as cnt FROM questions q JOIN exams e ON q.exam_id = e.id WHERE e.lesson_id = ?", [$lessonId]);
if (empty($existingCount['cnt']) || empty($existingQuestions['cnt'])) {
try {
AiVideoAnalyzerService::processLessonAutonomously($lessonId);
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
} catch (\Throwable $e) {
error_log("Autonomous video analysis 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(
@@ -696,17 +872,21 @@ class VideoController
$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 = [];
if ($q) {
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
$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' => $q ? (int)$q['id'] : 0,
'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'] ?? 'سؤال فحص فهم الفكرة:',
'question_text' => $q['question_text'],
'explanation' => $q['explanation_text'] ?? '',
'options' => array_map(function ($o) {
return [