Implement verified curriculum, video review, watch sessions, ledger safeguards, and remove demo data
This commit is contained in:
@@ -36,7 +36,7 @@ class CliqPaymentController
|
||||
}
|
||||
|
||||
$res = CliqPaymentService::initiatePayment($studentId, $courseId);
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : 400;
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400);
|
||||
$response->status($status)->json($res);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class CliqPaymentController
|
||||
}
|
||||
|
||||
$res = CliqPaymentService::verifyPaymentReceipt($studentId, $referenceCode, $receiptBase64, $manualTxId);
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : 400;
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400);
|
||||
$response->status($status)->json($res);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class CliqPaymentController
|
||||
}
|
||||
|
||||
$res = CliqPaymentService::requestTeacherPayout($teacherId, $amountJod, $cliqAlias);
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : 400;
|
||||
$status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400);
|
||||
$response->status($status)->json($res);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,6 @@ class CliqPaymentController
|
||||
public function processPayoutQueue(Request $request, Response $response): void
|
||||
{
|
||||
$res = CliqPaymentService::processPayoutQueue();
|
||||
$response->json($res);
|
||||
$response->status(($res['status'] ?? '') === 'unavailable' ? 503 : 200)->json($res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Database;
|
||||
use App\Services\CurriculumService;
|
||||
use App\Services\CurriculumExtractorService;
|
||||
use App\Services\PublishedContentService;
|
||||
use App\Services\LearningPackageService;
|
||||
|
||||
class CurriculumController
|
||||
{
|
||||
@@ -164,16 +167,33 @@ class CurriculumController
|
||||
$response->json(['status' => 'success', 'log' => $logContent]);
|
||||
}
|
||||
|
||||
/** GET /api/curriculum/lessons/{lessonId}/english-package */
|
||||
public function getPublishedEnglishPackage(Request $request, Response $response): void
|
||||
{
|
||||
try {
|
||||
$result=LearningPackageService::publishedEnglish(trim((string)$request->getParam('lessonId','')),(int)$request->user_id,$request->getHeader('x-national-id'));
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch (\Throwable $e) { error_log('English package read failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر قراءة حزمة الإنجليزية.']); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Complete Live Tree
|
||||
*/
|
||||
|
||||
public function getTree(Request $request, Response $response): void
|
||||
{
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => CurriculumService::getCurriculumTree()
|
||||
]);
|
||||
$tree = CurriculumService::getCurriculumTree();
|
||||
try {
|
||||
$published = Database::select("SELECT cl.uuid, cl.source_manifest_path, COUNT(vv.id) AS video_count FROM curriculum_lessons cl LEFT JOIN teacher_submissions ts ON ts.curriculum_lesson_id=cl.id AND ts.status='published' LEFT JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published' WHERE cl.source_status='approved' GROUP BY cl.id, cl.uuid, cl.source_manifest_path");
|
||||
$byPath=[];
|
||||
foreach ($published as $row) $byPath[(string)$row['source_manifest_path']]=['curriculum_lesson_id'=>$row['uuid'],'has_video'=>(int)$row['video_count']>0];
|
||||
foreach ($tree as &$grade) foreach (($grade['subjects'] ?? []) as &$subject) foreach (($subject['semesters'] ?? []) as &$semester) foreach (($semester['units'] ?? []) as &$unit) foreach (($unit['lessons'] ?? []) as &$lesson) {
|
||||
$path=(string)($lesson['file'] ?? '');
|
||||
if (isset($byPath[$path])) $lesson=array_merge($lesson,$byPath[$path]);
|
||||
}
|
||||
unset($grade,$subject,$semester,$unit,$lesson);
|
||||
} catch (\Throwable $e) { error_log('Published curriculum tree enrichment unavailable: '.$e->getMessage()); }
|
||||
$response->json(['status'=>'success','data'=>$tree]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,30 +443,18 @@ class CurriculumController
|
||||
$type = $params['type'] ?? 'textbook';
|
||||
|
||||
$storage = realpath(__DIR__ . '/../../storage/curriculum');
|
||||
$resolvedFile = null;
|
||||
|
||||
if (!empty($file) && !str_ends_with($file, '.pdf')) {
|
||||
$cleanFile = ltrim($file, '/');
|
||||
if (file_exists("{$storage}/{$cleanFile}")) {
|
||||
$resolvedFile = "{$storage}/{$cleanFile}";
|
||||
}
|
||||
if (!$storage || empty($file) || !str_ends_with(strtolower($file), '.md')) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'يجب تحديد ملف Markdown معتمد للوثيقة المطلوبة.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not resolved by exact path, resolve by subject
|
||||
if (!$resolvedFile && !empty($subject)) {
|
||||
if (str_contains($subject, 'english') || str_contains($subject, 'إنجليز')) {
|
||||
$mds = glob("{$storage}/grade_10/english_10/semester_1/*/*.md");
|
||||
if (!empty($mds)) $resolvedFile = $mds[0];
|
||||
} elseif (str_contains($subject, 'physic') || str_contains($subject, 'فيزياء')) {
|
||||
$mds = glob("{$storage}/grade_10/physics_10/semester_1/*/*.md");
|
||||
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
|
||||
} elseif (str_contains($subject, 'math') || str_contains($subject, 'رياضيات')) {
|
||||
$mds = glob("{$storage}/grade_10/math_10/semester_1/*/*.md");
|
||||
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolvedFile && file_exists($resolvedFile)) {
|
||||
$candidate = realpath($storage . '/' . ltrim($file, '/'));
|
||||
$storagePrefix = rtrim($storage, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
if ($candidate && str_starts_with($candidate, $storagePrefix) && is_file($candidate)) {
|
||||
$resolvedFile = $candidate;
|
||||
$content = file_get_contents($resolvedFile);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
@@ -458,26 +466,87 @@ class CurriculumController
|
||||
return;
|
||||
}
|
||||
|
||||
// Tailored subject fallback
|
||||
$content = self::getDefaultSubjectDocument($subject, $type);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'subject' => $subject,
|
||||
'type' => $type,
|
||||
'content' => $content
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'ملف الوثيقة المعتمد غير موجود أو غير متاح حالياً.'
|
||||
]);
|
||||
}
|
||||
|
||||
private static function getDefaultSubjectDocument(string $subject, string $type): string
|
||||
/**
|
||||
* Serves an approved asset from a published curriculum bundle by UUID only.
|
||||
* GET /api/curriculum/assets/{assetId}
|
||||
*/
|
||||
public function getPublishedAsset(Request $request, Response $response): void
|
||||
{
|
||||
$s = strtolower($subject);
|
||||
if (str_contains($s, 'english') || str_contains($s, 'إنجليز')) {
|
||||
return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary & Word Formation\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use **a / an** for non-specific singular countable nouns.\n- Use **the** when the listener knows which specific thing is meant.\n- Use **zero article** with plural or uncountable nouns spoken in general terms.";
|
||||
$assetId = trim((string)$request->getParam('assetId', ''));
|
||||
try {
|
||||
$asset = PublishedContentService::findPublishedAsset($assetId);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Published asset lookup failed: ' . $e->getMessage());
|
||||
$response->status(503)->json([
|
||||
'status' => 'unavailable',
|
||||
'message' => 'فهرس الأصول المنشورة غير متاح حالياً.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (str_contains($s, 'physic') || str_contains($s, 'فيزياء')) {
|
||||
return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|";
|
||||
|
||||
if (!$asset) {
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'الأصل المطلوب غير منشور أو غير متاح لك.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
return "## ملخص الوحدة والنتاجات التعليمية المقررة\n\n### 1. الأهداف العامة للمادة\n- استيعاب المفاهيم والمصطلحات الأساسية وفق المنهاج الوزاري المعتمد.\n- التدرب على حل النماذج والتطبيقات العملية والأسئلة الوزارية.\n- التحقق من اكتساب المهارات من خلال بنك الأسئلة التكيفي والمختبر.";
|
||||
|
||||
$path = PublishedContentService::readLocalAsset($asset);
|
||||
if (!$path) {
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'النسخة المنشورة من الأصل غير متاحة في التخزين.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with((string)$asset['mime_type'], 'text/')) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر قراءة الأصل المنشور.']);
|
||||
return;
|
||||
}
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'asset' => self::assetMetadata($asset),
|
||||
'content' => $content,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$response->setHeader('Content-Type', (string)$asset['mime_type']);
|
||||
$response->setHeader('Content-Length', (string)$asset['byte_size']);
|
||||
$response->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"');
|
||||
$response->sendHeaders();
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function assetMetadata(array $asset): array
|
||||
{
|
||||
return [
|
||||
'asset_id' => $asset['uuid'],
|
||||
'asset_type' => $asset['asset_type'],
|
||||
'mime_type' => $asset['mime_type'],
|
||||
'byte_size' => (int)$asset['byte_size'],
|
||||
'sha256' => $asset['sha256'],
|
||||
'bundle_version' => $asset['bundle_version'],
|
||||
'curriculum_lesson_id' => $asset['curriculum_lesson_uuid'],
|
||||
'identity' => [
|
||||
'grade' => $asset['grade_key'],
|
||||
'subject' => $asset['subject_key'],
|
||||
'semester' => $asset['semester_key'],
|
||||
'unit' => $asset['unit_key'],
|
||||
'lesson' => $asset['lesson_key'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -593,4 +662,3 @@ class CurriculumController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,18 +424,7 @@ class ExamController
|
||||
/** Additive production migration for installations created before completed_at. */
|
||||
private static function ensureAttemptSchema(): void
|
||||
{
|
||||
try {
|
||||
$column = Database::selectOne(
|
||||
"SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts'
|
||||
AND COLUMN_NAME = 'completed_at' LIMIT 1"
|
||||
);
|
||||
if (empty($column['cnt'])) {
|
||||
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT NULL AFTER ai_diagnostic_report");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Exam schema migration note: ' . $e->getMessage());
|
||||
}
|
||||
// Kept for compatibility with callers. Schema is installed by migrations.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,40 +473,7 @@ class ExamController
|
||||
*/
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
try {
|
||||
Database::query(
|
||||
"CREATE TABLE IF NOT EXISTS `student_question_answers` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`attempt_id` BIGINT UNSIGNED NOT NULL,
|
||||
`student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`question_id` BIGINT UNSIGNED NOT NULL,
|
||||
`selected_option_id` BIGINT UNSIGNED NULL,
|
||||
`is_correct` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`points_awarded` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
||||
`time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY `idx_sqa_attempt` (`attempt_id`),
|
||||
KEY `idx_sqa_student` (`student_id`),
|
||||
KEY `idx_sqa_question` (`question_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
// Ensure completed_at in exam_attempts
|
||||
$colCheck = Database::selectOne(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' AND COLUMN_NAME = 'completed_at' LIMIT 1"
|
||||
);
|
||||
if (!$colCheck) {
|
||||
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER ai_diagnostic_report");
|
||||
}
|
||||
|
||||
// Safely ensure scope ENUM covers unit_comprehensive if ever passed
|
||||
try {
|
||||
Database::query("ALTER TABLE exams MODIFY COLUMN scope ENUM('in_video_checkpoint', 'lesson_exam', 'unit_exam', 'unit_comprehensive', 'semester_final') NOT NULL DEFAULT 'in_video_checkpoint'");
|
||||
} catch (\Throwable $ign) {}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("ExamController ensureSchema notice: " . $e->getMessage());
|
||||
}
|
||||
// Kept for compatibility with callers. Schema is installed by migrations.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,12 +13,7 @@ class TeacherController
|
||||
{
|
||||
private static function ensureTeacherColumns(): void
|
||||
{
|
||||
try {
|
||||
Database::query("ALTER TABLE teachers ADD COLUMN grades_taught TEXT DEFAULT NULL");
|
||||
} catch (\Throwable $e) {}
|
||||
try {
|
||||
Database::query("ALTER TABLE teachers ADD COLUMN school_name VARCHAR(255) DEFAULT NULL");
|
||||
} catch (\Throwable $e) {}
|
||||
// Compatibility hook. These columns are installed by migrations.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +103,7 @@ class TeacherController
|
||||
|
||||
$teacher = Database::selectOne("SELECT * FROM teachers WHERE id = ? LIMIT 1", [$teacherId]);
|
||||
|
||||
$metrics = Database::selectOne('SELECT weighted_student_rating, total_reviews_count FROM teacher_performance_metrics WHERE teacher_id = ? LIMIT 1', [$userId]) ?: [];
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'تم توثيق بيانات المعلم والصفوف والمباحث بنجاح!',
|
||||
@@ -143,14 +139,20 @@ class TeacherController
|
||||
[$userId]
|
||||
)['total'];
|
||||
|
||||
$metrics = Database::selectOne(
|
||||
'SELECT weighted_student_rating, total_reviews_count FROM teacher_performance_metrics WHERE teacher_id = ? LIMIT 1',
|
||||
[$userId]
|
||||
) ?: [];
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'courses_count' => $coursesCount,
|
||||
'lessons_count' => $lessonsCount,
|
||||
'students_count' => $studentsCount,
|
||||
'rating' => 4.9,
|
||||
'completion_avg' => 87.5
|
||||
'rating' => isset($metrics['weighted_student_rating']) ? (float)$metrics['weighted_student_rating'] : null,
|
||||
'rating_count' => (int)($metrics['total_reviews_count'] ?? 0),
|
||||
'completion_avg' => null
|
||||
]
|
||||
]);
|
||||
}
|
||||
@@ -413,6 +415,43 @@ class TeacherController
|
||||
return;
|
||||
}
|
||||
|
||||
// Revenue sharing is being rebuilt around provider-backed ledger entries
|
||||
// and eligible watch minutes. Do not turn course prices, platform sales,
|
||||
// or legacy payout rows into a teacher wallet in the meantime.
|
||||
$courses = Database::select(
|
||||
"SELECT c.id, c.title as course_title, c.price_jod, c.is_published,
|
||||
COUNT(l.id) as lessons_total
|
||||
FROM courses c
|
||||
LEFT JOIN lessons l ON l.course_id = c.id
|
||||
WHERE c.teacher_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY c.id ASC",
|
||||
[$teacherId]
|
||||
);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'financial_status' => 'unavailable',
|
||||
'financial_message' => 'الأرباح والسحب متوقفان حتى اكتمال دفتر الاستحقاق وربط مزود الدفع.',
|
||||
'audience_breakdown' => [
|
||||
'institutional_students' => ['count' => 0],
|
||||
'marketplace_students' => ['count' => 0],
|
||||
],
|
||||
'course_price_jod' => null,
|
||||
'gross_revenue_jod' => null,
|
||||
'revenue_split' => null,
|
||||
'wallet' => [
|
||||
'available_balance_jod' => null,
|
||||
'pending_clearance_jod' => null,
|
||||
'total_withdrawn_jod' => null,
|
||||
'cliq_payout_alias' => '',
|
||||
'recent_payouts' => [],
|
||||
],
|
||||
'courses' => $courses,
|
||||
],
|
||||
]);
|
||||
return;
|
||||
|
||||
// 1. Real Student Counts from Database
|
||||
$totalStudents = (int)(Database::selectOne("SELECT COUNT(*) as c FROM students")['c'] ?? 0);
|
||||
|
||||
|
||||
@@ -24,9 +24,169 @@ use App\Core\Security;
|
||||
use App\Services\VideoService;
|
||||
use App\Services\AiVideoAnalyzerService;
|
||||
use App\Services\CurriculumService;
|
||||
use App\Services\TeacherSubmissionService;
|
||||
use App\Services\VideoReviewService;
|
||||
|
||||
class VideoController
|
||||
{
|
||||
/** POST /api/video-versions/{versionId}/watch-sessions */
|
||||
public function startWatchSession(Request $request, Response $response): void
|
||||
{
|
||||
try {
|
||||
$result = \App\Services\WatchSessionService::start((int)$request->user_id, trim((string)$request->getParam('versionId','')), $request->getHeader('x-national-id'));
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch (\Throwable $e) { error_log('Watch session start failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر بدء جلسة المشاهدة.']); }
|
||||
}
|
||||
|
||||
/** POST /api/watch-sessions/{sessionId}/events */
|
||||
public function recordWatchEvent(Request $request, Response $response): void
|
||||
{
|
||||
$body=$request->getBody();
|
||||
try {
|
||||
$result = \App\Services\WatchSessionService::event((int)$request->user_id,trim((string)$request->getParam('sessionId','')),(int)($body['sequence_no']??0),trim((string)($body['event_type']??'')),(int)($body['position_ms']??-1),is_array($body['payload']??null)?$body['payload']:[]);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch (\Throwable $e) { error_log('Watch event failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ حدث المشاهدة.']); }
|
||||
}
|
||||
/** GET /api/video-versions/{versionId}/playback */
|
||||
public function getVideoVersionPlayback(Request $request, Response $response): void
|
||||
{
|
||||
$versionUuid = trim((string)$request->getParam('versionId', ''));
|
||||
if (!preg_match('/^[0-9a-f-]{36}$/i', $versionUuid)) {
|
||||
$response->status(400)->json(['status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.']); return;
|
||||
}
|
||||
try {
|
||||
$row = Database::selectOne(
|
||||
"SELECT vv.uuid AS video_version_id, l.id AS lesson_id, l.course_id, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, l.duration_seconds,
|
||||
cl.uuid AS curriculum_lesson_id, cl.title, 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 lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready'
|
||||
WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid]
|
||||
);
|
||||
if (!$row) { $response->status(404)->json(['status'=>'error','message'=>'نسخة الفيديو المطلوبة غير منشورة أو سُحبت.']); return; }
|
||||
$course=Database::selectOne('SELECT grade_level FROM courses WHERE id=? LIMIT 1',[$row['course_id']]);
|
||||
$access=\App\Services\StudentAccessControlService::validateLessonAccess((int)$request->user_id, $request->getHeader('x-national-id'), \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10'), (int)$row['course_id'], (int)$row['lesson_id']);
|
||||
if (empty($access['allowed'])) { $response->status(403)->json(['status'=>'forbidden','message'=>$access['message'] ?? 'غير مصرح بمشاهدة هذه الحصة.']); return; }
|
||||
if ($row['storage_type']==='api_upload') $playback=['storage_type'=>'api_upload','video_url'=>$row['hls_url'] ?: '/api/videos/stream/'.$row['video_uuid'],'hls_url'=>$row['hls_url'] ?: '/api/videos/hls/'.$row['video_uuid'].'/index.m3u8'];
|
||||
elseif (!empty($row['bunny_video_id'])) $playback=array_merge(['storage_type'=>'bunny_stream'],VideoService::generateBunnySignedPlayback($row['bunny_video_id'],10800));
|
||||
elseif (!empty($row['ai_video_url'])) $playback=['storage_type'=>'direct_url','video_url'=>$row['ai_video_url'],'hls_url'=>$row['ai_video_url']];
|
||||
else { $response->status(409)->json(['status'=>'error','message'=>'تخزين نسخة الفيديو غير جاهز.']); return; }
|
||||
$response->json(['status'=>'success','data'=>['video_version_id'=>$row['video_version_id'],'submission_id'=>$row['submission_id'],'curriculum_lesson_id'=>$row['curriculum_lesson_id'],'lesson_id'=>(int)$row['lesson_id'],'title'=>$row['title'],'duration_seconds'=>(int)$row['duration_seconds'],'playback'=>$playback,'checkpoints'=>[]]]);
|
||||
} catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); }
|
||||
}
|
||||
|
||||
/** GET /api/curriculum/lessons/{lessonId}/videos?cursor=&limit= */
|
||||
public function listPublishedLessonVideos(Request $request, Response $response): void
|
||||
{
|
||||
$lessonUuid = trim((string)$request->getParam('lessonId', ''));
|
||||
$cursor = max(0, (int)$request->getQuery('cursor', 0));
|
||||
$limit = min(50, max(1, (int)$request->getQuery('limit', 20)));
|
||||
if (!preg_match('/^[0-9a-f-]{36}$/i', $lessonUuid)) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return;
|
||||
}
|
||||
try {
|
||||
$lesson = Database::selectOne("SELECT id, uuid, title 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,
|
||||
c.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 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) {
|
||||
$access = \App\Services\StudentAccessControlService::validateLessonAccess(
|
||||
(int)$request->user_id,
|
||||
$request->getHeader('x-national-id'),
|
||||
\App\Services\StudentAccessControlService::normalizeGrade($row['grade_level'] ?? 'grade_10'),
|
||||
(int)$row['course_id'],
|
||||
(int)$row['source_lesson_id'],
|
||||
false
|
||||
);
|
||||
if (!empty($access['allowed'])) $accessible[] = $row;
|
||||
}
|
||||
// `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]]);
|
||||
} catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); }
|
||||
}
|
||||
/** POST /api/teacher/submissions/preflight */
|
||||
public function preflightSubmission(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$lessonId = trim((string)($body['curriculum_lesson_id'] ?? ''));
|
||||
$replacement = !empty($body['replacement']);
|
||||
$submissionId = isset($body['submission_id']) ? trim((string)$body['submission_id']) : null;
|
||||
$key = trim((string)$request->getHeader('idempotency-key', ''));
|
||||
try {
|
||||
$result = TeacherSubmissionService::preflight((int)$request->user_id, $lessonId, $key, $replacement, $submissionId);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Teacher submission preflight failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر إنشاء نسخة الحصة حالياً.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/admin/video-review/publish; super-admin only route. */
|
||||
public function publishReviewedVersion(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$jobId = trim((string)($body['review_job_id'] ?? ''));
|
||||
$expected = isset($body['expected_current_version_id']) && $body['expected_current_version_id'] !== ''
|
||||
? trim((string)$body['expected_current_version_id']) : null;
|
||||
try {
|
||||
$result = VideoReviewService::publishApproved($jobId, $expected);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Video publication failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر نشر نسخة الفيديو حالياً.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/admin/video-review/decision; super-admin only route. */
|
||||
public function recordVideoReviewDecision(Request $request, Response $response): void
|
||||
{
|
||||
$body=$request->getBody();
|
||||
try {
|
||||
$result=VideoReviewService::recordHumanReview(trim((string)($body['review_job_id']??'')),(int)$request->user_id,trim((string)($body['recommendation']??'')),trim((string)($body['decision']??'')),is_array($body['report']??null)?$body['report']:[]);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
|
||||
} catch(\Throwable $e){error_log('Video review decision failed: '.$e->getMessage());$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ قرار المراجعة.']);}
|
||||
}
|
||||
|
||||
/** POST /api/admin/video-review/evidence; super-admin only route. */
|
||||
public function submitVideoReviewEvidence(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
try {
|
||||
$result = VideoReviewService::submitEvidence(
|
||||
trim((string)($body['review_job_id'] ?? '')),
|
||||
(int)$request->user_id,
|
||||
trim((string)($body['transcript_asset_id'] ?? '')),
|
||||
trim((string)($body['visual_evidence_asset_id'] ?? '')),
|
||||
is_array($body['coverage'] ?? null) ? $body['coverage'] : []
|
||||
);
|
||||
$response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true]));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Video review evidence failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ أدلة فحص الفيديو.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* رفع فيديو الشرح مباشرة عبر واجهة البرمجة وتوليد HLS وبدء التحليل السقراطي بالذكاء الاصطناعي
|
||||
* POST /api/teacher/videos/upload-direct
|
||||
@@ -54,6 +214,7 @@ class VideoController
|
||||
$title = trim((string)($request->getBody()['title'] ?? $_POST['title'] ?? ''));
|
||||
$seqOrder = (int)($request->getBody()['sequence_order'] ?? $_POST['sequence_order'] ?? 1);
|
||||
$curriculumKey = trim((string)($request->getBody()['curriculum_key'] ?? $_POST['curriculum_key'] ?? ''));
|
||||
$videoVersionId = trim((string)($request->getBody()['video_version_id'] ?? $_POST['video_version_id'] ?? ''));
|
||||
|
||||
if (empty($title)) {
|
||||
$response->status(400)->json([
|
||||
@@ -62,6 +223,26 @@ class VideoController
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if ($videoVersionId === '') {
|
||||
$response->status(409)->json([
|
||||
'status' => 'submission_preflight_required',
|
||||
'message' => 'أنشئ نسخة حصة عبر preflight قبل رفع الفيديو.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!TeacherSubmissionService::reserveUpload((int)$request->user_id, $videoVersionId)) {
|
||||
$response->status(409)->json([
|
||||
'status' => 'video_version_unavailable',
|
||||
'message' => 'نسخة الفيديو استُخدمت أو لم تعد متاحة للرفع. أنشئ نسخة مرشحة جديدة.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Video version reservation failed: ' . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر حجز نسخة الفيديو للرفع.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve a teacher-owned course when the app did not explicitly select one.
|
||||
$course = null;
|
||||
@@ -177,6 +358,12 @@ class VideoController
|
||||
if ($auditId) {
|
||||
Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]);
|
||||
}
|
||||
TeacherSubmissionService::attachUploadedLesson(
|
||||
(int)$request->user_id,
|
||||
$videoVersionId,
|
||||
$lessonId,
|
||||
hash_file('sha256', (string)$_FILES['video']['tmp_name'])
|
||||
);
|
||||
|
||||
// Immediately send HTTP 201 response to Flutter client and close connection
|
||||
$responsePayload = [
|
||||
@@ -210,10 +397,8 @@ class VideoController
|
||||
$uploadResult['mime_type']
|
||||
);
|
||||
|
||||
// 2. Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation
|
||||
AiVideoAnalyzerService::processLessonAutonomously($lessonId);
|
||||
|
||||
// 3. Mark encoding as ready
|
||||
// 2. Storage readiness is not publication. The evidence-bound
|
||||
// review job controls any later release of this version.
|
||||
Database::query("UPDATE lessons SET encoding_status = 'ready' WHERE id = ?", [$lessonId]);
|
||||
} catch (\Throwable $bgError) {
|
||||
error_log("Background sync/analysis error for lesson {$lessonId}: " . $bgError->getMessage());
|
||||
@@ -672,17 +857,8 @@ class VideoController
|
||||
}
|
||||
}
|
||||
|
||||
// If lesson has no checkpoints or missing questions, generate them autonomously
|
||||
$existingCount = Database::selectOne("SELECT COUNT(*) as cnt FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
|
||||
$existingQuestions = Database::selectOne("SELECT COUNT(*) as cnt FROM questions q JOIN exams e ON q.exam_id = e.id WHERE e.lesson_id = ?", [$lessonId]);
|
||||
if (empty($existingCount['cnt']) || empty($existingQuestions['cnt'])) {
|
||||
try {
|
||||
AiVideoAnalyzerService::processLessonAutonomously($lessonId);
|
||||
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Autonomous video analysis 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(
|
||||
@@ -696,17 +872,21 @@ class VideoController
|
||||
$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 = [];
|
||||
if ($q) {
|
||||
$opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]);
|
||||
$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' => $q ? (int)$q['id'] : 0,
|
||||
'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'] ?? 'سؤال فحص فهم الفكرة:',
|
||||
'question_text' => $q['question_text'],
|
||||
'explanation' => $q['explanation_text'] ?? '',
|
||||
'options' => array_map(function ($o) {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user