400,'status'=>'error','message'=>'قرار المراجعة غير صالح.']; } if (empty($report['bindings']) || empty($report['coverage'])) { return ['http_status'=>422,'status'=>'incomplete_report','message'=>'التقرير لا يحتوي bindings وتغطية الأدلة المطلوبة.']; } $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]); 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'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']); $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']]); return self::finish($pdo,['http_status'=>200,'status'=>$approved?'approved':'rejected']); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} } /** Attach reviewed, version-bound evidence before human review. */ public static function submitEvidence(string $jobUuid, int $reviewerId, string $transcriptAssetUuid, string $visualAssetUuid, array $coverage): array { 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]); 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') { 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']; } 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']]); return self::finish($pdo,['http_status'=>200,'status'=>'ready_for_human_review']); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} } public static function publishApproved(string $jobUuid, ?string $expectedCurrentVersionUuid): array { $pdo = Database::getConnection(); $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, 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 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') { return self::finish($pdo, ['status' => 'review_not_approved', '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' => 'الفيديو غير مرتبط بتخزين جاهز.']); 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']]); $pdo->commit(); return ['status' => 'published', 'http_status' => 200, 'video_version_id' => $job['version_uuid']]; } catch (\Throwable $e) { if ($pdo->inTransaction()) $pdo->rollBack(); throw $e; } } public static function queueForVersion(int $versionId): array { $existing = Database::selectOne( "SELECT uuid, status FROM video_review_jobs WHERE video_version_id = ? AND status NOT IN ('failed', 'superseded') ORDER BY id DESC LIMIT 1", [$versionId] ); if ($existing) return ['job_id' => $existing['uuid'], 'status' => $existing['status'], 'reused' => true]; $binding = Database::selectOne( "SELECT vv.content_sha256, ts.curriculum_lesson_id, cl.uuid AS lesson_uuid, cl.curriculum_version, a.uuid AS asset_uuid, a.sha256 AS markdown_sha256 FROM video_versions vv JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id LEFT JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id AND pb.status = 'published' LEFT JOIN publication_bundle_assets pba ON pba.publication_bundle_id = pb.id AND pba.role = 'primary_lesson' LEFT JOIN content_assets a ON a.id = pba.content_asset_id AND a.review_status = 'approved' WHERE vv.id = ? LIMIT 1", [$versionId] ); if (!$binding) throw new \RuntimeException('نسخة الفيديو غير مرتبطة بدرس منهجي.'); $missing = ['timestamped_transcript', 'visual_evidence', 'coverage_manifest']; if (empty($binding['asset_uuid'])) $missing[] = 'approved_markdown_asset'; $jobUuid = self::uuid(); $manifest = [ 'curriculum_lesson_id' => $binding['lesson_uuid'], 'curriculum_version' => $binding['curriculum_version'], 'markdown_asset_id' => $binding['asset_uuid'] ?: null, 'missing_inputs' => $missing, ]; Database::insert( "INSERT INTO video_review_jobs (uuid, video_version_id, curriculum_lesson_id, prompt_version, schema_version, video_sha256, markdown_sha256, status, input_manifest_json) VALUES (?, ?, ?, 'video-review-v1', 'video-review-schema-v1', ?, ?, 'needs_evidence', ?)", [$jobUuid, $versionId, $binding['curriculum_lesson_id'], $binding['content_sha256'] ?: str_repeat('0', 64), $binding['markdown_sha256'] ?: str_repeat('0', 64), json_encode($manifest, JSON_UNESCAPED_UNICODE)] ); return ['job_id' => $jobUuid, 'status' => 'needs_evidence', 'missing_inputs' => $missing]; } 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]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); 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; } }