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 = [];
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\RedisClient;
/** Short-lived, asset-scoped browser grant. Never place a session JWT in a URL. */
final class AssetDownloadTicketService
{
private const TTL_SECONDS = 120;
public static function issue(int $studentId, string $assetUuid): array
{
if ($studentId <= 0 || $assetUuid === '') throw new \InvalidArgumentException('Invalid download scope');
$ticket = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$key = 'asset_download:' . hash('sha256', $ticket);
$saved = RedisClient::getInstance()->setex($key, self::TTL_SECONDS, json_encode([
'student_id' => $studentId,
'asset_uuid' => $assetUuid,
], JSON_THROW_ON_ERROR));
if (!$saved) throw new \RuntimeException('Download grant unavailable');
return ['ticket' => $ticket, 'expires_in_seconds' => self::TTL_SECONDS];
}
public static function resolve(string $ticket, string $assetUuid): ?int
{
if (!preg_match('/^[A-Za-z0-9_-]{43}$/', $ticket)) return null;
$payload = RedisClient::getInstance()->get('asset_download:' . hash('sha256', $ticket));
if (!is_string($payload)) return null;
$grant = json_decode($payload, true);
if (!is_array($grant) || !hash_equals((string)($grant['asset_uuid'] ?? ''), $assetUuid)) return null;
$studentId = (int)($grant['student_id'] ?? 0);
return $studentId > 0 ? $studentId : null;
}
}
+23 -9
View File
@@ -144,7 +144,6 @@ class CurriculumService
*/
public static function getCurriculumTree(): array
{
self::ensureStorage();
if (!file_exists(self::$manifestFile)) {
return [];
}
@@ -281,13 +280,19 @@ class CurriculumService
*/
public static function saveLessonMarkdown(string $relativePath, string $content): bool
{
if (!self::safeMarkdownPath($relativePath)) return false;
self::ensureStorage();
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
$root = realpath(self::$storagePath);
if ($root === false) return false;
$fullPath = $root . '/' . $relativePath;
$dir = dirname($fullPath);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
if (!mkdir($dir, 0750, true) && !is_dir($dir)) return false;
}
return file_put_contents($fullPath, $content) !== false;
$realDir = realpath($dir);
if ($realDir === false || !str_starts_with($realDir, $root . DIRECTORY_SEPARATOR)) return false;
if (is_link($fullPath)) return false;
return file_put_contents($fullPath, $content, LOCK_EX) !== false;
}
/**
@@ -295,12 +300,21 @@ class CurriculumService
*/
public static function getLessonMarkdown(string $relativePath): string
{
self::ensureStorage();
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
if (file_exists($fullPath)) {
return file_get_contents($fullPath);
$root = realpath(self::$storagePath);
if ($root === false || !self::safeMarkdownPath($relativePath)) return '';
$fullPath = realpath($root . '/' . $relativePath);
if ($fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) return '';
return file_get_contents($fullPath) ?: '';
}
public static function safeMarkdownPath(string $path): bool
{
if ($path === '' || str_starts_with($path, '/') || str_contains($path, '\\')
|| !str_ends_with(strtolower($path), '.md') || preg_match('/[\x00-\x1f]/', $path)) return false;
foreach (explode('/', $path) as $part) {
if ($part === '' || $part === '.' || $part === '..') return false;
}
return "# محتوى المنهاج\nالمحتوى المعتمد للمنهاج الرسمي.";
return true;
}
public static function saveLessonAiAssets(string $relativePath, array $assets): bool
+11 -11
View File
@@ -137,7 +137,7 @@ class NabehService
// 1. Attempt with selected type (image card)
$result = $this->attemptSend($phoneRaw, $selectedType, $otp, $appName, $bearerToken);
if ($result['success']) {
if ($result['success'] || !empty($result['delivery_unknown'])) {
return $result;
}
@@ -192,18 +192,18 @@ class NabehService
if ($curlError) {
error_log("❌ [Nabeh OTP cURL Error (type={$type}, duration={$duration}s)] " . $curlError);
// Resilient Fallback: If gateway response timed out after dispatching (duration >= 18s),
// Nabeh has already received and queued the WhatsApp image/text message.
// The OTP is already stored in Redis on Saqel.
// A timeout does not prove whether Nabeh accepted or delivered the message.
// Do not report success or send a second OTP via the text fallback: that could
// create a duplicate while the original gateway request is still completing.
if ($duration >= 18.0 && str_contains(strtolower($curlError), 'timed out')) {
error_log("⚠️ [Nabeh OTP Tolerant Dispatch] WhatsApp ({$type}) message likely queued/dispatched despite gateway latency ({$duration}s). Proceeding.");
error_log("⚠️ [Nabeh OTP Delivery Unknown] WhatsApp ({$type}) request timed out after {$duration}s; delivery is unconfirmed.");
return [
'success' => true,
'type_used' => $type,
'duration' => "{$duration}s",
'http_code' => 200,
'message' => 'تم إرسال رمز التحقق بنجاح عبر بطاقة الواتساب المصورة',
'note' => 'Dispatched under gateway latency'
'success' => false,
'delivery_unknown' => true,
'type_used' => $type,
'duration' => "{$duration}s",
'http_code' => $httpCode,
'error' => 'انتهت مهلة بوابة واتساب؛ لم يصل تأكيد التسليم. تحقّق من الرسائل قبل طلب رمز جديد.'
];
}
@@ -51,31 +51,24 @@ final class PublishedContentService
return null;
}
$candidates = [
$root . '/' . $storageKey,
$root . '/_incoming/' . $storageKey,
dirname($root) . '/' . $storageKey,
dirname($root, 2) . '/' . $storageKey,
];
if (preg_match('#staged/grade_10/[^/]+/(.+)#', $storageKey, $matches)) {
$inner = $matches[1];
$candidates[] = $root . '/' . $inner;
$candidates[] = $root . '/_incoming/' . $inner;
}
// Published storage keys must resolve to their exact file inside the
// curriculum root. Do not fall back to another similarly named file.
$real = realpath($root . '/' . $storageKey);
$prefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$projectRoot = realpath(dirname(__DIR__, 2)) ?: '';
return $real !== false && str_starts_with($real, $prefix) && is_file($real) ? $real : null;
}
foreach ($candidates as $candidate) {
$real = realpath($candidate);
if ($real && is_file($real)) {
// Ensure candidate is strictly within the project root to prevent traversal
if ($projectRoot !== '' && str_starts_with($real, $projectRoot)) {
return $real;
}
}
}
return null;
public static function studentMayRead(int $studentId, array $asset): bool
{
if ($studentId <= 0 || empty($asset['grade_key'])) return false;
$student = Database::selectOne(
"SELECT s.grade_level FROM students s
JOIN auth_identities ai ON ai.id=s.identity_id AND ai.status='active'
WHERE s.id=? LIMIT 1",
[$studentId]
);
if (!$student || empty($student['grade_level'])) return false;
return StudentAccessControlService::normalizeGrade((string)$student['grade_level'])
=== StudentAccessControlService::normalizeGrade((string)$asset['grade_key']);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Services;
/** Builds a student catalogue from published, rights-cleared lesson identities. */
final class PublishedCurriculumTreeFilter
{
public static function filter(array $manifest, array $publishedLessons): array
{
$approved = [];
foreach ($publishedLessons as $row) {
$key = self::key(
(string)$row['grade_key'], (string)$row['subject_key'],
(string)$row['semester_key'], (string)$row['unit_key'],
(string)$row['lesson_key'], (string)$row['source_manifest_path']
);
// Rows are ordered newest-first by the caller. One visible version
// per exact identity; never infer identity from title or file alone.
$approved[$key] ??= $row;
}
$visible = [];
foreach ($manifest as $gradeKey => $grade) {
if (!is_array($grade) || !is_array($grade['subjects'] ?? null)) continue;
$subjects = [];
foreach ($grade['subjects'] as $subjectKey => $subject) {
if (!is_array($subject) || !is_array($subject['semesters'] ?? null)) continue;
$semesters = [];
foreach ($subject['semesters'] as $semesterKey => $semester) {
if (!is_array($semester) || !is_array($semester['units'] ?? null)) continue;
$units = [];
foreach ($semester['units'] as $unitKey => $unit) {
if (!is_array($unit) || !is_array($unit['lessons'] ?? null)) continue;
$lessons = [];
foreach ($unit['lessons'] as $lesson) {
if (!is_array($lesson)) continue;
$key = self::key(
(string)$gradeKey, (string)$subjectKey,
(string)$semesterKey, (string)$unitKey,
(string)($lesson['id'] ?? ''), (string)($lesson['file'] ?? '')
);
$row = $approved[$key] ?? null;
if ($row === null) continue;
$lessons[] = [
'id' => (string)$row['lesson_key'],
'title' => (string)$row['title'],
'curriculum_lesson_id' => (string)$row['uuid'],
'curriculum_version' => (string)$row['curriculum_version'],
'primary_lesson_asset_id' => (string)($row['primary_asset_id'] ?? ''),
'has_video' => !empty($row['has_video']),
];
}
if ($lessons === []) continue;
$unit['lessons'] = $lessons;
$units[$unitKey] = $unit;
}
if ($units === []) continue;
$semester['units'] = $units;
$semesters[$semesterKey] = $semester;
}
if ($semesters === []) continue;
$subject['semesters'] = $semesters;
$subject['resources'] = ['textbooks' => ['items' => []], 'worksheets' => ['items' => []]];
$subjects[$subjectKey] = $subject;
}
if ($subjects === []) continue;
$grade['subjects'] = $subjects;
$visible[$gradeKey] = $grade;
}
return $visible;
}
private static function key(string ...$parts): string
{
return json_encode($parts, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services;
/** Grades one immutable server snapshot; no client-supplied answer key. */
final class RemediationAttemptGrader
{
public static function grade(array $questionIds, array $answerKey, array $answers): int
{
if (count($questionIds) !== 3 || count(array_unique($questionIds)) !== 3 ||
count($answerKey) !== 3 || array_keys($answers) !== [0, 1, 2]) {
throw new \InvalidArgumentException('Invalid quiz shape');
}
$correct = 0;
foreach ($questionIds as $index => $questionId) {
$key = $answerKey[$index] ?? null;
$selected = $answers[$index];
if (!is_array($key) || !is_int($selected) || $selected <= 0 ||
(int)($key['question_id'] ?? 0) !== (int)$questionId ||
!is_array($key['option_ids'] ?? null) ||
!in_array($selected, $key['option_ids'], true) ||
!in_array((int)($key['correct_option_id'] ?? 0), $key['option_ids'], true)) {
throw new \InvalidArgumentException('Answer does not belong to the attempt');
}
if ($selected === (int)$key['correct_option_id']) $correct++;
}
return $correct;
}
}
@@ -38,7 +38,7 @@ class StudentAccessControlService
string $targetGrade,
?int $courseId = null,
?int $lessonId = null,
bool $allowSideEffects = true
bool $allowSideEffects = false
): array {
// 1. استرجاع بيانات الطالب وسجله الدراسي
$student = null;
@@ -64,19 +64,9 @@ class StudentAccessControlService
);
}
// إذا لم يكن مسجلاً، نتحقق مما إذا كان الدرس معاينة مجانية (Preview)
// Protected content requires a current student record. A preview flag
// must not turn a missing/deleted student into a valid entitlement.
if (!$student) {
if ($lessonId) {
$lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
if ($lesson && !empty($lesson['is_free_preview'])) {
return [
'allowed' => true,
'reason' => 'free_preview',
'student_grade' => 'guest',
'is_sponsored' => false
];
}
}
return [
'allowed' => false,
'reason' => 'unauthenticated_or_not_found',
@@ -86,79 +76,71 @@ class StudentAccessControlService
];
}
$activeStudentGrade = self::normalizeGrade($student['grade_level'] ?? 'grade_10');
$activeStudentGrade = self::normalizeGrade((string)($student['grade_level'] ?? ''));
$normalizedTargetGrade = self::normalizeGrade($targetGrade);
// Auto-heal obsolete database schema default:
// If student was recorded with the old default 'tawjihi_2008' or empty and target is grade_10
if ($allowSideEffects && ($student['grade_level'] === 'tawjihi_2008' || empty($student['grade_level'])) && $normalizedTargetGrade === 'grade_10') {
Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [(int)$student['id']]);
$student['grade_level'] = 'grade_10';
$activeStudentGrade = 'grade_10';
}
// 2. التحقق من قفل الصف الدراسي:
// يطبق قفل الصف الصارم حصراً على طلبة المدارس الشريكة المشمولة (الثقافة العسكرية والمدارس المرتبطة بمديريات)
// أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار
$isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']);
if ($activeStudentGrade !== $normalizedTargetGrade) {
if (!$isCohortLocked && !empty($student['id'])) {
if ($allowSideEffects) {
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]);
}
$activeStudentGrade = $normalizedTargetGrade;
} else {
$targetGradeName = self::getGradeDisplayName($normalizedTargetGrade);
$currentGradeName = self::getGradeDisplayName($activeStudentGrade);
return [
'allowed' => false,
'reason' => 'grade_mismatch',
'message' => "غير مصرح: أنت مسجل حالياً في ({$currentGradeName})، ولا يمكنك فتح حصص ({$targetGradeName}) حفاظاً على التركيز والمسار الأكاديمي المعتمد.",
'student_grade' => $activeStudentGrade,
'target_grade' => $normalizedTargetGrade,
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false)
];
}
}
// 3. التحقق من النموذج المالي المزدوج:
// أ. إذا كان الطالب تابعاً لمدارس الثقافة العسكرية أو مدرسة خاصة شريكة
$isMilitaryCulture = ($student['directorate_type'] ?? '') === 'military_culture' || ($student['school_type'] ?? '') === 'military_culture';
$isPrivateSponsored = !empty($student['is_school_sponsored']) && !empty($student['school_id']);
if ($isMilitaryCulture || $isPrivateSponsored) {
// الطالب مشمول مجاناً 100% (صفر دينار)
if ($allowSideEffects && $courseId && !empty($student['id'])) {
self::ensureSchoolIncludedPass((int)$student['id'], $courseId);
}
return [
'allowed' => true,
'reason' => 'school_sponsored_free',
'message' => 'مشمول مجاناً عبر المنظومة المؤسسية الشريكة (مديرية الثقافة العسكرية / المدرسة المعتمدة).',
'student_grade' => $activeStudentGrade,
'is_sponsored' => true,
'fee_jod' => 0.00
'allowed' => false, 'reason' => 'grade_mismatch',
'message' => 'الدرس خارج صف الطالب المسجل؛ غيّر صفك من الملف المصرح أو راجع المدرسة.',
'student_grade' => $activeStudentGrade, 'target_grade' => $normalizedTargetGrade,
'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false),
];
}
// ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (External Student)
// A lesson read never changes grade or creates a school access pass.
// Entitlements are checked against an actual course and school roster.
if ($courseId) {
// المساقات المجانية (السعر 0.00 دينار) متاحة فوراً ومجاناً للجميع
$course = Database::selectOne("SELECT price_jod FROM courses WHERE id = ? LIMIT 1", [$courseId]);
if ($course && (float)($course['price_jod'] ?? 0) <= 0.0) {
$course = Database::selectOne(
'SELECT c.id, c.school_id, c.is_school_exclusive, c.is_published, c.price_jod,
c.grade_level, t.is_school_exclusive AS teacher_school_exclusive,
t.is_marketplace_public
FROM courses c JOIN teachers t ON t.id=c.teacher_id WHERE c.id=? LIMIT 1',
[$courseId]
);
if (!$course || (int)$course['is_published'] !== 1) {
return ['allowed' => false, 'reason' => 'course_unavailable', 'student_grade' => $activeStudentGrade];
}
if ($course['grade_level'] !== null && $course['grade_level'] !== ''
&& self::normalizeGrade((string)$course['grade_level']) !== $normalizedTargetGrade) {
return ['allowed' => false, 'reason' => 'course_grade_mismatch', 'student_grade' => $activeStudentGrade];
}
if ($lessonId) {
$sourceLesson = Database::selectOne('SELECT course_id FROM lessons WHERE id=? LIMIT 1', [$lessonId]);
if (!$sourceLesson || (int)$sourceLesson['course_id'] !== $courseId) {
return ['allowed' => false, 'reason' => 'lesson_course_mismatch', 'student_grade' => $activeStudentGrade];
}
}
$schoolId = (int)($course['school_id'] ?? 0);
if ($schoolId > 0 || (int)$course['is_school_exclusive'] === 1 || (int)$course['teacher_school_exclusive'] === 1) {
if ($schoolId <= 0 || (int)($student['school_id'] ?? 0) !== $schoolId
|| empty($student['is_school_sponsored'])) {
return ['allowed' => false, 'reason' => 'school_scope_mismatch', 'student_grade' => $activeStudentGrade];
}
$roster = Database::selectOne(
'SELECT grade_level FROM school_rosters WHERE school_id=? AND claimed_student_id=? AND is_claimed=1 LIMIT 1',
[$schoolId, (int)$student['id']]
);
if (!$roster || self::normalizeGrade((string)$roster['grade_level']) !== $normalizedTargetGrade) {
return ['allowed' => false, 'reason' => 'school_roster_unverified', 'student_grade' => $activeStudentGrade];
}
return [
'allowed' => true,
'reason' => 'free_course',
'student_grade' => $activeStudentGrade,
'is_sponsored' => false
'allowed' => true, 'reason' => 'school_course_included',
'student_grade' => $activeStudentGrade, 'is_sponsored' => true,
];
}
if ((int)$course['is_marketplace_public'] !== 1) {
return ['allowed' => false, 'reason' => 'not_marketplace_public', 'student_grade' => $activeStudentGrade];
}
if ((float)$course['price_jod'] <= 0.0) {
return ['allowed' => true, 'reason' => 'free_course', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false];
}
$activePass = Database::selectOne(
"SELECT id, pass_type, expires_at, is_active FROM course_access_passes
WHERE student_id = ? AND course_id = ? AND is_active = 1
AND pass_type IN ('discounted_micro_pass','full_marketplace')
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1",
[(int)$student['id'], $courseId]
@@ -174,7 +156,7 @@ class StudentAccessControlService
];
}
// فحص إذا كان الدرس معاينة مجانية
// An explicit free preview is allowed only on a public course.
if ($lessonId) {
$lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
if ($lesson && !empty($lesson['is_free_preview'])) {
@@ -279,30 +261,6 @@ class StudentAccessControlService
];
}
/**
* التحقق من وجود تصريح مدرسي مجاني أو إنشاؤه تلقائياً لطلبة المدارس الشريكة
*/
private static function ensureSchoolIncludedPass(int $studentId, int $courseId): void
{
$existing = Database::selectOne(
"SELECT id FROM course_access_passes WHERE student_id = ? AND course_id = ? LIMIT 1",
[$studentId, $courseId]
);
if (!$existing) {
$course = Database::selectOne("SELECT teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]);
$teacherId = (int)($course['teacher_id'] ?? 1);
Database::insert(
"INSERT INTO course_access_passes
(student_id, course_id, teacher_id, pass_type, price_paid_jod, expires_at, is_active)
VALUES
(?, ?, ?, 'school_included', 0.00, DATE_ADD(NOW(), INTERVAL 1 YEAR), 1)",
[$studentId, $courseId, $teacherId]
);
}
}
/**
* توحيد أسماء ومفاتيح الصفوف
*/
+124 -29
View File
@@ -1,40 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
use PDO;
/** Integer-fils ledger. Credits are never inferred from UI, courses, or watch time. */
final class TeacherLedgerService {
public static function balance(int $teacherId): array {
$account=self::account($teacherId);
$row=Database::selectOne('SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN amount_fils WHEN debit_account_id=? THEN -amount_fils ELSE 0 END),0) AS balance FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?',[$account,$account,$account,$account]);
$holds=Database::selectOne("SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'",[$teacherId]);
$gross=(int)($row['balance']??0);$held=(int)($holds['held']??0);
return ['account_id'=>$account,'gross_fils'=>$gross,'held_fils'=>$held,'available_fils'=>max(0,$gross-$held)];
final class TeacherLedgerService
{
public static function balance(int $teacherId): array
{
// Balance reads must never create accounts or mutate financial state.
$accountId = self::existingAccountId($teacherId);
$gross = 0;
if ($accountId !== null) {
$row = Database::selectOne(
'SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN CAST(amount_fils AS SIGNED) WHEN debit_account_id=? THEN -CAST(amount_fils AS SIGNED) ELSE 0 END),0) AS balance
FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?',
[$accountId, $accountId, $accountId, $accountId]
);
$gross = (int)($row['balance'] ?? 0);
}
$holds = Database::selectOne(
"SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'",
[$teacherId]
);
$held = (int)($holds['held'] ?? 0);
return [
'account_id' => $accountId,
'gross_fils' => $gross,
'held_fils' => $held,
'available_fils' => max(0, $gross - $held),
];
}
public static function placeWithdrawalHold(int $teacherId,int $amountFils,string $key):array {
if($amountFils<=0||$amountFils>100000000||!preg_match('/^[A-Za-z0-9._:-]{16,128}$/',$key))return ['http_status'=>400,'status'=>'error','message'=>'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
$pdo=Database::getConnection();$pdo->beginTransaction();try {
$existing=Database::selectOne('SELECT uuid,amount_fils,status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',[$teacherId,$key]);
if($existing){if((int)$existing['amount_fils']!==$amountFils)return self::finish($pdo,['http_status'=>409,'status'=>'idempotency_conflict','message'=>'استعمل المفتاح ذاته بمبلغ مختلف.']);return self::finish($pdo,['http_status'=>200,'status'=>$existing['status'],'withdrawal_hold_id'=>$existing['uuid'],'replayed'=>true]);}
$balance=self::balance($teacherId);
if($amountFils>$balance['available_fils'])return self::finish($pdo,['http_status'=>409,'status'=>'insufficient_available_balance','message'=>'الرصيد المتاح لا يغطي الحجز.','available_fils'=>$balance['available_fils']]);
$uuid=self::uuid();Database::insert("INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",[$uuid,$teacherId,$amountFils,$key]);
return self::finish($pdo,['http_status'=>201,'status'=>'held','withdrawal_hold_id'=>$uuid,'amount_fils'=>$amountFils]);
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
public static function placeWithdrawalHold(int $teacherId, int $amountFils, string $key): array
{
if ($teacherId <= 0 || $amountFils <= 0 || $amountFils > 100000000
|| !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) {
return ['http_status' => 400, 'status' => 'error', 'message' => 'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
}
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
// This existing row serializes all holds for one teacher. Lock it
// before the first consistent read, so the next hold sees this one.
$teacher = Database::selectOne('SELECT id FROM teachers WHERE id=? FOR UPDATE', [$teacherId]);
if (!$teacher) {
return self::finish($pdo, ['http_status' => 404, 'status' => 'teacher_not_found']);
}
$existing = Database::selectOne(
'SELECT uuid, amount_fils, status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',
[$teacherId, $key]
);
if ($existing) {
if ((int)$existing['amount_fils'] !== $amountFils) {
return self::finish($pdo, [
'http_status' => 409, 'status' => 'idempotency_conflict',
'message' => 'استعمل المفتاح ذاته بمبلغ مختلف.',
]);
}
return self::finish($pdo, [
'http_status' => 200, 'status' => $existing['status'],
'withdrawal_hold_id' => $existing['uuid'], 'replayed' => true,
]);
}
$balance = self::balance($teacherId);
if ($amountFils > $balance['available_fils']) {
return self::finish($pdo, [
'http_status' => 409, 'status' => 'insufficient_available_balance',
'message' => 'الرصيد المتاح لا يغطي الحجز.',
'available_fils' => $balance['available_fils'],
]);
}
$uuid = self::uuid();
Database::insert(
"INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",
[$uuid, $teacherId, $amountFils, $key]
);
return self::finish($pdo, [
'http_status' => 201, 'status' => 'held',
'withdrawal_hold_id' => $uuid, 'amount_fils' => $amountFils,
]);
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
public static function releaseHold(int $teacherId,string $holdUuid,string $reason):array {
if(!self::isUuid($holdUuid)||trim($reason)==='')return ['http_status'=>400,'status'=>'error','message'=>'بيانات إلغاء الحجز غير صالحة.'];
$changed=Database::execute("UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW() WHERE uuid=? AND teacher_id=? AND status='held'",[$holdUuid,$teacherId]);
return $changed===1?['http_status'=>200,'status'=>'released','reason'=>$reason]:['http_status'=>409,'status'=>'hold_not_releasable','message'=>'الحجز غير موجود أو تمت تسويته.'];
public static function releaseHold(int $teacherId, string $holdUuid, string $reason): array
{
$reason = trim($reason);
if ($teacherId <= 0 || !self::isUuid($holdUuid) || $reason === '' || mb_strlen($reason) > 500) {
return ['http_status' => 400, 'status' => 'error', 'message' => 'بيانات إلغاء الحجز غير صالحة.'];
}
$changed = Database::execute(
"UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW(),release_reason=? WHERE uuid=? AND teacher_id=? AND status='held'",
[$reason, $holdUuid, $teacherId]
);
return $changed === 1
? ['http_status' => 200, 'status' => 'released', 'reason' => $reason]
: ['http_status' => 409, 'status' => 'hold_not_releasable', 'message' => 'الحجز غير موجود أو تمت تسويته.'];
}
private static function account(int $teacherId):int {
$existing=Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId]);
if($existing)return(int)$existing['id'];
Database::insert("INSERT INTO ledger_accounts (code,owner_type,owner_id,currency) VALUES ('teacher_available','teacher',?,'JOD')",[$teacherId]);
return(int)(Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId])['id']??0);
private static function existingAccountId(int $teacherId): ?int
{
$row = Database::selectOne(
"SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",
[$teacherId]
);
return $row ? (int)$row['id'] : null;
}
private static function isUuid(string $value): bool
{
return (bool)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', $value);
}
private static function uuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 15) | 64);
$bytes[8] = chr((ord($bytes[8]) & 63) | 128);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private static function finish(PDO $pdo, array $result): array
{
$pdo->commit();
return $result;
}
private static function isUuid(string $v):bool{return(bool)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',$v);}
private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));}
private static function finish(\PDO $pdo,array $result):array{$pdo->commit();return $result;}
}
@@ -20,6 +20,7 @@ final class TeacherSubmissionService {
$submission = Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]);
if ($submission && !$replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>409,'status'=>'existing_submission','message'=>'لديك حصة مسجلة لهذا الدرس. استخدم الاستبدال لإنشاء نسخة مرشحة.','submission_id'=>$submission['uuid'],'current_published_video_version_id'=>$submission['current_published_video_version_id'] ?: null]);
if (!$submission && $replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_missing','لا توجد حصة حالية لاستبدالها.'));
if ($submission && $replacement && empty($submission['current_published_video_version_id'])) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_unpublished','لا توجد نسخة منشورة حالية لاستبدالها.'));
if ($submissionUuid && (!$submission || !hash_equals((string)$submission['uuid'],$submissionUuid))) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_mismatch','هدف الاستبدال لا يطابق حصة المعلم الحالية.'));
if (!$submission) { Database::insert("INSERT INTO teacher_submissions (uuid,teacher_id,curriculum_lesson_id,status) VALUES (?,?,?,'draft')",[self::uuid(),$teacherId,$lesson['id']]); $submission=Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]); }
$next=(int)(Database::selectOne('SELECT COALESCE(MAX(version_number),0) n FROM video_versions WHERE teacher_submission_id=? FOR UPDATE',[$submission['id']])['n'] ?? 0)+1;
@@ -30,13 +31,14 @@ final class TeacherSubmissionService {
}
public static function attachUploadedLesson(int $teacherId,string $versionUuid,int $lessonId,string $sha): void {
if(!self::uuidValid($versionUuid)||!preg_match('/^[0-9a-f]{64}$/i',$sha)) throw new \RuntimeException('هوية النسخة أو بصمة الملف غير صالحة.');
$pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status='review' WHERE id=?",[$v['submission_id']]);$pdo->commit(); VideoReviewService::queueForVersion((int)$v['id']); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
$pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status=CASE WHEN current_published_video_version_id IS NULL THEN 'review' ELSE 'published' END WHERE id=?",[$v['submission_id']]); VideoReviewService::queueForVersion((int)$v['id']); $pdo->commit(); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
}
public static function reserveUpload(int $teacherId, string $versionUuid): bool {
if (!self::uuidValid($versionUuid)) return false;
return Database::execute(
"UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
SET vv.status='uploading', ts.status='uploading'
SET vv.status='uploading',
ts.status=CASE WHEN ts.current_published_video_version_id IS NULL THEN 'uploading' ELSE 'published' END
WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status IN ('draft', 'uploading')",
[$versionUuid, $teacherId]
) >= 1;
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Services;
/** Structural coverage gate. Content accuracy still requires academic review. */
final class VideoCoverageValidator
{
public static function valid(array $coverage, int $durationSeconds, string $videoSha256): bool
{
if ($durationSeconds <= 0 || $durationSeconds > 4 * 60 * 60 ||
!preg_match('/^[0-9a-f]{64}$/i', $videoSha256) ||
!hash_equals(strtolower($videoSha256), strtolower((string)($coverage['video_sha256'] ?? ''))) ||
($coverage['truncated'] ?? true) !== false ||
!empty($coverage['gaps']) ||
!is_array($coverage['segments'] ?? null) || $coverage['segments'] === []) {
return false;
}
$lastEnd = 0.0;
foreach ($coverage['segments'] as $segment) {
if (!is_array($segment) ||
!is_numeric($segment['start_seconds'] ?? null) ||
!is_numeric($segment['end_seconds'] ?? null) ||
!is_string($segment['transcript_ref'] ?? null) || trim($segment['transcript_ref']) === '' ||
!is_string($segment['visual_ref'] ?? null) || trim($segment['visual_ref']) === '') {
return false;
}
$start = (float)$segment['start_seconds'];
$end = (float)$segment['end_seconds'];
if (!is_finite($start) || !is_finite($end) ||
abs($start - $lastEnd) > 0.5 || $end <= $start || $end > $durationSeconds + 0.5) {
return false;
}
$lastEnd = $end;
}
return abs($lastEnd - $durationSeconds) <= 0.5;
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Services;
/** A human approval must not silently override missing or blocking findings. */
final class VideoReviewReportValidator
{
public static function approvable(array $report): bool
{
$coverage = $report['coverage'] ?? null;
if (!is_array($coverage) || ($coverage['truncated'] ?? true) !== false ||
!array_key_exists('unprocessed_intervals', $coverage) || $coverage['unprocessed_intervals'] !== [] ||
!is_array($coverage['section_results'] ?? null) || $coverage['section_results'] === [] ||
!is_array($coverage['objective_results'] ?? null) || $coverage['objective_results'] === [] ||
($report['missing_inputs'] ?? null) !== [] || !is_array($report['issues'] ?? null)) {
return false;
}
foreach (['section_results', 'objective_results'] as $field) {
foreach ($coverage[$field] as $result) {
if (!is_array($result) || !in_array($result['status'] ?? null, ['covered', 'out_of_declared_scope'], true)) {
return false;
}
if (($result['status'] ?? '') === 'covered' &&
empty($result['evidence_refs']) && empty($result['evidence_ref'])) {
return false;
}
if (($result['status'] ?? '') === 'out_of_declared_scope' &&
trim((string)($result['reason'] ?? '')) === '') {
return false;
}
}
}
foreach ($report['issues'] as $issue) {
if (!is_array($issue) ||
(in_array($issue['severity'] ?? null, ['critical', 'major'], true) &&
(($issue['resolution_status'] ?? '') !== 'resolved' ||
trim((string)($issue['resolution_evidence'] ?? '')) === ''))) {
return false;
}
}
return true;
}
}
+68 -17
View File
@@ -18,10 +18,24 @@ final class VideoReviewService
}
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$job=Database::selectOne('SELECT j.id,j.status,e.id AS evidence_id FROM video_review_jobs j LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
$job=Database::selectOne('SELECT j.id,j.status,j.video_sha256,j.markdown_sha256,vv.uuid AS video_version_uuid,cl.uuid AS curriculum_lesson_uuid,e.id AS evidence_id,e.coverage_json FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id JOIN curriculum_lessons cl ON cl.id=j.curriculum_lesson_id LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
if(!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
if(($job['status'] ?? '') !== 'ready_for_human_review') return self::finish($pdo,['http_status'=>409,'status'=>'job_not_reviewable','message'=>'حالة المهمة لا تسمح بقرار جديد.']);
if (empty($job['evidence_id'])) return self::finish($pdo,['http_status'=>422,'status'=>'missing_evidence','message'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']);
$bindings = $report['bindings'];
$coverageReport = $report['coverage'];
$savedCoverage = json_decode((string)$job['coverage_json'], true);
if (!is_array($bindings) || !is_array($coverageReport) || !is_array($savedCoverage) ||
!hash_equals((string)$job['video_version_uuid'], (string)($bindings['video_version_id'] ?? '')) ||
!hash_equals((string)$job['curriculum_lesson_uuid'], (string)($bindings['curriculum_lesson_id'] ?? '')) ||
!hash_equals((string)$job['video_sha256'], (string)($bindings['video_sha256'] ?? '')) ||
!hash_equals((string)$job['markdown_sha256'], (string)($bindings['markdown_sha256'] ?? '')) ||
($coverageReport['truncated'] ?? true) !== false || !empty($coverageReport['unprocessed_intervals'])) {
return self::finish($pdo,['http_status'=>422,'status'=>'stale_or_incomplete_report','message'=>'ارتباطات التقرير أو تغطيته لا تطابق النسخة والمصدر.']);
}
if ($decision === 'approved' && !VideoReviewReportValidator::approvable($report)) {
return self::finish($pdo,['http_status'=>422,'status'=>'blocking_findings','message'=>'تقرير الفحص لا يغطي كل الأقسام والأهداف أو يحتوي مشكلات غير محسومة.']);
}
$approved=$decision==='approved' && $recommendation==='ready_for_human_review';
Database::query("INSERT INTO video_review_reports (video_review_job_id,recommendation,report_json,reviewer_id,human_decision,reviewed_at) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE recommendation=VALUES(recommendation),report_json=VALUES(report_json),reviewer_id=VALUES(reviewer_id),human_decision=VALUES(human_decision),reviewed_at=NOW()",[$job['id'],$recommendation,json_encode($report,JSON_UNESCAPED_UNICODE),$reviewerId,$decision]);
Database::query('UPDATE video_review_jobs SET status=?, completed_at=NOW() WHERE id=?',[$approved?'approved':'rejected',$job['id']]);
@@ -34,25 +48,24 @@ final class VideoReviewService
if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) {
return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.'];
}
if (empty($coverage['segments']) || !is_array($coverage['segments'])) {
return ['http_status'=>422,'status'=>'incomplete_evidence','message'=>'يلزم سجل تغطية زمني للفيديو.'];
}
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$job=Database::selectOne('SELECT id,video_sha256,status FROM video_review_jobs WHERE uuid=? FOR UPDATE',[$jobUuid]);
$job=Database::selectOne('SELECT j.id,j.video_sha256,j.markdown_sha256,j.status,j.video_version_id,l.duration_seconds
FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id
JOIN lessons l ON l.id=vv.source_lesson_id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
if (!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
if (!in_array($job['status'],['queued','collecting_evidence','needs_evidence'],true)) return self::finish($pdo,['http_status'=>409,'status'=>'job_not_collecting','message'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']);
$transcript=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$transcriptAssetUuid]);
$visual=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$visualAssetUuid]);
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || $visual['review_status'] !== 'approved') {
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' ||
$visual['asset_type'] !== 'other' || $visual['review_status'] !== 'approved' ||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$transcript['source_reference']) ||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$visual['source_reference'])) {
return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
}
$segments=$coverage['segments']; $lastEnd=0;
foreach ($segments as $segment) {
if (!is_array($segment) || !isset($segment['start_seconds'],$segment['end_seconds'],$segment['transcript_ref'],$segment['visual_ref']) || !is_numeric($segment['start_seconds']) || !is_numeric($segment['end_seconds']) || (float)$segment['start_seconds'] < $lastEnd || (float)$segment['end_seconds'] <= (float)$segment['start_seconds']) {
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']);
}
$lastEnd=(float)$segment['end_seconds'];
if (!VideoCoverageValidator::valid($coverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
!hash_equals((string)$job['markdown_sha256'], (string)($coverage['markdown_sha256'] ?? ''))) {
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'التغطية لا تشمل كامل الفيديو أو لا تطابق المصدر.']);
}
Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]);
Database::query("UPDATE video_review_jobs SET status='ready_for_human_review' WHERE id=?",[$job['id']]);
@@ -66,25 +79,63 @@ final class VideoReviewService
$pdo->beginTransaction();
try {
$job = Database::selectOne(
"SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid,
vv.source_lesson_id, ts.id AS submission_id,
"SELECT j.id, j.status, j.video_version_id, j.video_sha256, j.markdown_sha256,
vv.uuid AS version_uuid, vv.status AS version_status, vv.content_sha256,
vv.source_lesson_id, vv.replaces_video_version_id,
ts.id AS submission_id, ts.curriculum_lesson_id,
ts.current_published_video_version_id, cl.source_status,
l.encoding_status, l.duration_seconds,
currentv.uuid AS current_version_uuid
FROM video_review_jobs j
JOIN video_versions vv ON vv.id = j.video_version_id
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
LEFT JOIN lessons l ON l.id=vv.source_lesson_id
LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id
WHERE j.uuid = ? FOR UPDATE",
[$jobUuid]
);
if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']);
$report = Database::selectOne('SELECT human_decision FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') {
$report = Database::selectOne('SELECT human_decision,recommendation,reviewer_id,report_json FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
$evidence = Database::selectOne('SELECT id,coverage_json FROM video_review_evidence WHERE video_review_job_id=? FOR UPDATE', [$job['id']]);
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved' ||
($report['recommendation'] ?? '') !== 'ready_for_human_review' || empty($report['reviewer_id']) || !$evidence) {
return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']);
}
$reportData = json_decode((string)$report['report_json'], true);
$savedCoverage = json_decode((string)$evidence['coverage_json'], true);
if (!is_array($reportData) || !is_array($savedCoverage) ||
!VideoReviewReportValidator::approvable($reportData) ||
!VideoCoverageValidator::valid($savedCoverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
!hash_equals((string)$job['markdown_sha256'], (string)($savedCoverage['markdown_sha256'] ?? '')) ||
($reportData['coverage']['truncated'] ?? true) !== false ||
!empty($reportData['coverage']['unprocessed_intervals'])) {
return self::finish($pdo, ['status'=>'stale_evidence','http_status'=>409,'message'=>'أدلة الفيديو أو التقرير لم تعد صالحة للنشر.']);
}
if (($job['version_status'] ?? '') !== 'review' || ($job['source_status'] ?? '') !== 'approved' ||
($job['encoding_status'] ?? '') !== 'ready' || empty($job['source_lesson_id']) ||
!hash_equals((string)$job['video_sha256'], (string)($job['content_sha256'] ?? '')) ||
$job['video_sha256'] === str_repeat('0', 64) || $job['markdown_sha256'] === str_repeat('0', 64)) {
return self::finish($pdo, ['status'=>'version_not_ready','http_status'=>409,'message'=>'نسخة الفيديو أو درسها غير جاهزين للنشر.']);
}
$markdown = Database::selectOne(
"SELECT a.id FROM publication_bundles pb
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
WHERE pb.curriculum_lesson_id=? AND pb.status='published'
AND a.sha256=? AND a.review_status='approved' AND a.rights_status='cleared'
LIMIT 1 FOR UPDATE",
[(int)$job['curriculum_lesson_id'], $job['markdown_sha256']]
);
if (!$markdown) return self::finish($pdo, ['status'=>'source_changed','http_status'=>409,'message'=>'مصدر الدرس المعتمد لا يطابق تقرير الفحص.']);
if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) {
return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']);
}
if (empty($job['source_lesson_id'])) return self::finish($pdo, ['status' => 'storage_not_ready', 'http_status' => 409, 'message' => 'الفيديو غير مرتبط بتخزين جاهز.']);
if (($job['current_published_video_version_id'] === null) !== ($job['replaces_video_version_id'] === null) ||
($job['current_published_video_version_id'] !== null && (int)$job['current_published_video_version_id'] !== (int)$job['replaces_video_version_id']) ||
($job['current_published_video_version_id'] !== null && $expectedCurrentVersionUuid === null)) {
return self::finish($pdo, ['status'=>'current_version_conflict','http_status'=>409,'message'=>'يلزم تحديد النسخة الحالية المطابقة قبل الاستبدال.']);
}
Database::query("UPDATE video_versions SET status = 'superseded' WHERE teacher_submission_id = ? AND status = 'published'", [$job['submission_id']]);
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$job['video_version_id']]);
Database::query("UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", [$job['video_version_id'], $job['submission_id']]);
+2 -2
View File
@@ -385,7 +385,7 @@ class VideoService
}
header('Content-Type: ' . $mime);
header('Cache-Control: public, max-age=86400');
header('Cache-Control: private, no-store');
header('Access-Control-Allow-Origin: *');
header('Content-Length: ' . filesize($filePath));
readfile($filePath);
@@ -438,7 +438,7 @@ class VideoService
header('Content-Type: ' . $mime);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600');
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
header('Access-Control-Allow-Origin: *');
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Services;
/** Pure watch-v1 measurement rules. This measures playback evidence, not attention. */
final class WatchIntervalPolicy
{
public static function evaluate(
string $previousType,
string $eventType,
int $previousPositionMs,
int $positionMs,
int $serverDeltaSeconds,
int $durationMs,
string $fundingSource
): array {
if ($eventType !== 'heartbeat' || !in_array($previousType, ['start', 'heartbeat', 'resume'], true)) {
return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'non_contiguous_event'];
}
if ($fundingSource === 'none') {
return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'unfunded_session'];
}
if ($durationMs <= 0 || $positionMs > $durationMs) {
return ['seconds' => 0, 'status' => 'review', 'reason' => 'position_out_of_bounds'];
}
$clientDeltaMs = $positionMs - $previousPositionMs;
if ($clientDeltaMs < 1000 || $clientDeltaMs > 90000 || $serverDeltaSeconds < 1 || $serverDeltaSeconds > 90) {
return ['seconds' => 0, 'status' => 'review', 'reason' => 'implausible_delta'];
}
$seconds = min((int)floor($clientDeltaMs / 1000), $serverDeltaSeconds, 60);
if ($seconds <= 0) return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'zero_interval'];
return ['seconds' => $seconds, 'status' => 'measured', 'reason' => 'contiguous_heartbeat'];
}
}
+23 -16
View File
@@ -10,8 +10,13 @@ final class WatchSessionService {
if (!$row['allowed']) return $row;
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$active=Database::selectOne("SELECT uuid FROM watch_sessions WHERE student_id=? AND video_version_id=? AND status='active' FOR UPDATE",[$studentId,$row['version_id']]);
if ($active) { $pdo->commit(); return ['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]; }
$student=Database::selectOne('SELECT id FROM students WHERE id=? FOR UPDATE',[$studentId]);
if(!$student)return self::finish($pdo,['http_status'=>404,'status'=>'student_not_found']);
$active=Database::selectOne("SELECT uuid,video_version_id FROM watch_sessions WHERE student_id=? AND status='active' ORDER BY id DESC LIMIT 1 FOR UPDATE",[$studentId]);
if ($active) {
if((int)$active['video_version_id']===(int)$row['version_id'])return self::finish($pdo,['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]);
return self::finish($pdo,['http_status'=>409,'status'=>'another_session_active','message'=>'أنهِ جلسة المشاهدة الحالية قبل بدء حصة أخرى.']);
}
$uuid=self::uuid();
Database::insert("INSERT INTO watch_sessions (uuid,student_id,video_version_id,funding_source,status) VALUES (?,?,?,?, 'active')",[$uuid,$studentId,$row['version_id'],$row['funding_source']]);
$id=(int)(Database::selectOne('SELECT id FROM watch_sessions WHERE uuid=?',[$uuid])['id'] ?? 0);
@@ -22,33 +27,35 @@ final class WatchSessionService {
public static function event(int $studentId,string $sessionUuid,int $sequence,string $type,int $positionMs,array $payload=[]):array {
if(!self::isUuid($sessionUuid)||$sequence<2||!in_array($type,['heartbeat','pause','seek','resume','end','buffer'],true)||$positionMs<0) return ['http_status'=>400,'status'=>'error','message'=>'حدث المشاهدة غير صالح.'];
$pdo=Database::getConnection();$pdo->beginTransaction();try {
$session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source FROM watch_sessions ws WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]);
$session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source,l.duration_seconds FROM watch_sessions ws JOIN video_versions vv ON vv.id=ws.video_version_id JOIN lessons l ON l.id=vv.source_lesson_id WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]);
if(!$session)return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'جلسة المشاهدة غير موجودة.']);
if($session['status']!=='active')return self::finish($pdo,['http_status'=>409,'status'=>'ended','message'=>'انتهت جلسة المشاهدة.']);
$version = Database::selectOne('SELECT uuid FROM video_versions WHERE id=?', [(int)$session['video_version_id']]);
$authorised = $version ? self::authorisedVersion($studentId, (string)$version['uuid'], null) : ['allowed'=>false];
if (empty($authorised['allowed'])) {
Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);
return self::finish($pdo,['http_status'=>409,'status'=>'revoked','message'=>'انتهت صلاحية هذه المشاهدة.']);
}
$last=Database::selectOne('SELECT sequence_no,event_type,client_position_ms,server_received_at FROM watch_events WHERE watch_session_id=? ORDER BY sequence_no DESC LIMIT 1 FOR UPDATE',[$session['id']]);
if((int)$last['sequence_no'] >= $sequence) return self::finish($pdo,['http_status'=>200,'status'=>'replayed']);
if((int)$last['sequence_no']+1 !== $sequence) return self::finish($pdo,['http_status'=>409,'status'=>'sequence_gap','message'=>'تسلسل أحداث المشاهدة غير متصل.']);
Database::insert('INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,?,?,?,?)',[$session['id'],$sequence,$type,$positionMs,json_encode($payload,JSON_UNESCAPED_UNICODE)]);
$eligible=0;
if($type==='heartbeat' && in_array($last['event_type'],['start','heartbeat','resume'],true)) {
$clientDelta=$positionMs-(int)$last['client_position_ms'];
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
// A contiguous interval is bounded by elapsed server time and 60s;
// seeks, background bursts, and client-only minutes earn nothing.
if($clientDelta>=1000 && $clientDelta<=90000 && $serverDelta>=1 && $serverDelta<=90) {
$eligible=min((int)floor($clientDelta/1000),(int)$serverDelta,60);
if($eligible>0) Database::insert("INSERT INTO eligible_watch_intervals (watch_session_id,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,'watch-v1','measured')",[$session['id'],(int)$last['client_position_ms'],$positionMs,$eligible,'contiguous_heartbeat']);
}
}
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
$decision=WatchIntervalPolicy::evaluate((string)$last['event_type'],$type,(int)$last['client_position_ms'],$positionMs,$serverDelta,max(0,(int)$session['duration_seconds'])*1000,(string)$session['funding_source']);
$eligible=(int)$decision['seconds'];
if($type==='heartbeat') Database::insert(
"INSERT INTO eligible_watch_intervals (watch_session_id,event_sequence_no,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,?,'watch-v1',?)",
[$session['id'],$sequence,(int)$last['client_position_ms'],$positionMs,$eligible,$decision['reason'],$decision['status']]
);
if($type==='end'){Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);}
return self::finish($pdo,['http_status'=>200,'status'=>$type==='end'?'ended':'recorded','eligible_seconds_added'=>$eligible]);
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
}
private static function authorisedVersion(int $studentId,string $uuid,?string $national):array {
if(!self::isUuid($uuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.'];
$row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,c.grade_level 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 lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' JOIN courses c ON c.id=l.course_id WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]);
$row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,cl.grade_key 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' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]);
if(!$row)return ['http_status'=>404,'status'=>'error','message'=>'نسخة الفيديو غير منشورة.'];
$access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_level']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false);
$access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_key']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false);
if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالمشاهدة.'];
return ['allowed'=>true,'version_id'=>(int)$row['id'],'funding_source'=>!empty($access['is_sponsored'])?'school':(($access['reason']??'')==='paid_pass_active'?'marketplace':'none')];
}