Harden published curriculum and student flows

This commit is contained in:
Hamza-Ayed
2026-09-30 08:22:17 +03:00
parent e8163fe265
commit ce7b0fcf14
53 changed files with 4723 additions and 3317 deletions
+68 -17
View File
@@ -18,10 +18,24 @@ final class VideoReviewService
}
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$job=Database::selectOne('SELECT j.id,j.status,e.id AS evidence_id FROM video_review_jobs j LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
$job=Database::selectOne('SELECT j.id,j.status,j.video_sha256,j.markdown_sha256,vv.uuid AS video_version_uuid,cl.uuid AS curriculum_lesson_uuid,e.id AS evidence_id,e.coverage_json FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id JOIN curriculum_lessons cl ON cl.id=j.curriculum_lesson_id LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
if(!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
if(($job['status'] ?? '') !== 'ready_for_human_review') return self::finish($pdo,['http_status'=>409,'status'=>'job_not_reviewable','message'=>'حالة المهمة لا تسمح بقرار جديد.']);
if (empty($job['evidence_id'])) return self::finish($pdo,['http_status'=>422,'status'=>'missing_evidence','message'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']);
$bindings = $report['bindings'];
$coverageReport = $report['coverage'];
$savedCoverage = json_decode((string)$job['coverage_json'], true);
if (!is_array($bindings) || !is_array($coverageReport) || !is_array($savedCoverage) ||
!hash_equals((string)$job['video_version_uuid'], (string)($bindings['video_version_id'] ?? '')) ||
!hash_equals((string)$job['curriculum_lesson_uuid'], (string)($bindings['curriculum_lesson_id'] ?? '')) ||
!hash_equals((string)$job['video_sha256'], (string)($bindings['video_sha256'] ?? '')) ||
!hash_equals((string)$job['markdown_sha256'], (string)($bindings['markdown_sha256'] ?? '')) ||
($coverageReport['truncated'] ?? true) !== false || !empty($coverageReport['unprocessed_intervals'])) {
return self::finish($pdo,['http_status'=>422,'status'=>'stale_or_incomplete_report','message'=>'ارتباطات التقرير أو تغطيته لا تطابق النسخة والمصدر.']);
}
if ($decision === 'approved' && !VideoReviewReportValidator::approvable($report)) {
return self::finish($pdo,['http_status'=>422,'status'=>'blocking_findings','message'=>'تقرير الفحص لا يغطي كل الأقسام والأهداف أو يحتوي مشكلات غير محسومة.']);
}
$approved=$decision==='approved' && $recommendation==='ready_for_human_review';
Database::query("INSERT INTO video_review_reports (video_review_job_id,recommendation,report_json,reviewer_id,human_decision,reviewed_at) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE recommendation=VALUES(recommendation),report_json=VALUES(report_json),reviewer_id=VALUES(reviewer_id),human_decision=VALUES(human_decision),reviewed_at=NOW()",[$job['id'],$recommendation,json_encode($report,JSON_UNESCAPED_UNICODE),$reviewerId,$decision]);
Database::query('UPDATE video_review_jobs SET status=?, completed_at=NOW() WHERE id=?',[$approved?'approved':'rejected',$job['id']]);
@@ -34,25 +48,24 @@ final class VideoReviewService
if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) {
return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.'];
}
if (empty($coverage['segments']) || !is_array($coverage['segments'])) {
return ['http_status'=>422,'status'=>'incomplete_evidence','message'=>'يلزم سجل تغطية زمني للفيديو.'];
}
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$job=Database::selectOne('SELECT id,video_sha256,status FROM video_review_jobs WHERE uuid=? FOR UPDATE',[$jobUuid]);
$job=Database::selectOne('SELECT j.id,j.video_sha256,j.markdown_sha256,j.status,j.video_version_id,l.duration_seconds
FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id
JOIN lessons l ON l.id=vv.source_lesson_id WHERE j.uuid=? FOR UPDATE',[$jobUuid]);
if (!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']);
if (!in_array($job['status'],['queued','collecting_evidence','needs_evidence'],true)) return self::finish($pdo,['http_status'=>409,'status'=>'job_not_collecting','message'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']);
$transcript=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$transcriptAssetUuid]);
$visual=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$visualAssetUuid]);
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || $visual['review_status'] !== 'approved') {
if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' ||
$visual['asset_type'] !== 'other' || $visual['review_status'] !== 'approved' ||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$transcript['source_reference']) ||
!hash_equals('video:'.(string)$job['video_sha256'], (string)$visual['source_reference'])) {
return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']);
}
$segments=$coverage['segments']; $lastEnd=0;
foreach ($segments as $segment) {
if (!is_array($segment) || !isset($segment['start_seconds'],$segment['end_seconds'],$segment['transcript_ref'],$segment['visual_ref']) || !is_numeric($segment['start_seconds']) || !is_numeric($segment['end_seconds']) || (float)$segment['start_seconds'] < $lastEnd || (float)$segment['end_seconds'] <= (float)$segment['start_seconds']) {
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']);
}
$lastEnd=(float)$segment['end_seconds'];
if (!VideoCoverageValidator::valid($coverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
!hash_equals((string)$job['markdown_sha256'], (string)($coverage['markdown_sha256'] ?? ''))) {
return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'التغطية لا تشمل كامل الفيديو أو لا تطابق المصدر.']);
}
Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]);
Database::query("UPDATE video_review_jobs SET status='ready_for_human_review' WHERE id=?",[$job['id']]);
@@ -66,25 +79,63 @@ final class VideoReviewService
$pdo->beginTransaction();
try {
$job = Database::selectOne(
"SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid,
vv.source_lesson_id, ts.id AS submission_id,
"SELECT j.id, j.status, j.video_version_id, j.video_sha256, j.markdown_sha256,
vv.uuid AS version_uuid, vv.status AS version_status, vv.content_sha256,
vv.source_lesson_id, vv.replaces_video_version_id,
ts.id AS submission_id, ts.curriculum_lesson_id,
ts.current_published_video_version_id, cl.source_status,
l.encoding_status, l.duration_seconds,
currentv.uuid AS current_version_uuid
FROM video_review_jobs j
JOIN video_versions vv ON vv.id = j.video_version_id
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
LEFT JOIN lessons l ON l.id=vv.source_lesson_id
LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id
WHERE j.uuid = ? FOR UPDATE",
[$jobUuid]
);
if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']);
$report = Database::selectOne('SELECT human_decision FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') {
$report = Database::selectOne('SELECT human_decision,recommendation,reviewer_id,report_json FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]);
$evidence = Database::selectOne('SELECT id,coverage_json FROM video_review_evidence WHERE video_review_job_id=? FOR UPDATE', [$job['id']]);
if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved' ||
($report['recommendation'] ?? '') !== 'ready_for_human_review' || empty($report['reviewer_id']) || !$evidence) {
return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']);
}
$reportData = json_decode((string)$report['report_json'], true);
$savedCoverage = json_decode((string)$evidence['coverage_json'], true);
if (!is_array($reportData) || !is_array($savedCoverage) ||
!VideoReviewReportValidator::approvable($reportData) ||
!VideoCoverageValidator::valid($savedCoverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) ||
!hash_equals((string)$job['markdown_sha256'], (string)($savedCoverage['markdown_sha256'] ?? '')) ||
($reportData['coverage']['truncated'] ?? true) !== false ||
!empty($reportData['coverage']['unprocessed_intervals'])) {
return self::finish($pdo, ['status'=>'stale_evidence','http_status'=>409,'message'=>'أدلة الفيديو أو التقرير لم تعد صالحة للنشر.']);
}
if (($job['version_status'] ?? '') !== 'review' || ($job['source_status'] ?? '') !== 'approved' ||
($job['encoding_status'] ?? '') !== 'ready' || empty($job['source_lesson_id']) ||
!hash_equals((string)$job['video_sha256'], (string)($job['content_sha256'] ?? '')) ||
$job['video_sha256'] === str_repeat('0', 64) || $job['markdown_sha256'] === str_repeat('0', 64)) {
return self::finish($pdo, ['status'=>'version_not_ready','http_status'=>409,'message'=>'نسخة الفيديو أو درسها غير جاهزين للنشر.']);
}
$markdown = Database::selectOne(
"SELECT a.id FROM publication_bundles pb
JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson'
JOIN content_assets a ON a.id=pba.content_asset_id
WHERE pb.curriculum_lesson_id=? AND pb.status='published'
AND a.sha256=? AND a.review_status='approved' AND a.rights_status='cleared'
LIMIT 1 FOR UPDATE",
[(int)$job['curriculum_lesson_id'], $job['markdown_sha256']]
);
if (!$markdown) return self::finish($pdo, ['status'=>'source_changed','http_status'=>409,'message'=>'مصدر الدرس المعتمد لا يطابق تقرير الفحص.']);
if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) {
return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']);
}
if (empty($job['source_lesson_id'])) return self::finish($pdo, ['status' => 'storage_not_ready', 'http_status' => 409, 'message' => 'الفيديو غير مرتبط بتخزين جاهز.']);
if (($job['current_published_video_version_id'] === null) !== ($job['replaces_video_version_id'] === null) ||
($job['current_published_video_version_id'] !== null && (int)$job['current_published_video_version_id'] !== (int)$job['replaces_video_version_id']) ||
($job['current_published_video_version_id'] !== null && $expectedCurrentVersionUuid === null)) {
return self::finish($pdo, ['status'=>'current_version_conflict','http_status'=>409,'message'=>'يلزم تحديد النسخة الحالية المطابقة قبل الاستبدال.']);
}
Database::query("UPDATE video_versions SET status = 'superseded' WHERE teacher_submission_id = ? AND status = 'published'", [$job['submission_id']]);
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$job['video_version_id']]);
Database::query("UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", [$job['video_version_id'], $job['submission_id']]);