Harden published curriculum and student flows

This commit is contained in:
Hamza-Ayed
2026-09-30 08:22:17 +03:00
parent e8163fe265
commit ce7b0fcf14
53 changed files with 4723 additions and 3317 deletions
+160 -109
View File
@@ -29,6 +29,50 @@ use App\Services\VideoReviewService;
class VideoController
{
public function legacyPlaybackUnavailable(Request $request, Response $response): void
{
$response->status(410)->json([
'status' => 'version_required',
'message' => 'استخدم نسخة الفيديو المنشورة المحددة بهوية الدرس المنهجي.',
]);
}
private function mayStreamVideo(Request $request, string $videoUuid): bool
{
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $videoUuid)) return false;
if ($request->role === 'super_admin') return true;
if ($request->role === 'teacher') {
$owner = Database::selectOne(
"SELECT l.id FROM lessons l JOIN courses c ON c.id=l.course_id
JOIN teachers t ON t.id=c.teacher_id
WHERE l.video_uuid=? AND t.identity_id=? LIMIT 1",
[$videoUuid, (int)$request->identity_id]
);
return (bool)$owner;
}
if ($request->role !== 'student') return false;
$row = Database::selectOne(
"SELECT l.id AS lesson_id,l.course_id,cl.grade_key
FROM lessons l JOIN video_versions vv ON vv.source_lesson_id=l.id AND vv.status='published'
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 AND cl.source_status='approved'
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
AND a.review_status='approved' AND a.rights_status='cleared'
WHERE l.video_uuid=? AND l.encoding_status='ready' LIMIT 1",
[$videoUuid]
);
if (!$row) return false;
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
(int)$request->user_id, null,
\App\Services\StudentAccessControlService::normalizeGrade($row['grade_key']),
(int)$row['course_id'], (int)$row['lesson_id'], false
);
return !empty($access['allowed']);
}
/** POST /api/video-versions/{versionId}/watch-sessions */
public function startWatchSession(Request $request, Response $response): void
{
@@ -60,7 +104,12 @@ class VideoController
cl.uuid AS curriculum_lesson_id, cl.title, cl.grade_key, 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 curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved'
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
AND a.review_status='approved' AND a.rights_status='cleared'
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]
);
@@ -206,43 +255,74 @@ class VideoController
try {
$lesson = Database::selectOne("SELECT id, uuid, title, grade_key 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,
cl.grade_key, c.grade_level AS course_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 curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
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) {
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
(int)$request->user_id,
$request->getHeader('x-national-id'),
$targetGrade,
(int)$row['course_id'],
(int)$row['source_lesson_id'],
false
);
if (!empty($access['allowed'])) $accessible[] = $row;
// Scan in bounded batches: entitlement is per source lesson/course,
// so a SQL LIMIT before the access check would hide later teachers.
$pdo = Database::getConnection();
$ownsTransaction = !$pdo->inTransaction();
if ($ownsTransaction) {
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$pdo->beginTransaction();
}
// `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]]);
try {
$batchSize = 100;
$rawOffset = 0;
$availableCount = 0;
$page = [];
do {
$rows = Database::select(
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
cl.grade_key, l.course_id, l.duration_seconds, vv.published_at,
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 curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
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 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'
AND EXISTS (
SELECT 1 FROM video_review_jobs j
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
AND a.review_status='approved' AND a.rights_status='cleared'
WHERE j.video_version_id=vv.id AND j.status='approved'
)
ORDER BY (COALESCE(pm.total_reviews_count, 0) > 0) DESC,
COALESCE(pm.weighted_student_rating, 0) DESC, vv.id ASC
LIMIT {$batchSize} OFFSET {$rawOffset}",
[$lesson['id']]
);
foreach ($rows as $row) {
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
(int)$request->user_id,
$request->getHeader('x-national-id'),
$targetGrade,
(int)$row['course_id'],
(int)$row['source_lesson_id'],
false
);
if (empty($access['allowed'])) continue;
if ($availableCount >= $cursor && count($page) < $limit) $page[] = $row;
$availableCount++;
}
$rawOffset += count($rows);
} while (count($rows) === $batchSize);
if ($ownsTransaction) $pdo->commit();
} catch (\Throwable $e) {
if ($ownsTransaction && $pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
$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,
'duration_seconds'=>(int)$r['duration_seconds'], 'published_at'=>$r['published_at'],
], $page);
$nextCursor = $cursor + count($page);
$response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>$availableCount,'items'=>$items,'next_cursor'=>$nextCursor < $availableCount ? $nextCursor : null]]);
} catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); }
}
/** POST /api/teacher/submissions/preflight */
@@ -649,8 +729,8 @@ class VideoController
public function streamLocalVideo(Request $request, Response $response): void
{
$uuid = $request->getParam('uuid');
if (empty($uuid)) {
$response->status(400)->json(['status' => 'error', 'message' => 'معرف الفيديو مطلوب']);
if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) {
$response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']);
return;
}
@@ -666,8 +746,8 @@ class VideoController
$uuid = $request->getParam('uuid');
$file = $request->getParam('file') ?: 'index.m3u8';
if (empty($uuid)) {
$response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']);
if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) {
$response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']);
return;
}
@@ -682,13 +762,14 @@ class VideoController
{
$body = $request->getBody();
$lessonId = (int)($body['lesson_id'] ?? 0);
$timeSeconds = (int)($body['timestamp_seconds'] ?? 15);
$rewindSecs = (int)($body['rewind_seconds'] ?? 45);
$timeSeconds = (int)($body['timestamp_seconds'] ?? 0);
$rewindSecs = (int)($body['rewind_seconds'] ?? 0);
$question = trim((string)($body['question_text'] ?? ''));
$options = (array)($body['options'] ?? []);
$correctIdx = (int)($body['correct_index'] ?? 0);
if (!$lessonId || empty($question) || empty($options)) {
if (!$lessonId || $question === '' || count($options) < 2 || $correctIdx < 0 || $correctIdx >= count($options) ||
$timeSeconds <= 0 || $rewindSecs < 0 || array_filter($options, fn($option) => !is_string($option) || trim($option) === '') !== []) {
$response->status(400)->json([
'status' => 'error',
'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة'
@@ -696,11 +777,19 @@ class VideoController
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]);
$lesson = Database::selectOne("SELECT l.id, l.course_id, l.duration_seconds, 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;
}
if ($timeSeconds >= (int)$lesson['duration_seconds']) {
$response->status(422)->json(['status'=>'error','message'=>'توقيت السؤال خارج مدة الفيديو']);
return;
}
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
$examUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
@@ -712,7 +801,7 @@ class VideoController
$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)",
VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'مسودة سؤال فيديو', ?, ?, 100.00, 10, 1, 0)",
[$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs]
);
@@ -736,10 +825,15 @@ class VideoController
[$qId, $optText, $isCorrect]
);
}
$pdo->commit();
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
$response->status(201)->json([
'status' => 'success',
'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!',
'message' => 'حُفظ السؤال كمسودة، ويحتاج ربطاً بدليل نسخة الفيديو ومراجعة قبل ظهوره للطالب.',
'data' => [
'exam_id' => $examId,
'timestamp_seconds' => $timeSeconds,
@@ -790,6 +884,23 @@ class VideoController
$conditions[] = $gradeCond;
}
// Do not expose legacy lesson rows as student video offerings. The
// exact published version must have approved, version-bound review
// evidence tied to the currently published curriculum Markdown.
$conditions[] = "EXISTS (
SELECT 1
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 AND cl.source_status='approved'
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
AND a.review_status='approved' AND a.rights_status='cleared'
WHERE vv.source_lesson_id=l.id AND vv.status='published'
)";
$whereClause = !empty($conditions) ? ('WHERE ' . implode(' AND ', $conditions)) : '';
$lessons = Database::select(
@@ -962,69 +1073,9 @@ class VideoController
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]
);
// Legacy exams have no video-version evidence binding. They must not
// mutate storage during GET or be exposed as approved checkpoints.
$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 = [];