Harden published curriculum and student flows
This commit is contained in:
@@ -517,10 +517,9 @@ class AuthController
|
||||
if ($user) {
|
||||
$rawGrade = (string)($user['grade_level'] ?? 'grade_10');
|
||||
$normGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
|
||||
if ($rawGrade === 'tawjihi_2008' || $rawGrade !== $normGrade) {
|
||||
Database::query("UPDATE students SET grade_level = ?, updated_at = NOW() WHERE id = ?", [$normGrade, $userId]);
|
||||
$user['grade_level'] = $normGrade;
|
||||
}
|
||||
// Profile reads may normalize presentation, but must not rewrite
|
||||
// a student's academic record as a side effect of GET.
|
||||
$user['grade_level'] = $normGrade;
|
||||
$isCompleted = !empty($user['full_name']) && $user['full_name'] !== 'طالب جديد' && $user['full_name'] !== 'الطالب المتميز' && !empty($user['grade_level']);
|
||||
$userData = [
|
||||
'id' => $user['id'],
|
||||
@@ -586,11 +585,6 @@ class AuthController
|
||||
$student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]);
|
||||
|
||||
if ($student) {
|
||||
if (($student['grade_level'] ?? '') === 'tawjihi_2008') {
|
||||
Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [$student['id']]);
|
||||
$student['grade_level'] = 'grade_10';
|
||||
}
|
||||
|
||||
// Student exists. Verify identity linkage.
|
||||
if ($student['identity_id'] === null) {
|
||||
// Pre-registered by Guardian, link to this identity phone now
|
||||
@@ -736,41 +730,14 @@ class AuthController
|
||||
null
|
||||
);
|
||||
} else {
|
||||
// Legacy / Fallback for already logged-in students updating profile
|
||||
$studentId = (int)$request->user_id;
|
||||
if (!$studentId) {
|
||||
$authHeader = $request->getHeader('authorization', '');
|
||||
if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
|
||||
$payload = Security::verifyJWT($matches[1]);
|
||||
if ($payload && isset($payload['user_id'])) {
|
||||
$studentId = (int)$payload['user_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$studentId) {
|
||||
$response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']);
|
||||
return;
|
||||
}
|
||||
|
||||
Database::query(
|
||||
"UPDATE students SET full_name = ?, grade_level = ?, stream = ?, national_id = IF(? != '', ?, national_id), national_id_hash = IF(? != '', ?, national_id_hash), updated_at = NOW() WHERE id = ?",
|
||||
[$fullName, $gradeLevel, $stream, $nationalId, Security::encrypt($nationalId), $nationalId, Security::blindIndex($nationalId), $studentId]
|
||||
);
|
||||
|
||||
$student = Database::selectOne("SELECT * FROM students WHERE id = ? LIMIT 1", [$studentId]);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم استكمال ملف الطالب بنجاح! مرحباً بك في منصة صَقِل',
|
||||
'user' => [
|
||||
'id' => $student['id'],
|
||||
'uuid' => $student['uuid'],
|
||||
'full_name' => $student['full_name'],
|
||||
'grade_level' => $student['grade_level'],
|
||||
'stream' => $student['stream'],
|
||||
'role' => 'student'
|
||||
]
|
||||
// This endpoint accepts only a pending student-identity proof.
|
||||
// A bare JWT here previously bypassed the active-session and role
|
||||
// checks and allowed changing a national ID without re-verification.
|
||||
$response->status(401)->json([
|
||||
'status' => 'identity_proof_required',
|
||||
'message' => 'إكمال الملف يتطلب رمز التحقق من الهوية. لتغيير صف طالب مستقل استخدم مسار تغيير الصف المصادق.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,30 +748,45 @@ class AuthController
|
||||
public function updateStudentGrade(Request $request, Response $response): void
|
||||
{
|
||||
$studentId = (int)$request->user_id;
|
||||
if (!$studentId) {
|
||||
$authHeader = $request->getHeader('authorization', '');
|
||||
if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
|
||||
$payload = Security::verifyJWT($matches[1]);
|
||||
if ($payload && isset($payload['user_id'])) {
|
||||
$studentId = (int)$payload['user_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$studentId) {
|
||||
$response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']);
|
||||
return;
|
||||
}
|
||||
|
||||
$body = $request->getBody();
|
||||
$rawGrade = trim((string)($body['grade_level'] ?? 'grade_10'));
|
||||
$stream = trim((string)($body['stream'] ?? 'general'));
|
||||
$rawGrade = trim((string)($body['grade_level'] ?? ''));
|
||||
$stream = trim((string)($body['stream'] ?? ''));
|
||||
|
||||
$normalizedGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade);
|
||||
if (!preg_match('/^grade_(?:[1-9]|1[0-2])$/', $normalizedGrade)
|
||||
|| !in_array($stream, ['scientific', 'literary', 'vocational', 'general'], true)) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'الصف أو المسار غير صالح.']);
|
||||
return;
|
||||
}
|
||||
|
||||
Database::query(
|
||||
"UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW() WHERE id = ?",
|
||||
$scope = Database::selectOne('SELECT id, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]);
|
||||
if (!$scope) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'طالب غير موجود']);
|
||||
return;
|
||||
}
|
||||
if (!empty($scope['school_id']) || !empty($scope['is_school_sponsored'])) {
|
||||
$response->status(403)->json(['status' => 'forbidden', 'message' => 'صف الطالب المدرسي يحدّثه مسؤول المدرسة من كشف الانتساب.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = Database::execute(
|
||||
"UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW()
|
||||
WHERE id = ? AND school_id IS NULL AND is_school_sponsored = 0",
|
||||
[$normalizedGrade, $stream, $studentId]
|
||||
);
|
||||
if ($changed !== 1) {
|
||||
$after = Database::selectOne('SELECT grade_level, stream, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]);
|
||||
if (!$after || !empty($after['school_id']) || !empty($after['is_school_sponsored'])
|
||||
|| (string)$after['grade_level'] !== $normalizedGrade || (string)$after['stream'] !== $stream) {
|
||||
$response->status(409)->json(['status' => 'conflict', 'message' => 'تغير نطاق حساب الطالب؛ أعد تحميل ملفك الدراسي.']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$student = Database::selectOne(
|
||||
"SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream, s.readiness_score, s.school_id, ai.phone_number, ai.status, s.created_at
|
||||
|
||||
@@ -26,6 +26,8 @@ use App\Services\CurriculumService;
|
||||
use App\Services\CurriculumExtractorService;
|
||||
use App\Services\PublishedContentService;
|
||||
use App\Services\LearningPackageService;
|
||||
use App\Services\PublishedCurriculumTreeFilter;
|
||||
use App\Services\AssetDownloadTicketService;
|
||||
|
||||
class CurriculumController
|
||||
{
|
||||
@@ -207,13 +209,23 @@ class CurriculumController
|
||||
$removeUnpublishedResources($tree);
|
||||
|
||||
try {
|
||||
$approvedLessons = Database::select("SELECT id, uuid, source_manifest_path FROM curriculum_lessons WHERE source_status='approved'");
|
||||
$approvedLessons = Database::select(
|
||||
"SELECT cl.id, cl.uuid, cl.source_manifest_path, a.uuid AS primary_asset_id
|
||||
FROM curriculum_lessons cl
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id
|
||||
AND a.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE cl.source_status='approved'
|
||||
ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC"
|
||||
);
|
||||
$byPath = [];
|
||||
$lessonMap = [];
|
||||
foreach ($approvedLessons as $row) {
|
||||
$p = (string)$row['source_manifest_path'];
|
||||
$byPath[$p] = [
|
||||
'curriculum_lesson_id' => (string)$row['uuid'],
|
||||
'primary_lesson_asset_id' => (string)$row['primary_asset_id'],
|
||||
'has_video' => false,
|
||||
];
|
||||
$lessonMap[(int)$row['id']] = $p;
|
||||
@@ -224,7 +236,13 @@ class CurriculumController
|
||||
$videoCounts = Database::select(
|
||||
"SELECT ts.curriculum_lesson_id, COUNT(vv.id) AS video_count
|
||||
FROM teacher_submissions ts
|
||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved'
|
||||
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
||||
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
|
||||
AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE ts.status = 'published'
|
||||
GROUP BY ts.curriculum_lesson_id"
|
||||
);
|
||||
@@ -263,7 +281,7 @@ class CurriculumController
|
||||
// bundle, and expose an opaque asset UUID rather than a storage path.
|
||||
$resourceRows = Database::select(
|
||||
"SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type, a.source_reference,
|
||||
cl.title AS lesson_title, cl.unit_key,
|
||||
cl.title AS lesson_title, cl.semester_key, cl.unit_key,
|
||||
pba.role, pba.sort_order
|
||||
FROM publication_bundles pb
|
||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||
@@ -292,7 +310,8 @@ class CurriculumController
|
||||
|
||||
$title = '';
|
||||
if ($group === 'textbooks') {
|
||||
$ref = strtolower((string)($row['source_reference'] ?? ''));
|
||||
$sourceReference = trim((string)($row['source_reference'] ?? ''));
|
||||
$ref = strtolower($sourceReference);
|
||||
$subjectNames = [
|
||||
'math' => 'الرياضيات',
|
||||
'physics' => 'الفيزياء',
|
||||
@@ -315,13 +334,19 @@ class CurriculumController
|
||||
break;
|
||||
}
|
||||
}
|
||||
$part = '';
|
||||
if (str_contains($ref, 'part1') || str_contains($ref, 'part_1') || str_contains($ref, 'semester_1')) {
|
||||
$part = ' (الفصل الأول)';
|
||||
} elseif (str_contains($ref, 'part2') || str_contains($ref, 'part_2') || str_contains($ref, 'semester_2')) {
|
||||
$part = ' (الفصل الثاني)';
|
||||
$sourceBasename = basename(str_replace('\\', '/', $sourceReference));
|
||||
$sourceTitle = trim(pathinfo($sourceBasename, PATHINFO_FILENAME));
|
||||
if ($sourceTitle !== '') {
|
||||
$title = $sourceTitle;
|
||||
} else {
|
||||
$semesterKey = strtolower((string)($row['semester_key'] ?? ''));
|
||||
$semesterLabel = str_contains($semesterKey, '1')
|
||||
? 'الفصل الأول'
|
||||
: (str_contains($semesterKey, '2') ? 'الفصل الثاني' : 'فصل غير محدد');
|
||||
$unitKey = preg_replace('/[^0-9]/', '', (string)($row['unit_key'] ?? ''));
|
||||
$unitLabel = $unitKey !== '' ? " — الوحدة $unitKey" : '';
|
||||
$title = "ملف PDF منشور للمادة: $foundSub — $semesterLabel$unitLabel";
|
||||
}
|
||||
$title = "الكتاب المدرسي الرسمي: $foundSub$part";
|
||||
} else {
|
||||
$lTitle = trim((string)($row['lesson_title'] ?? ''));
|
||||
$title = !empty($lTitle) ? ("ورقة عمل: " . $lTitle) : "ورقة عمل تعليمية";
|
||||
@@ -349,6 +374,78 @@ class CurriculumController
|
||||
$response->json(['status'=>'success','data'=>$tree]);
|
||||
}
|
||||
|
||||
/** Public student catalogue: fail closed when publication evidence is unavailable. */
|
||||
public function getPublishedTree(Request $request, Response $response): void
|
||||
{
|
||||
try {
|
||||
$rows = Database::select(
|
||||
"SELECT cl.id, cl.uuid, cl.curriculum_version, cl.grade_key, cl.subject_key, cl.semester_key,
|
||||
cl.unit_key, cl.lesson_key, cl.source_manifest_path, cl.title,
|
||||
a.uuid AS primary_asset_id
|
||||
FROM curriculum_lessons cl
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id
|
||||
AND a.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE cl.source_status='approved'
|
||||
ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC"
|
||||
);
|
||||
$videoRows = Database::select(
|
||||
"SELECT DISTINCT ts.curriculum_lesson_id
|
||||
FROM teacher_submissions ts
|
||||
JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published'
|
||||
JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready'
|
||||
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=ts.curriculum_lesson_id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
|
||||
AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE ts.status='published'"
|
||||
);
|
||||
$hasVideo = array_fill_keys(array_map(static fn($row) => (int)$row['curriculum_lesson_id'], $videoRows), true);
|
||||
foreach ($rows as &$row) $row['has_video'] = isset($hasVideo[(int)$row['id']]);
|
||||
unset($row);
|
||||
$tree = PublishedCurriculumTreeFilter::filter(CurriculumService::getCurriculumTree(), $rows);
|
||||
|
||||
if ($tree !== []) {
|
||||
$resources = Database::select(
|
||||
"SELECT cl.grade_key, cl.subject_key, cl.title AS lesson_title,
|
||||
pba.role, a.uuid AS asset_id, a.asset_type, a.mime_type
|
||||
FROM publication_bundles pb
|
||||
JOIN curriculum_lessons cl ON cl.id=pb.curriculum_lesson_id AND cl.source_status='approved'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role IN ('textbook','worksheet')
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE pb.status='published'
|
||||
AND ((pba.role='textbook' AND a.asset_type='textbook_pdf')
|
||||
OR (pba.role='worksheet' AND a.asset_type='worksheet_markdown'))
|
||||
ORDER BY cl.grade_key, cl.subject_key, pba.role, pba.sort_order, a.id"
|
||||
);
|
||||
$seen = [];
|
||||
foreach ($resources as $item) {
|
||||
$gradeKey = (string)$item['grade_key'];
|
||||
$subjectKey = (string)$item['subject_key'];
|
||||
if (!isset($tree[$gradeKey]['subjects'][$subjectKey])) continue;
|
||||
$assetId = (string)$item['asset_id'];
|
||||
if (isset($seen[$gradeKey][$subjectKey][$assetId])) continue;
|
||||
$seen[$gradeKey][$subjectKey][$assetId] = true;
|
||||
$isBook = $item['role'] === 'textbook';
|
||||
$group = $isBook ? 'textbooks' : 'worksheets';
|
||||
$tree[$gradeKey]['subjects'][$subjectKey]['resources'][$group]['items'][] = [
|
||||
'asset_id' => $assetId,
|
||||
'asset_type' => (string)$item['asset_type'],
|
||||
'mime_type' => (string)$item['mime_type'],
|
||||
'type' => $isBook ? 'textbook' : 'worksheet',
|
||||
'title' => $isBook ? 'كتاب المبحث المنشور' : 'ورقة عمل: ' . (string)$item['lesson_title'],
|
||||
];
|
||||
}
|
||||
}
|
||||
$response->json(['status' => 'success', 'data' => $tree === [] ? new \stdClass() : $tree]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Published curriculum tree unavailable: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تحميل فهرس الدروس المنشورة.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Single Lesson Markdown Content
|
||||
*/
|
||||
@@ -360,13 +457,15 @@ class CurriculumController
|
||||
return;
|
||||
}
|
||||
$content = CurriculumService::getLessonMarkdown($file);
|
||||
if ($content === '') {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'ملف الدرس المطلوب غير موجود.']);
|
||||
return;
|
||||
}
|
||||
$assets = CurriculumService::getLessonAiAssets($file);
|
||||
$serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/');
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'file' => $file,
|
||||
'server_full_path' => $serverFullPath,
|
||||
'content' => $content,
|
||||
'ai_assets' => $assets
|
||||
]);
|
||||
@@ -384,34 +483,35 @@ class CurriculumController
|
||||
return;
|
||||
}
|
||||
|
||||
CurriculumService::saveLessonMarkdown($file, $content);
|
||||
try {
|
||||
$published = Database::selectOne(
|
||||
"SELECT a.id FROM content_assets a
|
||||
JOIN publication_bundle_assets pba ON pba.content_asset_id=a.id
|
||||
JOIN publication_bundles pb ON pb.id=pba.publication_bundle_id
|
||||
WHERE a.storage_driver='local' AND a.storage_key=? AND pb.status='published'
|
||||
LIMIT 1",
|
||||
[$file]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Draft lesson save gate unavailable: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من حالة أصل الدرس؛ لم يُحفظ الملف.']);
|
||||
return;
|
||||
}
|
||||
if ($published) {
|
||||
$response->status(409)->json(['status' => 'published_asset_immutable', 'message' => 'هذا الأصل منشور؛ أنشئ نسخة مسودة جديدة ومررها للمراجعة.']);
|
||||
return;
|
||||
}
|
||||
if (!CurriculumService::saveLessonMarkdown($file, $content)) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'مسار ملف المسودة غير صالح أو تعذر حفظه.']);
|
||||
return;
|
||||
}
|
||||
if ($aiAssets !== null) {
|
||||
CurriculumService::saveLessonAiAssets($file, $aiAssets);
|
||||
}
|
||||
|
||||
// Sync to MySQL Database Table `lessons` if connected
|
||||
try {
|
||||
$aiVideoUrl = $aiAssets['ai_video_url'] ?? null;
|
||||
$cheatSheet = $aiAssets['cheat_sheet'] ?? null;
|
||||
$socraticJson = isset($aiAssets['socratic_quiz']) ? json_encode($aiAssets['socratic_quiz'], JSON_UNESCAPED_UNICODE) : null;
|
||||
|
||||
// Search lesson by matching filename or title
|
||||
$filename = basename($file, '.md');
|
||||
\App\Core\Database::query(
|
||||
"UPDATE lessons SET markdown_content = ?, ai_video_url = COALESCE(?, ai_video_url), cheat_sheet_markdown = COALESCE(?, cheat_sheet_markdown), socratic_quiz_json = COALESCE(?, socratic_quiz_json)
|
||||
WHERE title LIKE ? OR markdown_content LIKE ?",
|
||||
[$content, $aiVideoUrl, $cheatSheet, $socraticJson, "%{$filename}%", "%{$file}%"]
|
||||
);
|
||||
} catch (\Throwable $dbEx) {
|
||||
error_log("Curriculum DB save sync note: " . $dbEx->getMessage());
|
||||
}
|
||||
|
||||
$serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/');
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم حفظ واعتماد محتوى الدرس في المنهاج وقاعدة البيانات بنجاح!',
|
||||
'server_full_path' => $serverFullPath
|
||||
'message' => 'تم حفظ مسودة الدرس. النشر يتطلب مراجعة المحتوى والحقوق والحزمة.',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -634,6 +734,9 @@ class CurriculumController
|
||||
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||
try {
|
||||
$asset = PublishedContentService::findPublishedAsset($assetId);
|
||||
if ($asset && !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) {
|
||||
$asset = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Published asset lookup failed: ' . $e->getMessage());
|
||||
$response->status(503)->json([
|
||||
@@ -660,6 +763,16 @@ class CurriculumController
|
||||
return;
|
||||
}
|
||||
|
||||
$actualSha = hash_file('sha256', $path);
|
||||
if ($actualSha === false || !hash_equals((string)$asset['sha256'], $actualSha)
|
||||
|| filesize($path) !== (int)$asset['byte_size']) {
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'فشل التحقق من سلامة الأصل المنشور.']);
|
||||
return;
|
||||
}
|
||||
$response->setHeader('Cache-Control', 'private, no-store');
|
||||
$response->setHeader('Referrer-Policy', 'no-referrer');
|
||||
$response->setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
if (str_starts_with((string)$asset['mime_type'], 'text/')) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
@@ -682,6 +795,47 @@ class CurriculumController
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Authenticated student obtains a short-lived browser grant for one PDF. */
|
||||
public function issueAssetDownloadTicket(Request $request, Response $response): void
|
||||
{
|
||||
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||
try {
|
||||
$asset = PublishedContentService::findPublishedAsset($assetId);
|
||||
if (!$asset || $asset['asset_type'] !== 'textbook_pdf'
|
||||
|| $asset['mime_type'] !== 'application/pdf'
|
||||
|| !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الكتاب غير منشور أو غير متاح لك.']);
|
||||
return;
|
||||
}
|
||||
$grant = AssetDownloadTicketService::issue((int)$request->user_id, $assetId);
|
||||
$response->setHeader('Cache-Control', 'private, no-store');
|
||||
$response->json(['status' => 'success', 'data' => $grant]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Asset download grant unavailable: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تجهيز فتح الكتاب حالياً.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser receives only a scoped grant, never the student's session JWT. */
|
||||
public function downloadAssetWithTicket(Request $request, Response $response): void
|
||||
{
|
||||
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||
$ticket = trim((string)$request->getQuery('ticket', ''));
|
||||
try {
|
||||
$studentId = AssetDownloadTicketService::resolve($ticket, $assetId);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Asset download grant lookup unavailable: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من رابط الكتاب.']);
|
||||
return;
|
||||
}
|
||||
if ($studentId === null) {
|
||||
$response->status(403)->json(['status' => 'forbidden', 'message' => 'رابط الكتاب غير صالح أو انتهت صلاحيته.']);
|
||||
return;
|
||||
}
|
||||
$request->user_id = $studentId;
|
||||
$this->getPublishedAsset($request, $response);
|
||||
}
|
||||
|
||||
private static function assetMetadata(array $asset): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -5,13 +5,14 @@ namespace App\Controllers;
|
||||
use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Services\RemediationAttemptGrader;
|
||||
|
||||
class ErrorNotebookController
|
||||
{
|
||||
public function getErrorNotebook(Request $request, Response $response): void
|
||||
{
|
||||
$studentId = (int)$request->user_id;
|
||||
$sql = "SELECT * FROM student_error_notebook WHERE student_id = ?";
|
||||
$sql = "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE student_id = ?";
|
||||
$params = [$studentId];
|
||||
$subject = trim((string)$request->getQuery('subject', ''));
|
||||
$status = trim((string)$request->getQuery('status', ''));
|
||||
@@ -69,9 +70,11 @@ class ErrorNotebookController
|
||||
|
||||
public function getRemediationQuiz(Request $request, Response $response): void
|
||||
{
|
||||
$uuid = trim((string)$request->getQuery('error_uuid', ''));
|
||||
$body = $request->getBody();
|
||||
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||
$error = Database::selectOne(
|
||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
"SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due
|
||||
FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
[$uuid, (int)$request->user_id]
|
||||
);
|
||||
if (!$error) {
|
||||
@@ -79,49 +82,62 @@ class ErrorNotebookController
|
||||
return;
|
||||
}
|
||||
|
||||
$questions = [];
|
||||
|
||||
// 1. Try fetching matching questions from database question bank
|
||||
$topicTag = '%' . $error['topic_name'] . '%';
|
||||
if ($error['status'] === 'mastered') {
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'هذه الفجوة متقنة بالفعل']);
|
||||
return;
|
||||
}
|
||||
if ($error['status'] === 'in_remediation' && (int)$error['retention_is_due'] !== 1) {
|
||||
$response->status(409)->json(['status' => 'not_due', 'message' => 'اختبار الاسترجاع متاح بعد موعده المحدد']);
|
||||
return;
|
||||
}
|
||||
if (empty($error['lesson_id'])) {
|
||||
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا يوجد درس محدد لهذه الفجوة لتكوين اختبار موثوق']);
|
||||
return;
|
||||
}
|
||||
$dbQuestions = Database::select(
|
||||
"SELECT q.id, q.question_text, q.explanation_text, q.ai_hint
|
||||
FROM questions q
|
||||
"SELECT q.id, q.question_text FROM questions q
|
||||
JOIN exams e ON e.id = q.exam_id
|
||||
WHERE q.topic_tag LIKE ? OR q.question_text LIKE ? OR e.title LIKE ?
|
||||
LIMIT 3",
|
||||
[$topicTag, $topicTag, $topicTag]
|
||||
WHERE e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
|
||||
AND q.question_type = 'multiple_choice'
|
||||
AND q.remediation_review_status = 'approved'
|
||||
AND q.remediation_reviewed_by IS NOT NULL
|
||||
AND q.remediation_reviewed_at IS NOT NULL
|
||||
AND q.remediation_source_reference IS NOT NULL
|
||||
ORDER BY q.id ASC LIMIT 20",
|
||||
[(int)$error['lesson_id'], $error['topic_name']]
|
||||
);
|
||||
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$opts = Database::select(
|
||||
"SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC",
|
||||
[(int)$dbQ['id']]
|
||||
$questions = [];
|
||||
$answerKey = [];
|
||||
foreach ($dbQuestions as $question) {
|
||||
$options = Database::select(
|
||||
'SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC',
|
||||
[(int)$question['id']]
|
||||
);
|
||||
if (count($opts) >= 2) {
|
||||
$optTexts = [];
|
||||
$correctIdx = 0;
|
||||
foreach ($opts as $i => $opt) {
|
||||
$optTexts[] = $opt['option_text'];
|
||||
if ((int)$opt['is_correct'] === 1) {
|
||||
$correctIdx = $i;
|
||||
}
|
||||
}
|
||||
$questions[] = [
|
||||
'id' => (int)$dbQ['id'],
|
||||
'question' => $dbQ['question_text'],
|
||||
'options' => $optTexts,
|
||||
'correct_index' => $correctIdx,
|
||||
'explanation' => $dbQ['explanation_text'] ?: ($dbQ['ai_hint'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'),
|
||||
];
|
||||
}
|
||||
if (count($options) < 2 || count(array_filter($options, fn($o) => (int)$o['is_correct'] === 1)) !== 1) continue;
|
||||
$questions[] = [
|
||||
'id' => (int)$question['id'], 'question' => $question['question_text'],
|
||||
'options' => array_map(fn($o) => ['id' => (int)$o['id'], 'text' => $o['option_text']], $options),
|
||||
];
|
||||
$answerKey[] = [
|
||||
'question_id' => (int)$question['id'],
|
||||
'option_ids' => array_map(fn($o) => (int)$o['id'], $options),
|
||||
'correct_option_id' => (int)array_values(array_filter($options, fn($o) => (int)$o['is_correct'] === 1))[0]['id'],
|
||||
];
|
||||
if (count($questions) === 3) break;
|
||||
}
|
||||
if (count($questions) !== 3) {
|
||||
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا توجد ثلاثة أسئلة منشورة ومراجعة لهذا الدرس والمفهوم']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If question bank has fewer than 2 questions, provide curriculum-aligned remedial questions
|
||||
if (count($questions) < 2) {
|
||||
$topic = $error['topic_name'];
|
||||
$subject = $error['subject_name'];
|
||||
$questions = self::generateCurriculumRemedialQuiz((int)$error['id'], $subject, $topic, $error['question_text']);
|
||||
}
|
||||
$attemptUuid = $this->uuid();
|
||||
Database::insert(
|
||||
"INSERT INTO error_notebook_quiz_attempts (uuid,error_id,student_id,phase,question_ids_json,answer_key_json,expires_at)
|
||||
VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 30 MINUTE))",
|
||||
[$attemptUuid, (int)$error['id'], (int)$request->user_id,
|
||||
$error['status'] === 'in_remediation' ? 'retention' : 'initial',
|
||||
json_encode(array_column($questions, 'id')), json_encode($answerKey)]
|
||||
);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
@@ -129,6 +145,7 @@ class ErrorNotebookController
|
||||
'error_uuid' => $uuid,
|
||||
'topic_name' => $error['topic_name'],
|
||||
'subject_name' => $error['subject_name'],
|
||||
'attempt_uuid' => $attemptUuid,
|
||||
'questions' => $questions,
|
||||
],
|
||||
]);
|
||||
@@ -138,111 +155,94 @@ class ErrorNotebookController
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||
$attemptUuid = trim((string)($body['attempt_uuid'] ?? ''));
|
||||
$studentId = (int)$request->user_id;
|
||||
|
||||
$error = Database::selectOne(
|
||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
[$uuid, $studentId]
|
||||
);
|
||||
if (!$error) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']);
|
||||
$answers = $body['answers'] ?? null;
|
||||
if (!is_array($answers) || count($answers) !== 3) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'يجب إرسال إجابات الأسئلة الثلاثة']);
|
||||
return;
|
||||
}
|
||||
|
||||
$isRetentionPhase = ($error['status'] === 'in_remediation');
|
||||
$nextStatus = $isRetentionPhase ? 'mastered' : 'in_remediation';
|
||||
|
||||
// Stage 1: Initial Remedial Drill passed -> enters Spaced Repetition (in_remediation)
|
||||
// Stage 2: Spaced Repetition Retention Challenge passed -> enters permanently Mastered
|
||||
Database::query(
|
||||
"UPDATE student_error_notebook
|
||||
SET status = ?,
|
||||
mastered_at = IF(? = 'mastered', NOW(), mastered_at),
|
||||
remediation_attempts_count = remediation_attempts_count + 1
|
||||
WHERE id = ?",
|
||||
[$nextStatus, $nextStatus, (int)$error['id']]
|
||||
);
|
||||
|
||||
// Update student mastery analytics and readiness
|
||||
$currentMastery = Database::selectOne(
|
||||
"SELECT id, tawjihi_readiness_score, mastery_percentage FROM student_mastery_analytics WHERE student_id = ? ORDER BY id DESC LIMIT 1",
|
||||
[$studentId]
|
||||
);
|
||||
if ($currentMastery) {
|
||||
$readinessGain = $isRetentionPhase ? 1.5 : 0.6;
|
||||
$masteryGain = $isRetentionPhase ? 1.2 : 0.5;
|
||||
$newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + $readinessGain);
|
||||
$newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + $masteryGain);
|
||||
Database::query(
|
||||
"UPDATE student_mastery_analytics
|
||||
SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW()
|
||||
WHERE id = ?",
|
||||
[$newReadiness, $newMastery, (int)$currentMastery['id']]
|
||||
$answersHash = hash('sha256', json_encode($answers));
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$error = Database::selectOne(
|
||||
'SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE uuid = ? AND student_id = ? FOR UPDATE',
|
||||
[$uuid, $studentId]
|
||||
);
|
||||
$attempt = $error ? Database::selectOne(
|
||||
'SELECT *, (expires_at > NOW()) AS is_fresh FROM error_notebook_quiz_attempts WHERE uuid = ? AND error_id = ? AND student_id = ? FOR UPDATE',
|
||||
[$attemptUuid, (int)$error['id'], $studentId]
|
||||
) : null;
|
||||
if ($attempt && $attempt['status'] !== 'open' && hash_equals((string)($attempt['answers_sha256'] ?? ''), $answersHash)) {
|
||||
$pdo->commit();
|
||||
$response->json(['status' => 'success', 'passed' => $attempt['status'] === 'passed',
|
||||
'correct_count' => (int)$attempt['correct_count'],
|
||||
'new_status' => $attempt['status'] === 'passed' ? ($attempt['phase'] === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
|
||||
return;
|
||||
}
|
||||
if (!$attempt || $attempt['status'] !== 'open' || (int)$attempt['is_fresh'] !== 1) {
|
||||
$pdo->rollBack();
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'المحاولة غير متاحة أو انتهت صلاحيتها']);
|
||||
return;
|
||||
}
|
||||
$phase = $error['status'] === 'in_remediation' ? 'retention' : 'initial';
|
||||
if ($error['status'] === 'mastered' || $attempt['phase'] !== $phase ||
|
||||
($phase === 'retention' && (int)$error['retention_is_due'] !== 1)) {
|
||||
$pdo->rollBack();
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'حالة الفجوة تغيرت؛ ابدأ محاولة جديدة']);
|
||||
return;
|
||||
}
|
||||
$ids = json_decode($attempt['question_ids_json'], true);
|
||||
$answerKey = json_decode($attempt['answer_key_json'], true);
|
||||
if (!is_array($ids) || !is_array($answerKey)) {
|
||||
$pdo->rollBack();
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
|
||||
return;
|
||||
}
|
||||
foreach ($ids as $index => $questionId) {
|
||||
$published = Database::selectOne(
|
||||
"SELECT q.id FROM questions q JOIN exams e ON e.id = q.exam_id
|
||||
WHERE q.id = ? AND e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
|
||||
AND q.remediation_review_status = 'approved' AND q.remediation_reviewed_by IS NOT NULL
|
||||
AND q.remediation_reviewed_at IS NOT NULL AND q.remediation_source_reference IS NOT NULL FOR UPDATE",
|
||||
[(int)$questionId, (int)$error['lesson_id'], $error['topic_name']]
|
||||
);
|
||||
if (!$published) {
|
||||
$pdo->rollBack();
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'سُحب سؤال من هذه المحاولة؛ ابدأ محاولة جديدة']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$correct = RemediationAttemptGrader::grade($ids, $answerKey, $answers);
|
||||
} catch (\InvalidArgumentException $invalidAnswer) {
|
||||
$pdo->rollBack();
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
|
||||
return;
|
||||
}
|
||||
$passed = $correct >= 2;
|
||||
Database::query(
|
||||
"UPDATE error_notebook_quiz_attempts SET status = ?, correct_count = ?, answers_sha256 = ?, submitted_at = NOW() WHERE id = ?",
|
||||
[$passed ? 'passed' : 'failed', $correct, $answersHash, (int)$attempt['id']]
|
||||
);
|
||||
if ($passed) {
|
||||
Database::query(
|
||||
"UPDATE student_error_notebook SET status = ?, remediation_attempts_count = remediation_attempts_count + 1,
|
||||
retention_due_at = IF(? = 'initial', DATE_ADD(NOW(), INTERVAL 7 DAY), retention_due_at),
|
||||
mastered_at = IF(? = 'retention', NOW(), mastered_at) WHERE id = ?",
|
||||
[$phase === 'retention' ? 'mastered' : 'in_remediation', $phase, $phase, (int)$error['id']]
|
||||
);
|
||||
} else {
|
||||
Database::query('UPDATE student_error_notebook SET remediation_attempts_count = remediation_attempts_count + 1 WHERE id = ?', [(int)$error['id']]);
|
||||
}
|
||||
$pdo->commit();
|
||||
$response->json(['status' => 'success', 'passed' => $passed, 'correct_count' => $correct,
|
||||
'new_status' => $passed ? ($phase === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($isRetentionPhase) {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'mastered' => true,
|
||||
'is_retention_verified' => true,
|
||||
'new_status' => 'mastered',
|
||||
'message' => 'تم اجتياز اختبار الاسترجاع والتثبيت بنجاح تام! اعتُمد المفهوم كمتقن في الذاكرة طويلة المدى.',
|
||||
]);
|
||||
} else {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'mastered' => false,
|
||||
'is_retention_verified' => false,
|
||||
'new_status' => 'in_remediation',
|
||||
'message' => 'تم اجتياز الكويز العلاجي الأولي بنجاح! انتقل المفهوم إلى مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام ومنع النسيان).',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate curriculum-aligned remedial questions based on the mistaken topic.
|
||||
*/
|
||||
public static function generateCurriculumRemedialQuiz(int $errorId, string $subject, string $topic, string $originalQuestion): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => $errorId * 10 + 1,
|
||||
'question' => "سؤال علاجي في مفهوم [{$topic}]: ما المبدأ العلمي الأساسي الذي يحكم سلوك الظاهرة؟",
|
||||
'options' => [
|
||||
"الاعتماد المباشر على العلاقة الرياضية ومحددات الاتجاه للمتغيرات في المنهج",
|
||||
"تطبيق عشوائي للقيم دون ربطها بالقانون الفيزيائي أو الكيميائي",
|
||||
"إهمال الوحدات الأساسية والتحويل بين البادئات العلمية",
|
||||
"افتراض ثبات المتغيرات غير المقيسة بدون سند تجريبي"
|
||||
],
|
||||
'correct_index' => 0,
|
||||
'explanation' => "الأساس العلمي في دراسة {$topic} يقتضي الانطلاق دائماً من العلاقة الرياضية المعتمدة وتحديد المتغيرات التابعة والمستقلة بدقة.",
|
||||
],
|
||||
[
|
||||
'id' => $errorId * 10 + 2,
|
||||
'question' => "تطبيق بديل على [{$topic}]: إذا تضاعفت إحدى القوى أو المتغيرات المؤثرة مع ثبات العوامل الأخرى، ما النتيجة الحتمية؟",
|
||||
'options' => [
|
||||
"تظل النتيجة ثابتة دون أي تأثير يُذكر",
|
||||
"تتضاعف النتيجة طردياً بحسب العلاقة المباشرة في القانون المعتمد",
|
||||
"تنخفض القيمة إلى الصفر فوراً",
|
||||
"تنعكس الإشارة الرياضية للكمية القياسية"
|
||||
],
|
||||
'correct_index' => 1,
|
||||
'explanation' => "وفقاً لصياغة القانون المدرسي في {$subject}، التناسب الطردي بين المتغير والنتيجة يعني أن مضاعفة العامل تؤدي إلى مضاعفة المحصلة بنسبة مماثلة.",
|
||||
],
|
||||
[
|
||||
'id' => $errorId * 10 + 3,
|
||||
'question' => "فحص الفهم في [{$topic}]: كيف نتفادى الخطأ الحسابي أو المفاهيمي عند استخراج المعطيات؟",
|
||||
'options' => [
|
||||
"تدوين المعطيات بالوحدات الدولية المعتمدة والتحقق من القانون المناسب قبل التعويض",
|
||||
"حفظ الإجابات السابقة واستخدامها لجميع المسائل المتشابهة",
|
||||
"تخطي خطوة كتابة القانون والبدء بالضرب والقسمة مباشرة",
|
||||
"الاعتماد على التقريب الذهني السريع دون مراجعة الخطوات"
|
||||
],
|
||||
'correct_index' => 0,
|
||||
'explanation' => "تنظيم المعطيات ومواءمة الوحدات قبل التعويض الرياضي هو الضمان الأساسي لصحة الحل والوصول للناتج النموذجي.",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function uuid(): string
|
||||
|
||||
@@ -121,13 +121,10 @@ class SuperAdminController
|
||||
{
|
||||
$bundles = Database::select(
|
||||
"SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at,
|
||||
cl.title AS lesson_title, cl.curriculum_edition, s.name AS subject_name, g.name AS grade_name
|
||||
cl.title AS lesson_title, cl.curriculum_version, cl.subject_key, cl.grade_key,
|
||||
cl.semester_key, cl.unit_key, cl.lesson_key
|
||||
FROM publication_bundles pb
|
||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||
JOIN curriculum_units cu ON cu.id = cl.unit_id
|
||||
JOIN curriculum_courses cc ON cc.id = cu.course_id
|
||||
JOIN subjects s ON s.id = cc.subject_id
|
||||
JOIN grade_levels g ON g.id = cc.grade_level_id
|
||||
ORDER BY pb.id DESC LIMIT 100"
|
||||
);
|
||||
$submissions = Database::select(
|
||||
@@ -150,35 +147,10 @@ class SuperAdminController
|
||||
|
||||
public function reviewSubmission(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$versionUuid = trim((string)($body['version_uuid'] ?? ''));
|
||||
$decision = (string)($body['decision'] ?? ''); // 'approved' or 'rejected'
|
||||
|
||||
if (!in_array($decision, ['approved', 'rejected'], true) || $versionUuid === '') {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'بيانات مراجعة النسخة غير صالحة']);
|
||||
return;
|
||||
}
|
||||
|
||||
$version = Database::selectOne(
|
||||
"SELECT vv.id, vv.teacher_submission_id FROM video_versions vv WHERE vv.uuid = ? LIMIT 1",
|
||||
[$versionUuid]
|
||||
);
|
||||
if (!$version) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'نسخة الفيديو غير موجودة']);
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $decision === 'approved' ? 'published' : 'rejected';
|
||||
Database::query("UPDATE video_versions SET status = ?, reviewed_at = NOW() WHERE id = ?", [$status, (int)$version['id']]);
|
||||
|
||||
if ($decision === 'approved') {
|
||||
Database::query(
|
||||
"UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?",
|
||||
[(int)$version['id'], (int)$version['teacher_submission_id']]
|
||||
);
|
||||
}
|
||||
|
||||
$response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ونشر النسخة بنجاح' : 'تم رفض النسخة']);
|
||||
$response->status(410)->json([
|
||||
'status' => 'review_pipeline_required',
|
||||
'message' => 'استخدم مسارات أدلة فحص الفيديو، قرار المراجع، ثم نشر مهمة المراجعة المعتمدة.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function count(string $table): int
|
||||
|
||||
@@ -29,6 +29,50 @@ use App\Services\VideoReviewService;
|
||||
|
||||
class VideoController
|
||||
{
|
||||
public function legacyPlaybackUnavailable(Request $request, Response $response): void
|
||||
{
|
||||
$response->status(410)->json([
|
||||
'status' => 'version_required',
|
||||
'message' => 'استخدم نسخة الفيديو المنشورة المحددة بهوية الدرس المنهجي.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function mayStreamVideo(Request $request, string $videoUuid): bool
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $videoUuid)) return false;
|
||||
if ($request->role === 'super_admin') return true;
|
||||
if ($request->role === 'teacher') {
|
||||
$owner = Database::selectOne(
|
||||
"SELECT l.id FROM lessons l JOIN courses c ON c.id=l.course_id
|
||||
JOIN teachers t ON t.id=c.teacher_id
|
||||
WHERE l.video_uuid=? AND t.identity_id=? LIMIT 1",
|
||||
[$videoUuid, (int)$request->identity_id]
|
||||
);
|
||||
return (bool)$owner;
|
||||
}
|
||||
if ($request->role !== 'student') return false;
|
||||
$row = Database::selectOne(
|
||||
"SELECT l.id AS lesson_id,l.course_id,cl.grade_key
|
||||
FROM lessons l JOIN video_versions vv ON vv.source_lesson_id=l.id AND vv.status='published'
|
||||
JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
|
||||
AND ts.current_published_video_version_id=vv.id AND ts.status='published'
|
||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved'
|
||||
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
|
||||
AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE l.video_uuid=? AND l.encoding_status='ready' LIMIT 1",
|
||||
[$videoUuid]
|
||||
);
|
||||
if (!$row) return false;
|
||||
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
||||
(int)$request->user_id, null,
|
||||
\App\Services\StudentAccessControlService::normalizeGrade($row['grade_key']),
|
||||
(int)$row['course_id'], (int)$row['lesson_id'], false
|
||||
);
|
||||
return !empty($access['allowed']);
|
||||
}
|
||||
/** POST /api/video-versions/{versionId}/watch-sessions */
|
||||
public function startWatchSession(Request $request, Response $response): void
|
||||
{
|
||||
@@ -60,7 +104,12 @@ class VideoController
|
||||
cl.uuid AS curriculum_lesson_id, cl.title, cl.grade_key, ts.uuid AS submission_id
|
||||
FROM video_versions vv
|
||||
JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published'
|
||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
|
||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved'
|
||||
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
|
||||
AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready'
|
||||
WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid]
|
||||
);
|
||||
@@ -206,43 +255,74 @@ class VideoController
|
||||
try {
|
||||
$lesson = Database::selectOne("SELECT id, uuid, title, grade_key FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]);
|
||||
if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; }
|
||||
$rows = Database::select(
|
||||
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
|
||||
cl.grade_key, c.grade_level AS course_grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating,
|
||||
COALESCE(pm.total_reviews_count, 0) AS rating_count
|
||||
FROM teacher_submissions ts
|
||||
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
|
||||
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
||||
JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready'
|
||||
JOIN courses c ON c.id = l.course_id
|
||||
JOIN teachers t ON t.id = ts.teacher_id
|
||||
LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id
|
||||
WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published'
|
||||
ORDER BY (rating_count > 0) DESC, rating DESC, vv.id ASC LIMIT 101",
|
||||
[$lesson['id']]
|
||||
);
|
||||
// A list is also protected content: do not disclose a teacher, count,
|
||||
// or version that the student cannot play. A curriculum lesson may
|
||||
// legitimately have versions attached to different courses.
|
||||
$accessible = [];
|
||||
foreach ($rows as $row) {
|
||||
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
|
||||
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
||||
(int)$request->user_id,
|
||||
$request->getHeader('x-national-id'),
|
||||
$targetGrade,
|
||||
(int)$row['course_id'],
|
||||
(int)$row['source_lesson_id'],
|
||||
false
|
||||
);
|
||||
if (!empty($access['allowed'])) $accessible[] = $row;
|
||||
// Scan in bounded batches: entitlement is per source lesson/course,
|
||||
// so a SQL LIMIT before the access check would hide later teachers.
|
||||
$pdo = Database::getConnection();
|
||||
$ownsTransaction = !$pdo->inTransaction();
|
||||
if ($ownsTransaction) {
|
||||
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
||||
$pdo->beginTransaction();
|
||||
}
|
||||
// `cursor` is an offset in the stable rating order, rather than a
|
||||
// database id (an id seek would skip records after rating changes).
|
||||
$page = array_slice($accessible, $cursor, $limit);
|
||||
$hasMore = count($accessible) > ($cursor + count($page));
|
||||
$items = array_map(static fn($r) => ['video_version_id'=>$r['video_version_id'],'submission_id'=>$r['submission_id'],'teacher_name'=>$r['teacher_name'],'rating'=>(float)$r['rating'],'rating_count'=>(int)$r['rating_count'],'is_new'=>(int)$r['rating_count']===0], $page);
|
||||
$response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>count($accessible),'items'=>$items,'next_cursor'=>$hasMore ? $cursor + count($page) : null]]);
|
||||
try {
|
||||
$batchSize = 100;
|
||||
$rawOffset = 0;
|
||||
$availableCount = 0;
|
||||
$page = [];
|
||||
do {
|
||||
$rows = Database::select(
|
||||
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
|
||||
cl.grade_key, l.course_id, l.duration_seconds, vv.published_at,
|
||||
t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating,
|
||||
COALESCE(pm.total_reviews_count, 0) AS rating_count
|
||||
FROM teacher_submissions ts
|
||||
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
|
||||
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
|
||||
JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready'
|
||||
JOIN teachers t ON t.id = ts.teacher_id
|
||||
LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id
|
||||
WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM video_review_jobs j
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
|
||||
AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE j.video_version_id=vv.id AND j.status='approved'
|
||||
)
|
||||
ORDER BY (COALESCE(pm.total_reviews_count, 0) > 0) DESC,
|
||||
COALESCE(pm.weighted_student_rating, 0) DESC, vv.id ASC
|
||||
LIMIT {$batchSize} OFFSET {$rawOffset}",
|
||||
[$lesson['id']]
|
||||
);
|
||||
foreach ($rows as $row) {
|
||||
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
|
||||
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
||||
(int)$request->user_id,
|
||||
$request->getHeader('x-national-id'),
|
||||
$targetGrade,
|
||||
(int)$row['course_id'],
|
||||
(int)$row['source_lesson_id'],
|
||||
false
|
||||
);
|
||||
if (empty($access['allowed'])) continue;
|
||||
if ($availableCount >= $cursor && count($page) < $limit) $page[] = $row;
|
||||
$availableCount++;
|
||||
}
|
||||
$rawOffset += count($rows);
|
||||
} while (count($rows) === $batchSize);
|
||||
if ($ownsTransaction) $pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($ownsTransaction && $pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
$items = array_map(static fn($r) => [
|
||||
'video_version_id'=>$r['video_version_id'], 'submission_id'=>$r['submission_id'],
|
||||
'teacher_name'=>$r['teacher_name'], 'rating'=>(float)$r['rating'],
|
||||
'rating_count'=>(int)$r['rating_count'], 'is_new'=>(int)$r['rating_count']===0,
|
||||
'duration_seconds'=>(int)$r['duration_seconds'], 'published_at'=>$r['published_at'],
|
||||
], $page);
|
||||
$nextCursor = $cursor + count($page);
|
||||
$response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>$availableCount,'items'=>$items,'next_cursor'=>$nextCursor < $availableCount ? $nextCursor : null]]);
|
||||
} catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); }
|
||||
}
|
||||
/** POST /api/teacher/submissions/preflight */
|
||||
@@ -649,8 +729,8 @@ class VideoController
|
||||
public function streamLocalVideo(Request $request, Response $response): void
|
||||
{
|
||||
$uuid = $request->getParam('uuid');
|
||||
if (empty($uuid)) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'معرف الفيديو مطلوب']);
|
||||
if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) {
|
||||
$response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -666,8 +746,8 @@ class VideoController
|
||||
$uuid = $request->getParam('uuid');
|
||||
$file = $request->getParam('file') ?: 'index.m3u8';
|
||||
|
||||
if (empty($uuid)) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']);
|
||||
if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) {
|
||||
$response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -682,13 +762,14 @@ class VideoController
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$lessonId = (int)($body['lesson_id'] ?? 0);
|
||||
$timeSeconds = (int)($body['timestamp_seconds'] ?? 15);
|
||||
$rewindSecs = (int)($body['rewind_seconds'] ?? 45);
|
||||
$timeSeconds = (int)($body['timestamp_seconds'] ?? 0);
|
||||
$rewindSecs = (int)($body['rewind_seconds'] ?? 0);
|
||||
$question = trim((string)($body['question_text'] ?? ''));
|
||||
$options = (array)($body['options'] ?? []);
|
||||
$correctIdx = (int)($body['correct_index'] ?? 0);
|
||||
|
||||
if (!$lessonId || empty($question) || empty($options)) {
|
||||
if (!$lessonId || $question === '' || count($options) < 2 || $correctIdx < 0 || $correctIdx >= count($options) ||
|
||||
$timeSeconds <= 0 || $rewindSecs < 0 || array_filter($options, fn($option) => !is_string($option) || trim($option) === '') !== []) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة'
|
||||
@@ -696,11 +777,19 @@ class VideoController
|
||||
return;
|
||||
}
|
||||
|
||||
$lesson = Database::selectOne("SELECT l.id, l.course_id, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]);
|
||||
$lesson = Database::selectOne("SELECT l.id, l.course_id, l.duration_seconds, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]);
|
||||
if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) {
|
||||
$response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']);
|
||||
return;
|
||||
}
|
||||
if ($timeSeconds >= (int)$lesson['duration_seconds']) {
|
||||
$response->status(422)->json(['status'=>'error','message'=>'توقيت السؤال خارج مدة الفيديو']);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
|
||||
$examUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
@@ -712,7 +801,7 @@ class VideoController
|
||||
|
||||
$examId = Database::insert(
|
||||
"INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published)
|
||||
VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'فحص سقراطي لحظي', ?, ?, 100.00, 10, 1, 1)",
|
||||
VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'مسودة سؤال فيديو', ?, ?, 100.00, 10, 1, 0)",
|
||||
[$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs]
|
||||
);
|
||||
|
||||
@@ -736,10 +825,15 @@ class VideoController
|
||||
[$qId, $optText, $isCorrect]
|
||||
);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$response->status(201)->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!',
|
||||
'message' => 'حُفظ السؤال كمسودة، ويحتاج ربطاً بدليل نسخة الفيديو ومراجعة قبل ظهوره للطالب.',
|
||||
'data' => [
|
||||
'exam_id' => $examId,
|
||||
'timestamp_seconds' => $timeSeconds,
|
||||
@@ -790,6 +884,23 @@ class VideoController
|
||||
$conditions[] = $gradeCond;
|
||||
}
|
||||
|
||||
// Do not expose legacy lesson rows as student video offerings. The
|
||||
// exact published version must have approved, version-bound review
|
||||
// evidence tied to the currently published curriculum Markdown.
|
||||
$conditions[] = "EXISTS (
|
||||
SELECT 1
|
||||
FROM video_versions vv
|
||||
JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
|
||||
AND ts.current_published_video_version_id=vv.id AND ts.status='published'
|
||||
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved'
|
||||
JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved'
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published'
|
||||
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
|
||||
JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256
|
||||
AND a.review_status='approved' AND a.rights_status='cleared'
|
||||
WHERE vv.source_lesson_id=l.id AND vv.status='published'
|
||||
)";
|
||||
|
||||
$whereClause = !empty($conditions) ? ('WHERE ' . implode(' AND ', $conditions)) : '';
|
||||
|
||||
$lessons = Database::select(
|
||||
@@ -962,69 +1073,9 @@ class VideoController
|
||||
return;
|
||||
}
|
||||
|
||||
// Self-Healing Curriculum Guard: Purge any obsolete/mismatched calculus questions
|
||||
// for non-calculus lessons (e.g. Grade 10 Systems of Equations)
|
||||
$isCalculusLesson = (str_contains($lesson['title'], 'اشتقاق') || str_contains($lesson['title'], 'تفاضل'));
|
||||
if (!$isCalculusLesson) {
|
||||
try {
|
||||
$mismatched = Database::selectOne(
|
||||
"SELECT q.id FROM questions q
|
||||
JOIN exams e ON q.exam_id = e.id
|
||||
WHERE e.lesson_id = ? AND (q.question_text LIKE '%مشتق%' OR q.question_text LIKE '%f\'(x)%')
|
||||
LIMIT 1",
|
||||
[$lessonId]
|
||||
);
|
||||
if ($mismatched) {
|
||||
$badExams = Database::select("SELECT id FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
|
||||
foreach ($badExams as $be) {
|
||||
Database::query("DELETE FROM exams WHERE id = ?", [$be['id']]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Curriculum self-healing notice: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Playback is read-only. Checkpoints are published only by the review
|
||||
// workflow after it verifies the video transcript and lesson Markdown.
|
||||
|
||||
// Fetch attached in-video Socratic Checkpoints with Questions and Options
|
||||
$exams = Database::select(
|
||||
"SELECT e.id as exam_id, e.uuid as exam_uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage
|
||||
FROM exams e
|
||||
WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1
|
||||
ORDER BY e.timestamp_seconds ASC",
|
||||
[$lessonId]
|
||||
);
|
||||
|
||||
// Legacy exams have no video-version evidence binding. They must not
|
||||
// mutate storage during GET or be exposed as approved checkpoints.
|
||||
$checkpoints = [];
|
||||
foreach ($exams as $ex) {
|
||||
$q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]);
|
||||
if (!$q) {
|
||||
continue;
|
||||
}
|
||||
$opts = [];
|
||||
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
|
||||
if (count($opts) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$checkpoints[] = [
|
||||
'exam_id' => (int)$ex['exam_id'],
|
||||
'question_id' => (int)$q['id'],
|
||||
'timestamp_seconds' => (int)$ex['timestamp_seconds'],
|
||||
'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'],
|
||||
'question_text' => $q['question_text'],
|
||||
'explanation' => $q['explanation_text'] ?? '',
|
||||
'options' => array_map(function ($o) {
|
||||
return [
|
||||
'id' => (int)$o['id'],
|
||||
'text' => $o['option_text'],
|
||||
'is_correct' => (bool)$o['is_correct']
|
||||
];
|
||||
}, $opts)
|
||||
];
|
||||
}
|
||||
|
||||
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
|
||||
$playbackInfo = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* توحيد أسماء ومفاتيح الصفوف
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']]);
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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'"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user