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