Harden published curriculum and student flows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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