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
+38 -56
View File
@@ -517,10 +517,9 @@ class AuthController
if ($user) {
$rawGrade = (string)($user['grade_level'] ?? 'grade_10');
$normGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
if ($rawGrade === 'tawjihi_2008' || $rawGrade !== $normGrade) {
Database::query("UPDATE students SET grade_level = ?, updated_at = NOW() WHERE id = ?", [$normGrade, $userId]);
$user['grade_level'] = $normGrade;
}
// Profile reads may normalize presentation, but must not rewrite
// a student's academic record as a side effect of GET.
$user['grade_level'] = $normGrade;
$isCompleted = !empty($user['full_name']) && $user['full_name'] !== 'طالب جديد' && $user['full_name'] !== 'الطالب المتميز' && !empty($user['grade_level']);
$userData = [
'id' => $user['id'],
@@ -586,11 +585,6 @@ class AuthController
$student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]);
if ($student) {
if (($student['grade_level'] ?? '') === 'tawjihi_2008') {
Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [$student['id']]);
$student['grade_level'] = 'grade_10';
}
// Student exists. Verify identity linkage.
if ($student['identity_id'] === null) {
// Pre-registered by Guardian, link to this identity phone now
@@ -736,41 +730,14 @@ class AuthController
null
);
} else {
// Legacy / Fallback for already logged-in students updating profile
$studentId = (int)$request->user_id;
if (!$studentId) {
$authHeader = $request->getHeader('authorization', '');
if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
$payload = Security::verifyJWT($matches[1]);
if ($payload && isset($payload['user_id'])) {
$studentId = (int)$payload['user_id'];
}
}
}
if (!$studentId) {
$response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']);
return;
}
Database::query(
"UPDATE students SET full_name = ?, grade_level = ?, stream = ?, national_id = IF(? != '', ?, national_id), national_id_hash = IF(? != '', ?, national_id_hash), updated_at = NOW() WHERE id = ?",
[$fullName, $gradeLevel, $stream, $nationalId, Security::encrypt($nationalId), $nationalId, Security::blindIndex($nationalId), $studentId]
);
$student = Database::selectOne("SELECT * FROM students WHERE id = ? LIMIT 1", [$studentId]);
$response->json([
'status' => 'success',
'message' => 'تم استكمال ملف الطالب بنجاح! مرحباً بك في منصة صَقِل',
'user' => [
'id' => $student['id'],
'uuid' => $student['uuid'],
'full_name' => $student['full_name'],
'grade_level' => $student['grade_level'],
'stream' => $student['stream'],
'role' => 'student'
]
// This endpoint accepts only a pending student-identity proof.
// A bare JWT here previously bypassed the active-session and role
// checks and allowed changing a national ID without re-verification.
$response->status(401)->json([
'status' => 'identity_proof_required',
'message' => 'إكمال الملف يتطلب رمز التحقق من الهوية. لتغيير صف طالب مستقل استخدم مسار تغيير الصف المصادق.',
]);
return;
}
}
@@ -781,30 +748,45 @@ class AuthController
public function updateStudentGrade(Request $request, Response $response): void
{
$studentId = (int)$request->user_id;
if (!$studentId) {
$authHeader = $request->getHeader('authorization', '');
if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
$payload = Security::verifyJWT($matches[1]);
if ($payload && isset($payload['user_id'])) {
$studentId = (int)$payload['user_id'];
}
}
}
if (!$studentId) {
$response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']);
return;
}
$body = $request->getBody();
$rawGrade = trim((string)($body['grade_level'] ?? 'grade_10'));
$stream = trim((string)($body['stream'] ?? 'general'));
$rawGrade = trim((string)($body['grade_level'] ?? ''));
$stream = trim((string)($body['stream'] ?? ''));
$normalizedGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
if (!preg_match('/^grade_(?:[1-9]|1[0-2])$/', $normalizedGrade)
|| !in_array($stream, ['scientific', 'literary', 'vocational', 'general'], true)) {
$response->status(400)->json(['status' => 'error', 'message' => 'الصف أو المسار غير صالح.']);
return;
}
Database::query(
"UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW() WHERE id = ?",
$scope = Database::selectOne('SELECT id, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]);
if (!$scope) {
$response->status(404)->json(['status' => 'error', 'message' => 'طالب غير موجود']);
return;
}
if (!empty($scope['school_id']) || !empty($scope['is_school_sponsored'])) {
$response->status(403)->json(['status' => 'forbidden', 'message' => 'صف الطالب المدرسي يحدّثه مسؤول المدرسة من كشف الانتساب.']);
return;
}
$changed = Database::execute(
"UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW()
WHERE id = ? AND school_id IS NULL AND is_school_sponsored = 0",
[$normalizedGrade, $stream, $studentId]
);
if ($changed !== 1) {
$after = Database::selectOne('SELECT grade_level, stream, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]);
if (!$after || !empty($after['school_id']) || !empty($after['is_school_sponsored'])
|| (string)$after['grade_level'] !== $normalizedGrade || (string)$after['stream'] !== $stream) {
$response->status(409)->json(['status' => 'conflict', 'message' => 'تغير نطاق حساب الطالب؛ أعد تحميل ملفك الدراسي.']);
return;
}
}
$student = Database::selectOne(
"SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream, s.readiness_score, s.school_id, ai.phone_number, ai.status, s.created_at
+187 -33
View File
@@ -26,6 +26,8 @@ use App\Services\CurriculumService;
use App\Services\CurriculumExtractorService;
use App\Services\PublishedContentService;
use App\Services\LearningPackageService;
use App\Services\PublishedCurriculumTreeFilter;
use App\Services\AssetDownloadTicketService;
class CurriculumController
{
@@ -207,13 +209,23 @@ class CurriculumController
$removeUnpublishedResources($tree);
try {
$approvedLessons = Database::select("SELECT id, uuid, source_manifest_path FROM curriculum_lessons WHERE source_status='approved'");
$approvedLessons = Database::select(
"SELECT cl.id, cl.uuid, cl.source_manifest_path, a.uuid AS primary_asset_id
FROM curriculum_lessons cl
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.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared'
WHERE cl.source_status='approved'
ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC"
);
$byPath = [];
$lessonMap = [];
foreach ($approvedLessons as $row) {
$p = (string)$row['source_manifest_path'];
$byPath[$p] = [
'curriculum_lesson_id' => (string)$row['uuid'],
'primary_lesson_asset_id' => (string)$row['primary_asset_id'],
'has_video' => false,
];
$lessonMap[(int)$row['id']] = $p;
@@ -224,7 +236,13 @@ class CurriculumController
$videoCounts = Database::select(
"SELECT ts.curriculum_lesson_id, COUNT(vv.id) AS video_count
FROM teacher_submissions ts
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved'
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
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 ts.status = 'published'
GROUP BY ts.curriculum_lesson_id"
);
@@ -263,7 +281,7 @@ class CurriculumController
// bundle, and expose an opaque asset UUID rather than a storage path.
$resourceRows = Database::select(
"SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type, a.source_reference,
cl.title AS lesson_title, cl.unit_key,
cl.title AS lesson_title, cl.semester_key, cl.unit_key,
pba.role, pba.sort_order
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
@@ -292,7 +310,8 @@ class CurriculumController
$title = '';
if ($group === 'textbooks') {
$ref = strtolower((string)($row['source_reference'] ?? ''));
$sourceReference = trim((string)($row['source_reference'] ?? ''));
$ref = strtolower($sourceReference);
$subjectNames = [
'math' => 'الرياضيات',
'physics' => 'الفيزياء',
@@ -315,13 +334,19 @@ class CurriculumController
break;
}
}
$part = '';
if (str_contains($ref, 'part1') || str_contains($ref, 'part_1') || str_contains($ref, 'semester_1')) {
$part = ' (الفصل الأول)';
} elseif (str_contains($ref, 'part2') || str_contains($ref, 'part_2') || str_contains($ref, 'semester_2')) {
$part = ' (الفصل الثاني)';
$sourceBasename = basename(str_replace('\\', '/', $sourceReference));
$sourceTitle = trim(pathinfo($sourceBasename, PATHINFO_FILENAME));
if ($sourceTitle !== '') {
$title = $sourceTitle;
} else {
$semesterKey = strtolower((string)($row['semester_key'] ?? ''));
$semesterLabel = str_contains($semesterKey, '1')
? 'الفصل الأول'
: (str_contains($semesterKey, '2') ? 'الفصل الثاني' : 'فصل غير محدد');
$unitKey = preg_replace('/[^0-9]/', '', (string)($row['unit_key'] ?? ''));
$unitLabel = $unitKey !== '' ? " — الوحدة $unitKey" : '';
$title = "ملف PDF منشور للمادة: $foundSub — $semesterLabel$unitLabel";
}
$title = "الكتاب المدرسي الرسمي: $foundSub$part";
} else {
$lTitle = trim((string)($row['lesson_title'] ?? ''));
$title = !empty($lTitle) ? ("ورقة عمل: " . $lTitle) : "ورقة عمل تعليمية";
@@ -349,6 +374,78 @@ class CurriculumController
$response->json(['status'=>'success','data'=>$tree]);
}
/** Public student catalogue: fail closed when publication evidence is unavailable. */
public function getPublishedTree(Request $request, Response $response): void
{
try {
$rows = Database::select(
"SELECT cl.id, cl.uuid, cl.curriculum_version, cl.grade_key, cl.subject_key, cl.semester_key,
cl.unit_key, cl.lesson_key, cl.source_manifest_path, cl.title,
a.uuid AS primary_asset_id
FROM curriculum_lessons cl
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.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared'
WHERE cl.source_status='approved'
ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC"
);
$videoRows = Database::select(
"SELECT DISTINCT ts.curriculum_lesson_id
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 video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
JOIN publication_bundles pb ON pb.curriculum_lesson_id=ts.curriculum_lesson_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 ts.status='published'"
);
$hasVideo = array_fill_keys(array_map(static fn($row) => (int)$row['curriculum_lesson_id'], $videoRows), true);
foreach ($rows as &$row) $row['has_video'] = isset($hasVideo[(int)$row['id']]);
unset($row);
$tree = PublishedCurriculumTreeFilter::filter(CurriculumService::getCurriculumTree(), $rows);
if ($tree !== []) {
$resources = Database::select(
"SELECT cl.grade_key, cl.subject_key, cl.title AS lesson_title,
pba.role, a.uuid AS asset_id, a.asset_type, a.mime_type
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id=pb.curriculum_lesson_id AND cl.source_status='approved'
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role IN ('textbook','worksheet')
JOIN content_assets a ON a.id=pba.content_asset_id AND a.review_status='approved' AND a.rights_status='cleared'
WHERE pb.status='published'
AND ((pba.role='textbook' AND a.asset_type='textbook_pdf')
OR (pba.role='worksheet' AND a.asset_type='worksheet_markdown'))
ORDER BY cl.grade_key, cl.subject_key, pba.role, pba.sort_order, a.id"
);
$seen = [];
foreach ($resources as $item) {
$gradeKey = (string)$item['grade_key'];
$subjectKey = (string)$item['subject_key'];
if (!isset($tree[$gradeKey]['subjects'][$subjectKey])) continue;
$assetId = (string)$item['asset_id'];
if (isset($seen[$gradeKey][$subjectKey][$assetId])) continue;
$seen[$gradeKey][$subjectKey][$assetId] = true;
$isBook = $item['role'] === 'textbook';
$group = $isBook ? 'textbooks' : 'worksheets';
$tree[$gradeKey]['subjects'][$subjectKey]['resources'][$group]['items'][] = [
'asset_id' => $assetId,
'asset_type' => (string)$item['asset_type'],
'mime_type' => (string)$item['mime_type'],
'type' => $isBook ? 'textbook' : 'worksheet',
'title' => $isBook ? 'كتاب المبحث المنشور' : 'ورقة عمل: ' . (string)$item['lesson_title'],
];
}
}
$response->json(['status' => 'success', 'data' => $tree === [] ? new \stdClass() : $tree]);
} catch (\Throwable $e) {
error_log('Published curriculum tree unavailable: ' . $e->getMessage());
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تحميل فهرس الدروس المنشورة.']);
}
}
/**
* Get Single Lesson Markdown Content
*/
@@ -360,13 +457,15 @@ class CurriculumController
return;
}
$content = CurriculumService::getLessonMarkdown($file);
if ($content === '') {
$response->status(404)->json(['status' => 'error', 'message' => 'ملف الدرس المطلوب غير موجود.']);
return;
}
$assets = CurriculumService::getLessonAiAssets($file);
$serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/');
$response->json([
'status' => 'success',
'file' => $file,
'server_full_path' => $serverFullPath,
'content' => $content,
'ai_assets' => $assets
]);
@@ -384,34 +483,35 @@ class CurriculumController
return;
}
CurriculumService::saveLessonMarkdown($file, $content);
try {
$published = Database::selectOne(
"SELECT a.id FROM content_assets a
JOIN publication_bundle_assets pba ON pba.content_asset_id=a.id
JOIN publication_bundles pb ON pb.id=pba.publication_bundle_id
WHERE a.storage_driver='local' AND a.storage_key=? AND pb.status='published'
LIMIT 1",
[$file]
);
} catch (\Throwable $e) {
error_log('Draft lesson save gate unavailable: ' . $e->getMessage());
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من حالة أصل الدرس؛ لم يُحفظ الملف.']);
return;
}
if ($published) {
$response->status(409)->json(['status' => 'published_asset_immutable', 'message' => 'هذا الأصل منشور؛ أنشئ نسخة مسودة جديدة ومررها للمراجعة.']);
return;
}
if (!CurriculumService::saveLessonMarkdown($file, $content)) {
$response->status(400)->json(['status' => 'error', 'message' => 'مسار ملف المسودة غير صالح أو تعذر حفظه.']);
return;
}
if ($aiAssets !== null) {
CurriculumService::saveLessonAiAssets($file, $aiAssets);
}
// Sync to MySQL Database Table `lessons` if connected
try {
$aiVideoUrl = $aiAssets['ai_video_url'] ?? null;
$cheatSheet = $aiAssets['cheat_sheet'] ?? null;
$socraticJson = isset($aiAssets['socratic_quiz']) ? json_encode($aiAssets['socratic_quiz'], JSON_UNESCAPED_UNICODE) : null;
// Search lesson by matching filename or title
$filename = basename($file, '.md');
\App\Core\Database::query(
"UPDATE lessons SET markdown_content = ?, ai_video_url = COALESCE(?, ai_video_url), cheat_sheet_markdown = COALESCE(?, cheat_sheet_markdown), socratic_quiz_json = COALESCE(?, socratic_quiz_json)
WHERE title LIKE ? OR markdown_content LIKE ?",
[$content, $aiVideoUrl, $cheatSheet, $socraticJson, "%{$filename}%", "%{$file}%"]
);
} catch (\Throwable $dbEx) {
error_log("Curriculum DB save sync note: " . $dbEx->getMessage());
}
$serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/');
$response->json([
'status' => 'success',
'message' => 'تم حفظ واعتماد محتوى الدرس في المنهاج وقاعدة البيانات بنجاح!',
'server_full_path' => $serverFullPath
'message' => 'تم حفظ مسودة الدرس. النشر يتطلب مراجعة المحتوى والحقوق والحزمة.',
]);
}
@@ -634,6 +734,9 @@ class CurriculumController
$assetId = trim((string)$request->getParam('assetId', ''));
try {
$asset = PublishedContentService::findPublishedAsset($assetId);
if ($asset && !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) {
$asset = null;
}
} catch (\Throwable $e) {
error_log('Published asset lookup failed: ' . $e->getMessage());
$response->status(503)->json([
@@ -660,6 +763,16 @@ class CurriculumController
return;
}
$actualSha = hash_file('sha256', $path);
if ($actualSha === false || !hash_equals((string)$asset['sha256'], $actualSha)
|| filesize($path) !== (int)$asset['byte_size']) {
$response->status(503)->json(['status' => 'unavailable', 'message' => 'فشل التحقق من سلامة الأصل المنشور.']);
return;
}
$response->setHeader('Cache-Control', 'private, no-store');
$response->setHeader('Referrer-Policy', 'no-referrer');
$response->setHeader('X-Content-Type-Options', 'nosniff');
if (str_starts_with((string)$asset['mime_type'], 'text/')) {
$content = file_get_contents($path);
if ($content === false) {
@@ -682,6 +795,47 @@ class CurriculumController
exit;
}
/** Authenticated student obtains a short-lived browser grant for one PDF. */
public function issueAssetDownloadTicket(Request $request, Response $response): void
{
$assetId = trim((string)$request->getParam('assetId', ''));
try {
$asset = PublishedContentService::findPublishedAsset($assetId);
if (!$asset || $asset['asset_type'] !== 'textbook_pdf'
|| $asset['mime_type'] !== 'application/pdf'
|| !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) {
$response->status(404)->json(['status' => 'error', 'message' => 'الكتاب غير منشور أو غير متاح لك.']);
return;
}
$grant = AssetDownloadTicketService::issue((int)$request->user_id, $assetId);
$response->setHeader('Cache-Control', 'private, no-store');
$response->json(['status' => 'success', 'data' => $grant]);
} catch (\Throwable $e) {
error_log('Asset download grant unavailable: ' . $e->getMessage());
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تجهيز فتح الكتاب حالياً.']);
}
}
/** Browser receives only a scoped grant, never the student's session JWT. */
public function downloadAssetWithTicket(Request $request, Response $response): void
{
$assetId = trim((string)$request->getParam('assetId', ''));
$ticket = trim((string)$request->getQuery('ticket', ''));
try {
$studentId = AssetDownloadTicketService::resolve($ticket, $assetId);
} catch (\Throwable $e) {
error_log('Asset download grant lookup unavailable: ' . $e->getMessage());
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من رابط الكتاب.']);
return;
}
if ($studentId === null) {
$response->status(403)->json(['status' => 'forbidden', 'message' => 'رابط الكتاب غير صالح أو انتهت صلاحيته.']);
return;
}
$request->user_id = $studentId;
$this->getPublishedAsset($request, $response);
}
private static function assetMetadata(array $asset): array
{
return [
@@ -5,13 +5,14 @@ namespace App\Controllers;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Services\RemediationAttemptGrader;
class ErrorNotebookController
{
public function getErrorNotebook(Request $request, Response $response): void
{
$studentId = (int)$request->user_id;
$sql = "SELECT * FROM student_error_notebook WHERE student_id = ?";
$sql = "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE student_id = ?";
$params = [$studentId];
$subject = trim((string)$request->getQuery('subject', ''));
$status = trim((string)$request->getQuery('status', ''));
@@ -69,9 +70,11 @@ class ErrorNotebookController
public function getRemediationQuiz(Request $request, Response $response): void
{
$uuid = trim((string)$request->getQuery('error_uuid', ''));
$body = $request->getBody();
$uuid = trim((string)($body['error_uuid'] ?? ''));
$error = Database::selectOne(
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
"SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due
FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
[$uuid, (int)$request->user_id]
);
if (!$error) {
@@ -79,49 +82,62 @@ class ErrorNotebookController
return;
}
$questions = [];
// 1. Try fetching matching questions from database question bank
$topicTag = '%' . $error['topic_name'] . '%';
if ($error['status'] === 'mastered') {
$response->status(409)->json(['status' => 'error', 'message' => 'هذه الفجوة متقنة بالفعل']);
return;
}
if ($error['status'] === 'in_remediation' && (int)$error['retention_is_due'] !== 1) {
$response->status(409)->json(['status' => 'not_due', 'message' => 'اختبار الاسترجاع متاح بعد موعده المحدد']);
return;
}
if (empty($error['lesson_id'])) {
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا يوجد درس محدد لهذه الفجوة لتكوين اختبار موثوق']);
return;
}
$dbQuestions = Database::select(
"SELECT q.id, q.question_text, q.explanation_text, q.ai_hint
FROM questions q
"SELECT q.id, q.question_text FROM questions q
JOIN exams e ON e.id = q.exam_id
WHERE q.topic_tag LIKE ? OR q.question_text LIKE ? OR e.title LIKE ?
LIMIT 3",
[$topicTag, $topicTag, $topicTag]
WHERE e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
AND q.question_type = 'multiple_choice'
AND q.remediation_review_status = 'approved'
AND q.remediation_reviewed_by IS NOT NULL
AND q.remediation_reviewed_at IS NOT NULL
AND q.remediation_source_reference IS NOT NULL
ORDER BY q.id ASC LIMIT 20",
[(int)$error['lesson_id'], $error['topic_name']]
);
foreach ($dbQuestions as $dbQ) {
$opts = Database::select(
"SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC",
[(int)$dbQ['id']]
$questions = [];
$answerKey = [];
foreach ($dbQuestions as $question) {
$options = Database::select(
'SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC',
[(int)$question['id']]
);
if (count($opts) >= 2) {
$optTexts = [];
$correctIdx = 0;
foreach ($opts as $i => $opt) {
$optTexts[] = $opt['option_text'];
if ((int)$opt['is_correct'] === 1) {
$correctIdx = $i;
}
}
$questions[] = [
'id' => (int)$dbQ['id'],
'question' => $dbQ['question_text'],
'options' => $optTexts,
'correct_index' => $correctIdx,
'explanation' => $dbQ['explanation_text'] ?: ($dbQ['ai_hint'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'),
];
}
if (count($options) < 2 || count(array_filter($options, fn($o) => (int)$o['is_correct'] === 1)) !== 1) continue;
$questions[] = [
'id' => (int)$question['id'], 'question' => $question['question_text'],
'options' => array_map(fn($o) => ['id' => (int)$o['id'], 'text' => $o['option_text']], $options),
];
$answerKey[] = [
'question_id' => (int)$question['id'],
'option_ids' => array_map(fn($o) => (int)$o['id'], $options),
'correct_option_id' => (int)array_values(array_filter($options, fn($o) => (int)$o['is_correct'] === 1))[0]['id'],
];
if (count($questions) === 3) break;
}
if (count($questions) !== 3) {
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا توجد ثلاثة أسئلة منشورة ومراجعة لهذا الدرس والمفهوم']);
return;
}
// 2. If question bank has fewer than 2 questions, provide curriculum-aligned remedial questions
if (count($questions) < 2) {
$topic = $error['topic_name'];
$subject = $error['subject_name'];
$questions = self::generateCurriculumRemedialQuiz((int)$error['id'], $subject, $topic, $error['question_text']);
}
$attemptUuid = $this->uuid();
Database::insert(
"INSERT INTO error_notebook_quiz_attempts (uuid,error_id,student_id,phase,question_ids_json,answer_key_json,expires_at)
VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 30 MINUTE))",
[$attemptUuid, (int)$error['id'], (int)$request->user_id,
$error['status'] === 'in_remediation' ? 'retention' : 'initial',
json_encode(array_column($questions, 'id')), json_encode($answerKey)]
);
$response->json([
'status' => 'success',
@@ -129,6 +145,7 @@ class ErrorNotebookController
'error_uuid' => $uuid,
'topic_name' => $error['topic_name'],
'subject_name' => $error['subject_name'],
'attempt_uuid' => $attemptUuid,
'questions' => $questions,
],
]);
@@ -138,111 +155,94 @@ class ErrorNotebookController
{
$body = $request->getBody();
$uuid = trim((string)($body['error_uuid'] ?? ''));
$attemptUuid = trim((string)($body['attempt_uuid'] ?? ''));
$studentId = (int)$request->user_id;
$error = Database::selectOne(
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
[$uuid, $studentId]
);
if (!$error) {
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']);
$answers = $body['answers'] ?? null;
if (!is_array($answers) || count($answers) !== 3) {
$response->status(422)->json(['status' => 'error', 'message' => 'يجب إرسال إجابات الأسئلة الثلاثة']);
return;
}
$isRetentionPhase = ($error['status'] === 'in_remediation');
$nextStatus = $isRetentionPhase ? 'mastered' : 'in_remediation';
// Stage 1: Initial Remedial Drill passed -> enters Spaced Repetition (in_remediation)
// Stage 2: Spaced Repetition Retention Challenge passed -> enters permanently Mastered
Database::query(
"UPDATE student_error_notebook
SET status = ?,
mastered_at = IF(? = 'mastered', NOW(), mastered_at),
remediation_attempts_count = remediation_attempts_count + 1
WHERE id = ?",
[$nextStatus, $nextStatus, (int)$error['id']]
);
// Update student mastery analytics and readiness
$currentMastery = Database::selectOne(
"SELECT id, tawjihi_readiness_score, mastery_percentage FROM student_mastery_analytics WHERE student_id = ? ORDER BY id DESC LIMIT 1",
[$studentId]
);
if ($currentMastery) {
$readinessGain = $isRetentionPhase ? 1.5 : 0.6;
$masteryGain = $isRetentionPhase ? 1.2 : 0.5;
$newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + $readinessGain);
$newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + $masteryGain);
Database::query(
"UPDATE student_mastery_analytics
SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW()
WHERE id = ?",
[$newReadiness, $newMastery, (int)$currentMastery['id']]
$answersHash = hash('sha256', json_encode($answers));
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
$error = Database::selectOne(
'SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE uuid = ? AND student_id = ? FOR UPDATE',
[$uuid, $studentId]
);
$attempt = $error ? Database::selectOne(
'SELECT *, (expires_at > NOW()) AS is_fresh FROM error_notebook_quiz_attempts WHERE uuid = ? AND error_id = ? AND student_id = ? FOR UPDATE',
[$attemptUuid, (int)$error['id'], $studentId]
) : null;
if ($attempt && $attempt['status'] !== 'open' && hash_equals((string)($attempt['answers_sha256'] ?? ''), $answersHash)) {
$pdo->commit();
$response->json(['status' => 'success', 'passed' => $attempt['status'] === 'passed',
'correct_count' => (int)$attempt['correct_count'],
'new_status' => $attempt['status'] === 'passed' ? ($attempt['phase'] === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
return;
}
if (!$attempt || $attempt['status'] !== 'open' || (int)$attempt['is_fresh'] !== 1) {
$pdo->rollBack();
$response->status(409)->json(['status' => 'error', 'message' => 'المحاولة غير متاحة أو انتهت صلاحيتها']);
return;
}
$phase = $error['status'] === 'in_remediation' ? 'retention' : 'initial';
if ($error['status'] === 'mastered' || $attempt['phase'] !== $phase ||
($phase === 'retention' && (int)$error['retention_is_due'] !== 1)) {
$pdo->rollBack();
$response->status(409)->json(['status' => 'error', 'message' => 'حالة الفجوة تغيرت؛ ابدأ محاولة جديدة']);
return;
}
$ids = json_decode($attempt['question_ids_json'], true);
$answerKey = json_decode($attempt['answer_key_json'], true);
if (!is_array($ids) || !is_array($answerKey)) {
$pdo->rollBack();
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
return;
}
foreach ($ids as $index => $questionId) {
$published = Database::selectOne(
"SELECT q.id FROM questions q JOIN exams e ON e.id = q.exam_id
WHERE q.id = ? AND e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
AND q.remediation_review_status = 'approved' AND q.remediation_reviewed_by IS NOT NULL
AND q.remediation_reviewed_at IS NOT NULL AND q.remediation_source_reference IS NOT NULL FOR UPDATE",
[(int)$questionId, (int)$error['lesson_id'], $error['topic_name']]
);
if (!$published) {
$pdo->rollBack();
$response->status(409)->json(['status' => 'error', 'message' => 'سُحب سؤال من هذه المحاولة؛ ابدأ محاولة جديدة']);
return;
}
}
try {
$correct = RemediationAttemptGrader::grade($ids, $answerKey, $answers);
} catch (\InvalidArgumentException $invalidAnswer) {
$pdo->rollBack();
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
return;
}
$passed = $correct >= 2;
Database::query(
"UPDATE error_notebook_quiz_attempts SET status = ?, correct_count = ?, answers_sha256 = ?, submitted_at = NOW() WHERE id = ?",
[$passed ? 'passed' : 'failed', $correct, $answersHash, (int)$attempt['id']]
);
if ($passed) {
Database::query(
"UPDATE student_error_notebook SET status = ?, remediation_attempts_count = remediation_attempts_count + 1,
retention_due_at = IF(? = 'initial', DATE_ADD(NOW(), INTERVAL 7 DAY), retention_due_at),
mastered_at = IF(? = 'retention', NOW(), mastered_at) WHERE id = ?",
[$phase === 'retention' ? 'mastered' : 'in_remediation', $phase, $phase, (int)$error['id']]
);
} else {
Database::query('UPDATE student_error_notebook SET remediation_attempts_count = remediation_attempts_count + 1 WHERE id = ?', [(int)$error['id']]);
}
$pdo->commit();
$response->json(['status' => 'success', 'passed' => $passed, 'correct_count' => $correct,
'new_status' => $passed ? ($phase === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
if ($isRetentionPhase) {
$response->json([
'status' => 'success',
'mastered' => true,
'is_retention_verified' => true,
'new_status' => 'mastered',
'message' => 'تم اجتياز اختبار الاسترجاع والتثبيت بنجاح تام! اعتُمد المفهوم كمتقن في الذاكرة طويلة المدى.',
]);
} else {
$response->json([
'status' => 'success',
'mastered' => false,
'is_retention_verified' => false,
'new_status' => 'in_remediation',
'message' => 'تم اجتياز الكويز العلاجي الأولي بنجاح! انتقل المفهوم إلى مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام ومنع النسيان).',
]);
}
}
/**
* Generate curriculum-aligned remedial questions based on the mistaken topic.
*/
public static function generateCurriculumRemedialQuiz(int $errorId, string $subject, string $topic, string $originalQuestion): array
{
return [
[
'id' => $errorId * 10 + 1,
'question' => "سؤال علاجي في مفهوم [{$topic}]: ما المبدأ العلمي الأساسي الذي يحكم سلوك الظاهرة؟",
'options' => [
"الاعتماد المباشر على العلاقة الرياضية ومحددات الاتجاه للمتغيرات في المنهج",
"تطبيق عشوائي للقيم دون ربطها بالقانون الفيزيائي أو الكيميائي",
"إهمال الوحدات الأساسية والتحويل بين البادئات العلمية",
"افتراض ثبات المتغيرات غير المقيسة بدون سند تجريبي"
],
'correct_index' => 0,
'explanation' => "الأساس العلمي في دراسة {$topic} يقتضي الانطلاق دائماً من العلاقة الرياضية المعتمدة وتحديد المتغيرات التابعة والمستقلة بدقة.",
],
[
'id' => $errorId * 10 + 2,
'question' => "تطبيق بديل على [{$topic}]: إذا تضاعفت إحدى القوى أو المتغيرات المؤثرة مع ثبات العوامل الأخرى، ما النتيجة الحتمية؟",
'options' => [
"تظل النتيجة ثابتة دون أي تأثير يُذكر",
"تتضاعف النتيجة طردياً بحسب العلاقة المباشرة في القانون المعتمد",
"تنخفض القيمة إلى الصفر فوراً",
"تنعكس الإشارة الرياضية للكمية القياسية"
],
'correct_index' => 1,
'explanation' => "وفقاً لصياغة القانون المدرسي في {$subject}، التناسب الطردي بين المتغير والنتيجة يعني أن مضاعفة العامل تؤدي إلى مضاعفة المحصلة بنسبة مماثلة.",
],
[
'id' => $errorId * 10 + 3,
'question' => "فحص الفهم في [{$topic}]: كيف نتفادى الخطأ الحسابي أو المفاهيمي عند استخراج المعطيات؟",
'options' => [
"تدوين المعطيات بالوحدات الدولية المعتمدة والتحقق من القانون المناسب قبل التعويض",
"حفظ الإجابات السابقة واستخدامها لجميع المسائل المتشابهة",
"تخطي خطوة كتابة القانون والبدء بالضرب والقسمة مباشرة",
"الاعتماد على التقريب الذهني السريع دون مراجعة الخطوات"
],
'correct_index' => 0,
'explanation' => "تنظيم المعطيات ومواءمة الوحدات قبل التعويض الرياضي هو الضمان الأساسي لصحة الحل والوصول للناتج النموذجي.",
],
];
}
private function uuid(): string
@@ -121,13 +121,10 @@ class SuperAdminController
{
$bundles = Database::select(
"SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at,
cl.title AS lesson_title, cl.curriculum_edition, s.name AS subject_name, g.name AS grade_name
cl.title AS lesson_title, cl.curriculum_version, cl.subject_key, cl.grade_key,
cl.semester_key, cl.unit_key, cl.lesson_key
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
JOIN curriculum_units cu ON cu.id = cl.unit_id
JOIN curriculum_courses cc ON cc.id = cu.course_id
JOIN subjects s ON s.id = cc.subject_id
JOIN grade_levels g ON g.id = cc.grade_level_id
ORDER BY pb.id DESC LIMIT 100"
);
$submissions = Database::select(
@@ -150,35 +147,10 @@ class SuperAdminController
public function reviewSubmission(Request $request, Response $response): void
{
$body = $request->getBody();
$versionUuid = trim((string)($body['version_uuid'] ?? ''));
$decision = (string)($body['decision'] ?? ''); // 'approved' or 'rejected'
if (!in_array($decision, ['approved', 'rejected'], true) || $versionUuid === '') {
$response->status(422)->json(['status' => 'error', 'message' => 'بيانات مراجعة النسخة غير صالحة']);
return;
}
$version = Database::selectOne(
"SELECT vv.id, vv.teacher_submission_id FROM video_versions vv WHERE vv.uuid = ? LIMIT 1",
[$versionUuid]
);
if (!$version) {
$response->status(404)->json(['status' => 'error', 'message' => 'نسخة الفيديو غير موجودة']);
return;
}
$status = $decision === 'approved' ? 'published' : 'rejected';
Database::query("UPDATE video_versions SET status = ?, reviewed_at = NOW() WHERE id = ?", [$status, (int)$version['id']]);
if ($decision === 'approved') {
Database::query(
"UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?",
[(int)$version['id'], (int)$version['teacher_submission_id']]
);
}
$response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ونشر النسخة بنجاح' : 'تم رفض النسخة']);
$response->status(410)->json([
'status' => 'review_pipeline_required',
'message' => 'استخدم مسارات أدلة فحص الفيديو، قرار المراجع، ثم نشر مهمة المراجعة المعتمدة.',
]);
}
private function count(string $table): int
+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 = [];