Harden published curriculum and student flows
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user