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')];
}
@@ -0,0 +1,33 @@
-- Apply after database_schema.sql. Rollback: disable the new endpoints, preserve
-- attempt rows for audit, then remove the column/table only after export.
ALTER TABLE student_error_notebook
ADD COLUMN retention_due_at TIMESTAMP NULL DEFAULT NULL AFTER mastered_at;
-- Existing question rows remain draft. A reviewer must approve each question
-- against the named lesson and record its source before it may prove remediation.
ALTER TABLE questions
ADD COLUMN remediation_review_status ENUM('draft','approved','rejected') NOT NULL DEFAULT 'draft',
ADD COLUMN remediation_reviewed_by BIGINT UNSIGNED NULL,
ADD COLUMN remediation_reviewed_at TIMESTAMP NULL DEFAULT NULL,
ADD COLUMN remediation_source_reference VARCHAR(700) NULL;
CREATE TABLE IF NOT EXISTS error_notebook_quiz_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
uuid CHAR(36) NOT NULL,
error_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED NOT NULL,
phase ENUM('initial','retention') NOT NULL,
question_ids_json JSON NOT NULL,
answer_key_json JSON NOT NULL,
status ENUM('open','passed','failed') NOT NULL DEFAULT 'open',
answers_sha256 CHAR(64) NULL,
correct_count TINYINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
submitted_at TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_error_quiz_uuid (uuid),
KEY idx_error_quiz_student (student_id,error_id,status),
CONSTRAINT fk_error_quiz_error FOREIGN KEY (error_id) REFERENCES student_error_notebook(id) ON DELETE RESTRICT,
CONSTRAINT fk_error_quiz_student FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,7 @@
-- Apply after 20260909_financial_ledger.sql, before deploying releaseHold changes.
-- Existing released rows stay NULL: their original reason cannot be reconstructed.
ALTER TABLE teacher_withdrawal_holds
ADD COLUMN release_reason VARCHAR(500) NULL AFTER released_at;
-- Rollback: revert the application writer first. Keep this nullable audit column
-- and any recorded reasons; dropping it would erase financial history.
@@ -0,0 +1,17 @@
-- Apply after 20260909_watch_sessions.sql.
-- Preflight: this intentionally fails if historical data has more than one
-- active session per student. Reconcile those rows to ended/invalidated first.
ALTER TABLE watch_sessions
ADD COLUMN active_student_id BIGINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN status = 'active' THEN student_id ELSE NULL END) STORED,
ADD UNIQUE KEY uq_watch_one_active_per_student (active_student_id);
-- One decision per event, including repeated positions that are legitimately
-- excluded. Position pairs alone are not an event identity.
ALTER TABLE eligible_watch_intervals
DROP INDEX uq_eligible_interval,
ADD COLUMN event_sequence_no INT UNSIGNED NULL AFTER watch_session_id,
ADD UNIQUE KEY uq_watch_interval_event (watch_session_id, event_sequence_no, policy_version);
-- Rollback: stop writers that rely on the invariant first. Dropping the key
-- weakens protection and is not a routine production rollback.
+11 -8
View File
@@ -75,8 +75,9 @@ $chatMiddleware = [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\Chat
$router->post('/api/curriculum/upload-pdf', [\App\Controllers\CurriculumController::class, 'uploadPdf'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/upload-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getTree']);
$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getPublishedTree']);
$router->get('/api/teacher/curriculum/intake-tree', [\App\Controllers\CurriculumController::class, 'getTree'], $teacherMiddleware);
$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $curriculumManagerMiddleware);
@@ -91,8 +92,10 @@ $router->get('/api/curriculum/search', function ($request, $response) {
});
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']);
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware);
$router->post('/api/curriculum/assets/{assetId}/download-ticket', [\App\Controllers\CurriculumController::class, 'issueAssetDownloadTicket'], $studentMiddleware);
$router->get('/api/curriculum/assets/{assetId}/download', [\App\Controllers\CurriculumController::class, 'downloadAssetWithTicket']);
$router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware);
$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);
$router->get('/api/video-versions/{versionId}/playback', [\App\Controllers\VideoController::class, 'getVideoVersionPlayback'], $studentMiddleware);
@@ -144,9 +147,9 @@ $router->post('/api/auth/otp/verify', [\App\Controllers\AuthController::class,
$router->post('/api/auth/logout', [\App\Controllers\AuthController::class, 'logout'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/auth/student/login-national-id', [\App\Controllers\AuthController::class, 'verifyNationalId'], [\App\Middlewares\RateLimitMiddleware::class]);
$router->get('/api/student/profile/status', [\App\Controllers\AuthController::class, 'studentProfileStatus'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/student/profile/status', [\App\Controllers\AuthController::class, 'studentProfileStatus'], $studentMiddleware);
$router->post('/api/student/profile/setup', [\App\Controllers\AuthController::class, 'studentProfileSetup'], [\App\Middlewares\RateLimitMiddleware::class]);
$router->post('/api/student/profile/update-grade', [\App\Controllers\AuthController::class, 'updateStudentGrade'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/student/profile/update-grade', [\App\Controllers\AuthController::class, 'updateStudentGrade'], $studentMiddleware);
// Guardian Routes (Authenticated)
$router->get('/api/guardian/dashboard', [\App\Controllers\GuardianController::class, 'getDashboard'], $guardianMiddleware);
@@ -175,8 +178,8 @@ $router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoCon
$router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/videos/hls/{uuid}/{file}', [\App\Controllers\VideoController::class, 'streamHls'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/lessons/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/lessons/playback', [\App\Controllers\VideoController::class, 'legacyPlaybackUnavailable'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'legacyPlaybackUnavailable'], [\App\Middlewares\AuthMiddleware::class]);
// Student & Teacher Chat Routes (API-Driven, Authenticated)
$router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], $chatMiddleware);
@@ -195,7 +198,7 @@ $router->post('/api/student/lessons/{id}/progress', [\App\Controllers\VideoContr
// Smart Error Notebook & Adaptive Remediation Routes (دفتر الأخطاء الذكي والمسارات العلاجية)
$router->get('/api/student/error-notebook', [\App\Controllers\ErrorNotebookController::class, 'getErrorNotebook'], $studentMiddleware);
$router->post('/api/student/error-notebook/log', [\App\Controllers\ErrorNotebookController::class, 'logError'], $studentMiddleware);
$router->get('/api/student/error-notebook/remediation-quiz', [\App\Controllers\ErrorNotebookController::class, 'getRemediationQuiz'], $studentMiddleware);
$router->post('/api/student/error-notebook/remediation-quiz', [\App\Controllers\ErrorNotebookController::class, 'getRemediationQuiz'], $studentMiddleware);
$router->post('/api/student/error-notebook/resolve', [\App\Controllers\ErrorNotebookController::class, 'resolveError'], $studentMiddleware);
// Multi-Teacher Marketplace & Fair Reputation Routes (AI Telemetry + Anti-Brigade Defense)
+50 -147
View File
@@ -3,169 +3,72 @@
declare(strict_types=1);
/**
* Links existing and ministry Grade 10 Math videos to teacher_submissions and video_versions
* so that they appear in the curriculum tree with has_video = true and stream in the student app.
* Read-only audit for historic Grade 10 Math video records.
*
* Usage:
* php backend/scripts/link_legacy_math_videos.php [--apply]
* This replaces the retired linker, which hard-coded a Bunny hostname and
* marked legacy records published without version-bound video review evidence.
* New media must go through TeacherSubmissionService and the full review flow.
*
* Usage: php backend/scripts/link_legacy_math_videos.php
*/
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
use App\Services\CurriculumService;
$apply = in_array('--apply', $argv, true);
echo "=== Grade 10 Math Video Linking & Activation ===\n";
// 1. Resolve master system course
$courseId = CurriculumService::getOrCreateSystemCourse();
echo "System Course ID: {$courseId}\n";
// 2. Resolve teacher ID
$teacher = Database::selectOne("SELECT id, full_name FROM teachers WHERE id = 1 LIMIT 1");
if (!$teacher) {
$teacher = Database::selectOne("SELECT id, full_name FROM teachers LIMIT 1");
if (in_array('--apply', $argv, true)) {
fwrite(STDERR, "This legacy linker is retired and read-only. Use the teacher upload and review workflow.\n");
exit(2);
}
if (!$teacher) {
echo "ERROR: No teacher found in database.\n";
exit(1);
}
$teacherId = (int)$teacher['id'];
echo "Publishing Teacher: {$teacher['full_name']} (ID: {$teacherId})\n";
// 3. Find Grade 10 Math Unit 1 Lessons in curriculum_lessons
$mathLessons = Database::select(
"SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path
FROM curriculum_lessons
WHERE grade_key = 'grade_10' AND subject_key = 'math_10' AND unit_key = 'unit_01'
ORDER BY lesson_key"
echo "=== Grade 10 Math legacy video audit (read-only) ===\n";
$lessons = Database::select(
"SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path, curriculum_version
FROM curriculum_lessons
WHERE grade_key='grade_10' AND subject_key='math_10' AND unit_key='unit_01'
ORDER BY lesson_key, id"
);
if (empty($mathLessons)) {
echo "No Grade 10 Math Unit 1 lessons found in curriculum_lessons.\n";
exit(1);
}
foreach ($lessons as $curriculumLesson) {
$path = (string)$curriculumLesson['source_manifest_path'];
$pathWithoutExtension = preg_replace('/\.md$/i', '', $path);
$records = Database::select(
"SELECT l.id, l.title, l.curriculum_key, l.storage_type, l.duration_seconds,
l.encoding_status, l.hls_url, l.r2_url,
vv.uuid AS version_uuid, vv.status AS version_status,
ts.status AS submission_status, j.status AS review_status
FROM lessons l
LEFT JOIN video_versions vv ON vv.source_lesson_id=l.id
LEFT JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
LEFT JOIN video_review_jobs j ON j.video_version_id=vv.id
WHERE l.curriculum_key IN (?, ?)
ORDER BY l.id, vv.id",
[$path, $pathWithoutExtension]
);
echo "Found " . count($mathLessons) . " Unit 1 Math lessons in curriculum_lessons.\n";
// Ministry / Certified lesson video streams (Bunny Stream / Cloudflare R2 standard HLS)
$knownStreams = [
'lesson_01' => [
'title' => 'الدرس الأول: حل نظام مكون من معادلة خطية ومعادلة تربيعية',
'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l1/playlist.m3u8',
'duration' => 1380, // 23 minutes
],
'lesson_02' => [
'title' => 'الدرس الثاني: حل نظام مكون من معادلتين تربيعيتين',
'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l2/playlist.m3u8',
'duration' => 1440, // 24 minutes
],
];
foreach ($mathLessons as $cl) {
$lessonKey = $cl['lesson_key'];
if (!isset($knownStreams[$lessonKey])) {
echo "\n{$path} [{$curriculumLesson['curriculum_version']}]\n";
if ($records === []) {
echo " no matching video records\n";
continue;
}
$streamInfo = $knownStreams[$lessonKey];
echo "\nProcessing: [{$lessonKey}] {$cl['title']}\n";
// Check if a record already exists in lessons table
$existingLesson = Database::selectOne(
"SELECT id, title, hls_url, encoding_status FROM lessons WHERE curriculum_key = ? OR title LIKE ? LIMIT 1",
[$cl['source_manifest_path'], '%' . $streamInfo['title'] . '%']
);
$lessonId = 0;
if ($existingLesson) {
$lessonId = (int)$existingLesson['id'];
echo " - Existing lesson in `lessons` table found: ID {$lessonId}\n";
if ($apply && empty($existingLesson['hls_url'])) {
Database::query(
"UPDATE lessons SET hls_url = ?, encoding_status = 'ready', duration_seconds = ? WHERE id = ?",
[$streamInfo['hls_url'], $streamInfo['duration'], $lessonId]
);
}
} else {
echo " - Creating entry in `lessons` table...\n";
if ($apply) {
$vUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$lessonId = (int)Database::insert(
"INSERT INTO lessons (course_id, title, curriculum_key, sequence_order, video_uuid, storage_type, hls_url, encoding_status, duration_seconds)
VALUES (?, ?, ?, 1, ?, 'bunny_stream', ?, 'ready', ?)",
[$courseId, $cl['title'], $cl['source_manifest_path'], $vUuid, $streamInfo['hls_url'], $streamInfo['duration']]
);
echo " - Created lesson ID: {$lessonId}\n";
}
}
// Check teacher_submissions
$sub = Database::selectOne(
"SELECT id, uuid, current_published_video_version_id, status FROM teacher_submissions WHERE teacher_id = ? AND curriculum_lesson_id = ? LIMIT 1",
[$teacherId, $cl['id']]
);
$subId = 0;
if ($sub) {
$subId = (int)$sub['id'];
echo " - Existing submission found: ID {$subId} (Status: {$sub['status']})\n";
if ($apply && $sub['status'] !== 'published') {
Database::query("UPDATE teacher_submissions SET status = 'published' WHERE id = ?", [$subId]);
}
} else {
echo " - Creating teacher_submission...\n";
if ($apply) {
$sUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$subId = (int)Database::insert(
"INSERT INTO teacher_submissions (uuid, teacher_id, curriculum_lesson_id, status) VALUES (?, ?, ?, 'published')",
[$sUuid, $teacherId, $cl['id']]
);
echo " - Created submission ID: {$subId}\n";
}
}
// Check video_versions
if ($subId > 0 && $lessonId > 0) {
$vv = Database::selectOne(
"SELECT id, uuid, status FROM video_versions WHERE teacher_submission_id = ? AND source_lesson_id = ? LIMIT 1",
[$subId, $lessonId]
foreach ($records as $record) {
$url = (string)($record['hls_url'] ?: $record['r2_url'] ?: '');
$host = $url !== '' ? (string)(parse_url($url, PHP_URL_HOST) ?: 'relative') : 'none';
$storage = str_ends_with($host, '.r2.dev') ? 'cloudflare_r2' : $record['storage_type'];
echo sprintf(
" lesson=%d storage=%s duration=%ss encoding=%s submission=%s version=%s review=%s host=%s\n",
(int)$record['id'],
$storage,
(int)$record['duration_seconds'],
(string)$record['encoding_status'],
(string)($record['submission_status'] ?? 'none'),
(string)($record['version_status'] ?? 'none'),
(string)($record['review_status'] ?? 'missing'),
$host
);
$vvId = 0;
if ($vv) {
$vvId = (int)$vv['id'];
echo " - Existing video_version found: ID {$vvId} (Status: {$vv['status']})\n";
if ($apply && $vv['status'] !== 'published') {
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$vvId]);
}
} else {
echo " - Creating video_version...\n";
if ($apply) {
$vvUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$vvId = (int)Database::insert(
"INSERT INTO video_versions (uuid, teacher_submission_id, version_number, status, source_lesson_id, published_at)
VALUES (?, ?, 1, 'published', ?, NOW())",
[$vvUuid, $subId, $lessonId]
);
echo " - Created video_version ID: {$vvId}\n";
}
}
if ($apply && $vvId > 0) {
Database::query(
"UPDATE teacher_submissions SET current_published_video_version_id = ? WHERE id = ?",
[$vvId, $subId]
);
echo " - Updated submission current_published_video_version_id = {$vvId}\n";
}
}
}
if ($apply) {
echo "\n=== Successfully linked and activated Math Unit 1 videos! ===\n";
} else {
echo "\nDRY RUN complete. Run with --apply to execute writes.\n";
}
echo "\nNo records were changed.\n";
+68 -29
View File
@@ -5,12 +5,12 @@ declare(strict_types=1);
/**
* Publication manager for Grade 10 curriculum bundles and assets.
*
* Transitions lessons and their assets from `draft`/`unverified` to `published`/`approved`.
* Publishes bundles only after independent lesson and asset approval.
*
* Safety rules:
* - Default mode is a read-only dry run. It writes nothing unless `--apply` is present.
* - All database writes happen inside a single atomic transaction.
* - Only assets belonging to the selected curriculum lessons are updated.
* - This script never grants academic approval or clears rights.
*
* Usage:
* php backend/scripts/publish_grade10_bundle.php --status
@@ -93,7 +93,7 @@ if ($isPilot) {
'grade_10/math_10/semester_1/unit_01/lesson_01.md',
'grade_10/math_10/semester_1/unit_01/lesson_02.md',
'grade_10/math_10/semester_1/unit_01/lesson_03.md',
'grade_10/math_10/semester_1/unit_01/intro_and_project.md',
'grade_10/physics_10/semester_1/unit_02/lesson_03.md',
// Physics 10: Semester 1, Units 1 & 2
'grade_10/physics_10/semester_1/unit_01/lesson_01.md',
'grade_10/physics_10/semester_1/unit_01/lesson_02.md',
@@ -140,12 +140,16 @@ if (empty($targetLessons)) {
echo "No matching curriculum lessons found for the specified criteria.\n";
exit(0);
}
if ($isPilot && count($targetLessons) !== 12) {
fwrite(STDERR, "REFUSE: pilot must contain exactly 12 matching lessons; found " . count($targetLessons) . ".\n");
exit(2);
}
$lessonIds = array_column($targetLessons, 'id');
$idList = implode(',', array_map('intval', $lessonIds));
// 3. Plan summary
$targetAction = $isRevert ? 'REVERT TO DRAFT' : 'PUBLISH / APPROVE';
$targetAction = $isRevert ? 'WITHDRAW PUBLISHED BUNDLES' : 'PUBLISH REVIEWED BUNDLES';
echo sprintf("=== Grade 10 Publication Plan (%s) ===\n", $apply ? 'APPLY' : 'DRY-RUN');
echo sprintf("Action: %s\n", $targetAction);
echo sprintf("Matched Lessons: %d\n", count($targetLessons));
@@ -161,8 +165,60 @@ foreach ($targetLessons as $idx => $l) {
);
}
function publicationBlockers(array $targetLessons, bool $lock): array
{
$blockers = [];
foreach ($targetLessons as $lesson) {
$id = (int)$lesson['id'];
$path = $lesson['source_manifest_path'];
$detail = Database::selectOne(
'SELECT source_status, reviewed_by, reviewed_at FROM curriculum_lessons WHERE id = ?' . ($lock ? ' FOR UPDATE' : ''),
[$id]
);
if (!$detail || $detail['source_status'] !== 'approved' || empty($detail['reviewed_by']) || empty($detail['reviewed_at'])) {
$blockers[] = "{$path}: academic approval with reviewer and date is missing";
}
$bundles = Database::select(
'SELECT id, status FROM publication_bundles WHERE curriculum_lesson_id = ?' . ($lock ? ' FOR UPDATE' : ''),
[$id]
);
if (count($bundles) !== 1) {
$blockers[] = "{$path}: expected exactly one bundle; found " . count($bundles);
continue;
}
$bundle = $bundles[0];
if (!in_array($bundle['status'], ['review', 'published'], true)) {
$blockers[] = "{$path}: bundle must be in review before publication";
}
$assets = Database::select(
'SELECT a.id, a.asset_type, a.review_status, a.rights_status, a.source_reference, pba.role
FROM publication_bundle_assets pba JOIN content_assets a ON a.id = pba.content_asset_id
WHERE pba.publication_bundle_id = ?' . ($lock ? ' FOR UPDATE' : ''),
[(int)$bundle['id']]
);
if ($assets === []) {
$blockers[] = "{$path}: bundle has no assets";
}
$hasPrimary = false;
foreach ($assets as $asset) {
if ($asset['role'] === 'primary_lesson' && $asset['asset_type'] === 'lesson_markdown') $hasPrimary = true;
if ($asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' || trim((string)$asset['source_reference']) === '') {
$blockers[] = "{$path}: asset {$asset['id']} lacks review, rights clearance, or source";
}
}
if (!$hasPrimary) $blockers[] = "{$path}: approved lesson Markdown is missing";
}
return $blockers;
}
if (!$apply && !$isRevert) {
$blockers = publicationBlockers($targetLessons, false);
foreach ($blockers as $blocker) echo "BLOCKED {$blocker}\n";
echo $blockers === [] ? "\n[Ready for reviewed publication; append --apply.]\n" : "\n[Publication blocked; resolve review evidence first.]\n";
exit($blockers === [] ? 0 : 2);
}
if (!$apply) {
echo "\n[Dry-run completed. To execute, append --apply to your command.]\n";
echo "\n[Dry-run completed. To withdraw bundles, append --apply.]\n";
exit(0);
}
@@ -172,34 +228,17 @@ $pdo->beginTransaction();
try {
if ($isRevert) {
// Revert to draft
// Withdrawal preserves academic decisions, rights records and history.
Database::query(
"UPDATE curriculum_lessons SET source_status = 'unverified' WHERE id IN ({$idList})"
);
Database::query(
"UPDATE publication_bundles SET status = 'draft', published_at = NULL WHERE curriculum_lesson_id IN ({$idList})"
);
Database::query(
"UPDATE 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
SET a.review_status = 'draft', a.rights_status = 'review_required'
WHERE pb.curriculum_lesson_id IN ({$idList})"
"UPDATE publication_bundles SET status = 'withdrawn', withdrawn_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'published'"
);
} else {
// Publish and approve
$blockers = publicationBlockers($targetLessons, true);
if ($blockers !== []) {
throw new RuntimeException("Publication blocked:\n" . implode("\n", $blockers));
}
Database::query(
"UPDATE curriculum_lessons SET source_status = 'approved', reviewed_at = CURRENT_TIMESTAMP WHERE id IN ({$idList})"
);
Database::query(
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList})"
);
Database::query(
"UPDATE 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
SET a.review_status = 'approved', a.rights_status = 'cleared'
WHERE pb.curriculum_lesson_id IN ({$idList})"
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'review'"
);
}
+102 -147
View File
@@ -1,13 +1,10 @@
<?php
declare(strict_types=1);
/**
* Publishes Grade 10 textbook PDFs into content_assets and publication_bundle_assets
* so they appear in the student app under "الكتب المقررة" for each subject.
*
* Usage:
* php backend/scripts/publish_grade10_textbooks.php [--apply]
* Link previously reviewed Grade 10 textbook PDFs to published bundles.
* Never creates a published bundle, grants rights, or approves an asset.
* Usage: php backend/scripts/publish_grade10_textbooks.php [--apply]
*/
require_once dirname(__DIR__) . '/app/bootstrap.php';
@@ -15,165 +12,123 @@ require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
$apply = in_array('--apply', $argv, true);
$projectRoot = dirname(__DIR__, 2);
$booksRoot = $projectRoot . '/books';
$curriculumRoot = $projectRoot . '/backend/storage/curriculum';
echo "=== Publishing Grade 10 Textbooks ===\n";
$booksRoot = dirname(__DIR__, 2) . '/books';
$curriculumRoot = dirname(__DIR__) . '/storage/curriculum';
$subjectMap = [
'كيمياء' => 'chemistry_10', 'الرياضيات' => 'math_10', 'الفيزياء' => 'physics_10',
'الأحياء' => 'biology_10', 'حياتية' => 'biology_10', 'علوم الأرض' => 'earth_sciences_10',
'اللغة العربية' => 'arabic_10', 'العربية' => 'arabic_10',
'الإنجليزية' => 'english_10', 'الانجليزية' => 'english_10',
'التربية الإسلامية' => 'islamic_10', 'الإسلامية' => 'islamic_10',
'تاريخ' => 'history_10', 'جغرافيا' => 'geography_10',
'الوطنية' => 'civics_10', 'المالية' => 'financial_10',
'الرقمية' => 'digital_skills_10', 'حاسوب' => 'digital_skills_10',
];
if (!is_dir($booksRoot)) {
echo "Books directory not found at {$booksRoot}\n";
fwrite(STDERR, "Books directory is missing.\n");
exit(1);
}
$pdfFiles = glob($booksRoot . '/*.pdf') ?: [];
echo "Found " . count($pdfFiles) . " textbook PDFs in {$booksRoot}.\n";
$subjectMap = [
'كيمياء' => 'chemistry_10',
'الرياضيات' => 'math_10',
'الفيزياء' => 'physics_10',
'الأحياء' => 'biology_10',
'حياتية' => 'biology_10',
'علوم الأرض' => 'earth_sciences_10',
'اللغة العربية' => 'arabic_10',
'العربية' => 'arabic_10',
'الإنجليزية' => 'english_10',
'الانجليزية' => 'english_10',
'التربية الإسلامية' => 'islamic_10',
'الإسلامية' => 'islamic_10',
'تاريخ' => 'history_10',
'جغرافيا' => 'geography_10',
'الوطنية' => 'civics_10',
'المالية' => 'financial_10',
'الرقمية' => 'digital_skills_10',
'حاسوب' => 'digital_skills_10',
];
$publishedCount = 0;
foreach ($pdfFiles as $filePath) {
$ready = [];
$blocked = [];
foreach (glob($booksRoot . '/*.pdf') ?: [] as $filePath) {
$filename = basename($filePath);
$sha256 = hash_file('sha256', $filePath);
$bytes = filesize($filePath);
// Identify subject
$matchedSubject = null;
foreach ($subjectMap as $keyword => $subKey) {
if (str_contains($filename, $keyword)) {
$matchedSubject = $subKey;
break;
}
}
if (!$matchedSubject) {
echo " - Skipping unclassified PDF: {$filename}\n";
if (!str_contains($filename, 'كتاب الطالب')) {
echo "SKIP {$filename}: not a student textbook\n";
continue;
}
$semesterKey = str_contains($filename, 'الثاني') ? 'semester_2' : 'semester_1';
echo "\nProcessing: [{$matchedSubject} - {$semesterKey}] {$filename}\n";
// Destination in curriculum storage
$relativeDest = "textbooks/grade_10/{$matchedSubject}/" . basename($filePath);
$fullDest = $curriculumRoot . '/' . $relativeDest;
if ($apply) {
$destDir = dirname($fullDest);
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
if (!file_exists($fullDest) || hash_file('sha256', $fullDest) !== $sha256) {
copy($filePath, $fullDest);
}
$subject = null;
foreach ($subjectMap as $keyword => $key) {
if (str_contains($filename, $keyword)) { $subject = $key; break; }
}
// Find the representative published bundle for this subject
$lesson = Database::selectOne(
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
ORDER BY cl.unit_key ASC, cl.lesson_key ASC LIMIT 1",
[$matchedSubject, $semesterKey]
);
if (!$lesson) {
// Fallback: any lesson in the subject
$lesson = Database::selectOne(
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ?
ORDER BY cl.id ASC LIMIT 1",
[$matchedSubject]
);
}
if (!$lesson) {
echo " - No curriculum lesson found for {$matchedSubject}.\n";
if ($subject === null) { $blocked[] = "{$filename}: unknown subject"; continue; }
if (str_contains($filename, 'الفصل الثاني')) {
$semester = 'semester_2';
} elseif (str_contains($filename, 'الفصل الأول')) {
$semester = 'semester_1';
} else {
$blocked[] = "{$filename}: explicit semester is missing";
continue;
}
$sha = hash_file('sha256', $filePath);
if ($sha === false) { $blocked[] = "{$filename}: unreadable source"; continue; }
$lessonId = (int)$lesson['id'];
// Ensure publication bundle exists
$bundle = Database::selectOne(
"SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND status = 'published' LIMIT 1",
[$lessonId]
);
$bundleId = 0;
if ($bundle) {
$bundleId = (int)$bundle['id'];
} elseif ($apply) {
$bUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$bundleId = (int)Database::insert(
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status, published_at)
VALUES (?, ?, 'grade10-intake-2026-09-10', 'published', NOW())",
[$bUuid, $lessonId]
);
}
// Ensure content_asset exists
$asset = Database::selectOne(
"SELECT id, uuid FROM content_assets WHERE sha256 = ? LIMIT 1",
[$sha256]
"SELECT id, storage_driver, storage_key, sha256, review_status, rights_status, source_reference
FROM content_assets WHERE sha256 = ? AND asset_type = 'textbook_pdf' LIMIT 1",
[$sha]
);
$assetId = 0;
if ($asset) {
$assetId = (int)$asset['id'];
echo " - Asset exists (ID: {$assetId})\n";
if ($apply) {
Database::query(
"UPDATE content_assets SET review_status = 'approved', rights_status = 'cleared', storage_key = ? WHERE id = ?",
[$relativeDest, $assetId]
);
}
} elseif ($apply) {
$aUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$assetId = (int)Database::insert(
"INSERT INTO content_assets
(uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, rights_status, review_status)
VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, 'cleared', 'approved')",
[$aUuid, $relativeDest, $bytes, $sha256]
);
echo " - Created content_asset ID {$assetId}\n";
if (!$asset || $asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' ||
trim((string)$asset['source_reference']) === '') {
$blocked[] = "{$filename}: reviewed textbook asset and rights evidence are missing";
continue;
}
if ($asset['storage_driver'] !== 'local') {
$blocked[] = "{$filename}: verify the remote asset with its storage provider before linking";
continue;
}
$storagePath = realpath($curriculumRoot . '/' . $asset['storage_key']);
$rootPath = realpath($curriculumRoot);
if ($storagePath === false || $rootPath === false || !str_starts_with($storagePath, $rootPath . DIRECTORY_SEPARATOR) ||
!hash_equals($sha, (string)hash_file('sha256', $storagePath))) {
$blocked[] = "{$filename}: approved stored file is missing or its checksum differs";
continue;
}
$bundle = Database::selectOne(
"SELECT pb.id FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
AND cl.source_status = 'approved' AND pb.status = 'published'
ORDER BY cl.unit_key, cl.lesson_key, pb.id LIMIT 1",
[$subject, $semester]
);
if (!$bundle) {
$blocked[] = "{$filename}: no published bundle for {$subject}/{$semester}";
continue;
}
$ready[] = ['name' => $filename, 'bundle_id' => (int)$bundle['id'], 'asset_id' => (int)$asset['id']];
}
// Link in publication_bundle_assets
if ($apply && $bundleId > 0 && $assetId > 0) {
foreach ($blocked as $reason) echo "BLOCKED {$reason}\n";
foreach ($ready as $row) echo "READY {$row['name']} => bundle {$row['bundle_id']}\n";
if (!$apply) {
echo "DRY RUN: " . count($ready) . " ready, " . count($blocked) . " blocked.\n";
exit($blocked === [] ? 0 : 2);
}
if ($blocked !== []) {
fwrite(STDERR, "REFUSE: resolve all textbook review and matching blockers before --apply.\n");
exit(2);
}
if ($ready === []) {
fwrite(STDERR, "REFUSE: no reviewed textbook to link.\n");
exit(2);
}
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
foreach ($ready as $row) {
$current = Database::selectOne(
"SELECT pb.status, cl.source_status, a.review_status, a.rights_status
FROM publication_bundles pb JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
JOIN content_assets a ON a.id = ? WHERE pb.id = ? FOR UPDATE",
[$row['asset_id'], $row['bundle_id']]
);
if (!$current || $current['status'] !== 'published' || $current['source_status'] !== 'approved' ||
$current['review_status'] !== 'approved' || $current['rights_status'] !== 'cleared') {
throw new RuntimeException('Approval changed while linking textbooks.');
}
Database::query(
"INSERT INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role, sort_order)
VALUES (?, ?, 'textbook', 1)
ON DUPLICATE KEY UPDATE role = 'textbook'",
[$bundleId, $assetId]
VALUES (?, ?, 'textbook', 1) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order)",
[$row['bundle_id'], $row['asset_id']]
);
echo " - Linked to publication bundle {$bundleId} as textbook.\n";
$publishedCount++;
}
}
if ($apply) {
echo "\n=== Successfully published {$publishedCount} textbooks! ===\n";
} else {
echo "\nDRY RUN complete. Run with --apply to execute.\n";
$pdo->commit();
echo "Linked " . count($ready) . " reviewed textbooks.\n";
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
fwrite(STDERR, "FAILED: {$e->getMessage()}\n");
exit(1);
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
// In-memory fixture only; no writes to repository, external database or network.
require_once dirname(__DIR__) . '/app/Core/Database.php';
require_once dirname(__DIR__) . '/app/Services/StudentAccessControlService.php';
require_once dirname(__DIR__) . '/app/Services/PublishedContentService.php';
require_once dirname(__DIR__) . '/app/Services/CurriculumService.php';
use App\Core\Database;
use App\Services\PublishedContentService;
use App\Services\CurriculumService;
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo);
$pdo->exec('CREATE TABLE auth_identities (id INTEGER PRIMARY KEY, status TEXT)');
$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, grade_level TEXT, identity_id INTEGER)');
$pdo->exec("INSERT INTO auth_identities VALUES (1, 'active'), (2, 'disabled')");
$pdo->exec("INSERT INTO students VALUES (7, 'grade_10', 1), (8, 'grade_9', 1), (10, 'grade_10', 2)");
function checkContent(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
$asset = ['grade_key' => 'grade_10', 'storage_driver' => 'local', 'storage_key' => 'manifest.json'];
checkContent(PublishedContentService::studentMayRead(7, $asset), 'Same-grade student denied');
checkContent(!PublishedContentService::studentMayRead(8, $asset), 'Other-grade student allowed');
checkContent(!PublishedContentService::studentMayRead(9, $asset), 'Unknown student allowed');
checkContent(!PublishedContentService::studentMayRead(10, $asset), 'Disabled identity allowed');
checkContent(PublishedContentService::readLocalAsset($asset) !== null, 'Exact in-root asset not found');
checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => '../../database_schema.sql'])) === null, 'Traversal escaped storage root');
checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => 'missing.md'])) === null, 'Missing file substituted');
checkContent(CurriculumService::getLessonMarkdown('../manifest.json') === '', 'Unsafe lesson read returned content');
checkContent(!CurriculumService::safeMarkdownPath('../grade_10/a.md'), 'Traversal path accepted');
checkContent(CurriculumService::safeMarkdownPath('grade_10/math_10/semester_1/unit_01/lesson_01.md'), 'Valid markdown path rejected');
echo "PASS 10 local content-access checks (not an HTTP/tenant test)\n";
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
// Pure in-memory checks; no database, files written, or external services.
require_once dirname(__DIR__) . '/app/Services/PublishedCurriculumTreeFilter.php';
use App\Services\PublishedCurriculumTreeFilter;
$manifest = [
'grade_10' => ['subjects' => [
'math_10' => [
'resources' => ['textbooks' => ['items' => [['file' => 'unpublished.pdf']]]],
'semesters' => ['semester_1' => ['units' => [
'unit_01' => ['lessons' => [
['id' => 'lesson_01', 'title' => 'عنوان أولي', 'file' => 'g10/math/u1/l1.md', 'outcomes' => ['غير مراجع']],
['id' => 'lesson_02', 'title' => 'غير منشور', 'file' => 'g10/math/u1/l2.md'],
]],
]]],
],
]],
'grade_11' => ['subjects' => [
'math_10' => ['semesters' => ['semester_1' => ['units' => [
'unit_01' => ['lessons' => [['id' => 'lesson_01', 'title' => 'درس آخر', 'file' => 'g10/math/u1/l1.md']]],
]]]],
]],
];
$row = [
'grade_key' => 'grade_10', 'subject_key' => 'math_10', 'semester_key' => 'semester_1',
'unit_key' => 'unit_01', 'lesson_key' => 'lesson_01', 'source_manifest_path' => 'g10/math/u1/l1.md',
'uuid' => '0e506aae-4699-49e4-a91d-aa3146e4addb', 'title' => 'العنوان المراجع',
'curriculum_version' => '2026-a', 'has_video' => true,
'primary_asset_id' => '47d85a37-bdaf-4f54-a8b8-216b619b5d04',
];
function expectTree(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
expectTree(PublishedCurriculumTreeFilter::filter($manifest, []) === [], 'No publications must yield an empty tree');
$visible = PublishedCurriculumTreeFilter::filter($manifest, [$row]);
expectTree(count($visible) === 1 && isset($visible['grade_10']), 'Unpublished grade leaked');
$subject = $visible['grade_10']['subjects']['math_10'];
$lessons = $subject['semesters']['semester_1']['units']['unit_01']['lessons'];
expectTree(count($lessons) === 1, 'Unpublished lesson leaked');
expectTree($lessons[0]['title'] === 'العنوان المراجع' && $lessons[0]['curriculum_lesson_id'] === $row['uuid'], 'Published identity/title not authoritative');
expectTree($lessons[0]['curriculum_version'] === '2026-a', 'Curriculum version missing');
expectTree($lessons[0]['primary_lesson_asset_id'] === $row['primary_asset_id'], 'Published markdown asset not linked');
expectTree($lessons[0]['has_video'] === true, 'Published video availability lost');
expectTree(!isset($lessons[0]['file']) && !isset($lessons[0]['outcomes']), 'Unreviewed manifest fields leaked');
expectTree($subject['resources']['textbooks']['items'] === [], 'Unpublished resource leaked');
expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['source_manifest_path' => 'wrong.md'])]) === [], 'Wrong source path matched');
expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['subject_key' => 'physics_10'])]) === [], 'Wrong subject matched');
echo "PASS 11 published curriculum filter checks\n";
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
// Pure local checks. No database, network, files written, or paid services.
require_once dirname(__DIR__) . '/app/Services/RemediationAttemptGrader.php';
use App\Services\RemediationAttemptGrader;
$ids = [11, 12, 13];
$key = [
['question_id' => 11, 'option_ids' => [101, 102], 'correct_option_id' => 102],
['question_id' => 12, 'option_ids' => [201, 202], 'correct_option_id' => 201],
['question_id' => 13, 'option_ids' => [301, 302], 'correct_option_id' => 302],
];
$checks = 0;
function expectGrade(array $ids, array $key, array $answers, ?int $expected): void
{
global $checks;
try {
$actual = RemediationAttemptGrader::grade($ids, $key, $answers);
if ($expected === null || $actual !== $expected) throw new RuntimeException('Unexpected grade');
} catch (InvalidArgumentException $e) {
if ($expected !== null) throw $e;
}
$checks++;
}
expectGrade($ids, $key, [102, 201, 302], 3);
expectGrade($ids, $key, [102, 202, 302], 2);
expectGrade($ids, $key, [101, 202, 301], 0);
expectGrade($ids, $key, [102, 201], null);
expectGrade($ids, $key, [102, 201, 999], null);
expectGrade($ids, $key, [102, 102, 302], null);
expectGrade($ids, $key, ['102', 201, 302], null);
expectGrade($ids, array_reverse($key), [102, 201, 302], null);
expectGrade([11, 11, 13], $key, [102, 201, 302], null);
echo "PASS {$checks} remediation grading cases\n";
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
// SQLite memory fixture: no production DB, no network, no money or student writes.
// MySQL/HTTP concurrency and real-school roster verification remain pending.
require_once dirname(__DIR__) . '/app/Core/Database.php';
require_once dirname(__DIR__) . '/app/Services/StudentAccessControlService.php';
use App\Core\Database;
use App\Services\StudentAccessControlService;
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($pdo instanceof \Pdo\Sqlite) $pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
else $pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo);
$pdo->exec('CREATE TABLE directorates (id INTEGER PRIMARY KEY, type TEXT)');
$pdo->exec('CREATE TABLE schools (id INTEGER PRIMARY KEY, name TEXT, type TEXT, directorate_id INTEGER)');
$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, national_id TEXT, grade_level TEXT, school_id INTEGER, is_school_sponsored INTEGER)');
$pdo->exec('CREATE TABLE teachers (id INTEGER PRIMARY KEY, is_school_exclusive INTEGER, is_marketplace_public INTEGER)');
$pdo->exec('CREATE TABLE courses (id INTEGER PRIMARY KEY, teacher_id INTEGER, school_id INTEGER, is_school_exclusive INTEGER, is_published INTEGER, price_jod NUMERIC, grade_level TEXT)');
$pdo->exec('CREATE TABLE lessons (id INTEGER PRIMARY KEY, course_id INTEGER, is_free_preview INTEGER)');
$pdo->exec('CREATE TABLE school_rosters (school_id INTEGER, claimed_student_id INTEGER, is_claimed INTEGER, grade_level TEXT)');
$pdo->exec('CREATE TABLE course_access_passes (id INTEGER PRIMARY KEY, student_id INTEGER, course_id INTEGER, pass_type TEXT, expires_at TEXT, is_active INTEGER)');
$pdo->exec("INSERT INTO schools VALUES (1,'School A','private',NULL),(2,'School B','private',NULL)");
$pdo->exec("INSERT INTO students VALUES (1,'a','grade_10',NULL,0),(2,'b','grade_10',1,1),(3,'c','grade_10',2,1),(4,'d','grade_9',NULL,0),(5,'e','grade_10',1,1)");
$pdo->exec('INSERT INTO teachers VALUES (1,0,1),(2,1,0)');
$pdo->exec("INSERT INTO courses VALUES (10,1,NULL,0,1,10,'grade_10'),(11,2,1,1,1,30,'grade_10'),(12,2,2,1,1,30,'grade_10'),(13,1,NULL,0,1,0,'grade_10'),(14,1,NULL,0,0,0,'grade_10')");
$pdo->exec('INSERT INTO lessons VALUES (100,10,0),(110,11,0),(120,12,0),(130,13,0),(140,14,0)');
$pdo->exec("INSERT INTO school_rosters VALUES (1,2,1,'grade_10'),(2,3,1,'grade_10')");
$pdo->exec("INSERT INTO course_access_passes VALUES (1,1,10,'full_marketplace',NULL,1),(2,2,10,'school_included',NULL,1)");
$checks = 0;
function expectAccess(int $studentId, int $courseId, int $lessonId, bool $allowed, string $reason, bool $legacySideEffects = false): void
{
global $checks;
$result = StudentAccessControlService::validateLessonAccess($studentId, null, 'grade_10', $courseId, $lessonId, $legacySideEffects);
if ((bool)$result['allowed'] !== $allowed || (string)$result['reason'] !== $reason) {
throw new RuntimeException("Unexpected access for student {$studentId}, course {$courseId}: " . json_encode($result));
}
$checks++;
}
expectAccess(1, 10, 100, true, 'paid_pass_active');
expectAccess(1, 11, 110, false, 'school_scope_mismatch');
expectAccess(2, 11, 110, true, 'school_course_included');
expectAccess(2, 12, 120, false, 'school_scope_mismatch');
expectAccess(2, 10, 100, false, 'payment_required');
expectAccess(2, 13, 130, true, 'free_course');
expectAccess(5, 11, 110, false, 'school_roster_unverified');
expectAccess(4, 13, 130, false, 'grade_mismatch', true);
expectAccess(1, 14, 140, false, 'course_unavailable');
expectAccess(1, 10, 110, false, 'lesson_course_mismatch');
expectAccess(99, 13, 130, false, 'unauthenticated_or_not_found');
$grade = $pdo->query('SELECT grade_level FROM students WHERE id=4')->fetchColumn();
$passes = (int)$pdo->query('SELECT COUNT(*) FROM course_access_passes')->fetchColumn();
if ($grade !== 'grade_9' || $passes !== 2) throw new RuntimeException('Access reads changed grade or issued passes');
$checks++;
echo "PASS {$checks} local student-access scope checks (not an HTTP/MySQL test)\n";
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
// Isolated SQLite memory fixture. No server, production database, or money movement.
// FOR UPDATE concurrency semantics remain a separate MySQL staging test.
require_once dirname(__DIR__) . '/app/Core/Database.php';
require_once dirname(__DIR__) . '/app/Services/TeacherLedgerService.php';
use App\Core\Database;
use App\Services\TeacherLedgerService;
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($pdo instanceof \Pdo\Sqlite) {
$pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
} else {
$pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
}
$property = new ReflectionProperty(Database::class, 'instance');
$property->setValue(null, $pdo);
$pdo->exec('CREATE TABLE ledger_accounts (id INTEGER PRIMARY KEY, code TEXT, owner_type TEXT, owner_id INTEGER, currency TEXT)');
$pdo->exec('CREATE TABLE ledger_entries (id INTEGER PRIMARY KEY, debit_account_id INTEGER, credit_account_id INTEGER, amount_fils INTEGER)');
$pdo->exec('CREATE TABLE teacher_withdrawal_holds (id INTEGER PRIMARY KEY, uuid TEXT, teacher_id INTEGER, amount_fils INTEGER, status TEXT, idempotency_key TEXT, released_at TEXT, release_reason TEXT)');
function checkLedger(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
$empty = TeacherLedgerService::balance(42);
checkLedger($empty['account_id'] === null && $empty['available_fils'] === 0, 'Missing account must have zero available');
checkLedger((int)$pdo->query('SELECT COUNT(*) FROM ledger_accounts')->fetchColumn() === 0, 'Balance read created an account');
$pdo->exec("INSERT INTO ledger_accounts VALUES (1, 'teacher_available', 'teacher', 42, 'JOD')");
$pdo->exec('INSERT INTO ledger_entries VALUES (1, 2, 1, 2000), (2, 1, 2, 500)');
$pdo->exec("INSERT INTO teacher_withdrawal_holds VALUES (1, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 42, 400, 'held', 'fixture-key', NULL, NULL)");
$balance = TeacherLedgerService::balance(42);
checkLedger($balance['gross_fils'] === 1500 && $balance['held_fils'] === 400 && $balance['available_fils'] === 1100, 'Signed net balance or hold subtraction wrong');
$wrongTeacher = TeacherLedgerService::releaseHold(43, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'سبب مراجعة');
checkLedger($wrongTeacher['status'] === 'hold_not_releasable', 'Cross-teacher release was allowed');
$released = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'فشل نهائي موثق');
checkLedger($released['status'] === 'released', 'First release failed');
$again = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'إعادة');
checkLedger($again['status'] === 'hold_not_releasable', 'Second release changed state');
$row = $pdo->query('SELECT status, release_reason FROM teacher_withdrawal_holds WHERE id=1')->fetch(PDO::FETCH_ASSOC);
checkLedger($row['status'] === 'released' && $row['release_reason'] === 'فشل نهائي موثق', 'Release reason not persisted');
checkLedger(TeacherLedgerService::balance(42)['available_fils'] === 1500, 'Released hold still reduced availability');
$invalid = TeacherLedgerService::placeWithdrawalHold(42, 0, 'valid-key-1234567');
checkLedger($invalid['http_status'] === 400, 'Zero hold should be rejected before database access');
echo "PASS 9 local ledger checks (not a MySQL concurrency test)\n";
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
// Pure local validation: no database, network, files written, or AI calls.
require_once dirname(__DIR__) . '/app/Services/VideoCoverageValidator.php';
use App\Services\VideoCoverageValidator;
$sha = str_repeat('a', 64);
$valid = [
'video_sha256' => $sha,
'markdown_sha256' => str_repeat('b', 64),
'truncated' => false,
'gaps' => [],
'segments' => [
['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t1','visual_ref'=>'v1'],
['start_seconds'=>30,'end_seconds'=>60,'transcript_ref'=>'t2','visual_ref'=>'v2'],
],
];
$cases = [
[$valid, true],
[array_replace($valid, ['truncated'=>true]), false],
[array_replace($valid, ['gaps'=>[[30,31]]]), false],
[array_replace($valid, ['video_sha256'=>str_repeat('c',64)]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>1,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>59,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t','visual_ref'=>'v'],['start_seconds'=>31,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>60,'transcript_ref'=>'','visual_ref'=>'v']]]), false],
];
foreach ($cases as $index => [$coverage, $expected]) {
if (VideoCoverageValidator::valid($coverage, 60, $sha) !== $expected) {
throw new RuntimeException("Coverage case {$index} failed");
}
}
echo 'PASS ' . count($cases) . " video coverage cases\n";
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
// Pure local validation only; no database, network, writes, or AI calls.
require_once dirname(__DIR__) . '/app/Services/VideoReviewReportValidator.php';
use App\Services\VideoReviewReportValidator;
$good = [
'missing_inputs' => [], 'issues' => [],
'coverage' => [
'truncated' => false, 'unprocessed_intervals' => [],
'section_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]],
'objective_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]],
],
];
$cases = [
[$good, true],
[array_replace($good, ['missing_inputs'=>['transcript']]), false],
[array_replace_recursive($good, ['coverage'=>['truncated'=>true]]), false],
[array_replace_recursive($good, ['coverage'=>['unprocessed_intervals'=>[[0,5]]]]), false],
[array_replace_recursive($good, ['coverage'=>['section_results'=>[['status'=>'missing']]]]), false],
[array_replace($good, ['issues'=>[['severity'=>'major','resolution_status'=>'open']]]), false],
[array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved']]]), false],
[array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved','resolution_evidence'=>'reviewer-note-1']]]), true],
];
foreach ($cases as $index => [$report, $expected]) {
if (VideoReviewReportValidator::approvable($report) !== $expected) {
throw new RuntimeException("Report case {$index} failed");
}
}
echo 'PASS ' . count($cases) . " video review report cases\n";